Compare commits

..

No commits in common. "main" and "v0.05" have entirely different histories.
main ... v0.05

155 changed files with 5966 additions and 10527 deletions

View file

@ -1,8 +0,0 @@
**/db.sqlite
**/node_modules
**/screenshots
.gitignore
.github
.git
Dockerfile
docker-compose.yaml

3
.github/FUNDING.yml vendored
View file

@ -1 +1,2 @@
patreon: DweebUI
github: [lllllllillllllillll]
patreon: DweebUI

View file

@ -1,20 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "🤖 Dependencies"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
labels:
- "🤖 Dependencies"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
labels:
- "🤖 Dependencies"

View file

@ -1,67 +0,0 @@
name: Docker
on:
push:
branches:
- "main"
paths-ignore:
- "**.md"
- LICENSE
- "compose.yml"
- ".github/dependabot.yml"
pull_request:
branches:
- "*"
workflow_dispatch:
release:
types: [published, edited]
jobs:
build-and-publish-image:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
# Generate image tags based on semver
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/lllllllillllllillll/DweebUI
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
# Setup QEMU and Buildx for Multi-platform Support
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Only login to Registry if not running in a Pull Request
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build image and only publish if not a Pull Request
- name: Build and Publish Docker Image
uses: docker/build-push-action@v6
timeout-minutes: 30
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

5
.gitignore vendored
View file

@ -1,5 +0,0 @@
**/db.sqlite
**/node_modules
**/appdata
.github
.git

View file

@ -1,145 +1,35 @@
## v0.60 (June 9th 2024) - Permissions system and import templates
* Converted JS template literals into HTML.
* Converted modals into HTML/HTMX.
* Moved functions into dashboard controller.
* New - Modal placeholder with loading spinner.
* Container cards now update independently.
* Container cards now display pending action (starting, stopping, pausing, restarting).
* User avatars are now automatically generated.
* Updated database models.
* New - Multi-user permission system.
* Refactored dashboard to support multiple users.
* New - Banner alerts.
* New - Template importing (*.yml, *.yaml, *.json).
* Improved app search.
* New - Search by category.
* Updated dependencies.
* Removed warning from the bottom of the registration page. Will be added back in a different location.
* New - admin checks, session checks, and permission checks for router.
* Added titles to activity indicators.
* Created Github Wiki.
* Added image pull to images page.
* Images and volumes display 'In use'.
* Images display tag.
* Image pull gets latest if not set.
* Updated buttons to trigger from 'mousedown' (John Carmack + Theo told me to).
* Volumes page displays type (Volume or Bind).
* Volume button is now functional.
## v0.40 (Feb 26th 2024) - HTMX rewrite
* Pages rewritten to use HTMX.
* Removed Socket.io.
* Changed view files to *.HTML instead of *.EJS.
* Removed "USER root" from Dockerfile.
* Express sessions configured to use memorystore.
* Improved chart rendering.
* Improvements to container charts.
* Created Variables page.
* Created Supporters page.
* Ability to remove images, volumes, or networks.
* Fixed list.js sorting.
* Fixed apps.js page navigation.
* Removed stackfiles from templates.json and updated some icons.
* New logo.
* Improved handling of Docker events.
* Improved dashboard responsiveness.
* Updated server metrics styles.
* Container cards display pending action.
* Container charts only rendered if container running.
* Created permissions modal.
* Podman support (untested).
* Started a new template for FOSS apps.
## v0.20 (Jan 20th 2024) - The rewrite. Jumping all the way to v0.20.
* Changed to ES6 imports.
* Cleaned up file structure and code layout.
* Updated DweebUI logo.
* Visual tweaks to login and registration pages.
* Added .gitignore and .dockerignore files.
* Syslogs - View logs for sign-in and registration attempts. :new:
* Docker socket now uses default connection.
* Updated Users page displays 'inactive' if no sign-ins within 30 days.
* Dashboard updates now triggered by Docker events.
* Massive reduction in the amount of HTML, CSS, and JS on client side.
* Container graphs are significantly more efficent and no longer use localStorage.
* Made dark mode the default theme.
* Created intervals to allow application to idle or scale with more users.
* Pages for images, volumes, and networks. :new:
* Localized fonts.
* CORS.
* Testing with Mocha and Supertest.
* Created Portal page. :new:
## <del>v0.09 (dev)</del> dead. (It had so many problems that I essentially rewrote everything)
* Added authentication middleware to router.
* Added gzip compression.
* Added PM2.
* Added Helmet.
* Fixed missing session data.
* Reduced sqlite queries.
## v0.08 (Dec 15th 2023)
* Updates to compose file and instructions from [steveiliop56](https://github.com/steveiliop56)
* Added SECRET field to compose file as a basic security measure.
* Visibility button to hide containers or reset view.
* Container link now uses server IP address.
* More compact container card, with style options planned.
* Improved log view.
* Removed VPN, Firewall, and VNC buttons.
* Updated dependencies (Sequelize 6.35.2)
* Fixed web pages not using the "public" static folder.
* Small tweaks to router.
* Replaced the default icon shown for missing icons (docker.png).
## v0.07 (Dec 8th 2023)
* View container logs.
* Removed Redis.
* Improved uninstall function and form id fix.
* WebUI Port can be changed in compose.yml
* Code clean-up.
* Updated dependencies (systeminformation).
## v0.06 (Nov 24th 2023)
* Multi-platform image (amd64/arm64).
* Removed Caddy from compose file.
* Proxy Manager UI can be enabled from environment variable.
* Removed hardcoded redis passwords.
* Repo change: Implemented image build-and-publish and dependabot (Thank you, gaby).
* Updated dependencies.
## v0.05 (Nov 17th 2023)
* Environment Variables and Labels are now unchecked by default.
* Support for Docker volumes.
* Fixed app uninstall.
* Fixed Proxy Manager.
* Updated functions to ignore the three DweebUI containers: DweebUI, DweebCache(redis), and DweebProxy(caddy).
* Visual updates: Tabs for networks, images, and volumes. Added 'update' option in container drop-down.
* Updated main.js to prevent javascript errors.
* Fix for templates using 'set' instead of 'default' in environment variables.
* Fixes for templates with no volumes or no labels.
* New README.md.
* New screenshots.
* Automatically persists data in docker volumes if there is no bind mount.
## v0.04 (Nov 11th 2023)
* Docker Image and Compose file available.
* The containers DweebUI and DweebCache are hidden from the dashboard.
* Default icon for containers.
* Fixed missing information in container details/edit modals (Ports, Env, Volumes, Labels).
## v0.03 (Nov 5th 2023)
* Container graphs now load instantly on refresh
* Working net data for server dashboard
* Redis is now installed as a docker container.
## v0.02 (Nov 1st 2023)
* Significant code clean-up and improvements
* CPU and RAM graphs for each container
* Updated Templates.json
* Fixed text color of VPN and VNC buttons
## v0.01 (Oct 15th 2023)
* First release. Not much working.
## v0.05 ( Nov 17th 2023 )
* Environment Variables and Labels are now unchecked by default.
* Support for Docker volumes.
* Fixed app uninstall.
* Fixed Proxy Manager.
* Updated functions to ignore the three DweebUI containers: DweebUI, DweebCache(redis), and DweebProxy(caddy).
* Visual updates: Tabs for networks, images, and volumes. Added 'update' option in container drop-down.
* Updated main.js to prevent javascript errors.
* Fix for templates using 'set' instead of 'default' in environment variables.
* Fixes for templates with no volumes or no labels.
* New README.md.
* New screenshots.
* Automatically persists data in docker volumes if there is no bind mount.
## v0.04 (Nov 11th 2023)
* Docker Image and Compose file available.
* The containers DweebUI and DweebCache are hidden from the dashboard.
* Default icon for containers.
* Fixed missing information in container details/edit modals (Ports, Env, Volumes, Labels).
## v0.03 (Nov 5th 2023)
* Container graphs now load instantly on refresh
* Working net data for server dashboard
* Redis is now installed as a docker container.
## v0.02 (Nov 1st 2023)
* Significant code clean-up and improvements
* CPU and RAM graphs for each container
* Updated Templates.json
* Fixed text color of VPN and VNC buttons
## v0.01 (Oct 15th 2023)
* First release. Not much working.

View file

@ -1,7 +1,36 @@
FROM node:22-alpine
ENV NODE_ENV=production
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/engine/reference/builder/
ARG NODE_VERSION=20.0.0
FROM node:${NODE_VERSION}-alpine
# Use production node environment by default.
ENV NODE_ENV production
WORKDIR /app
COPY . /app
RUN npm install
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.npm to speed up subsequent builds.
# Leverage a bind mounts to package.json and package-lock.json to avoid having to copy them into
# into this layer.
RUN --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# Run the application as a non-root user.
USER root
# Copy the rest of the source files into the image.
COPY . .
# Expose the port that the application listens on.
EXPOSE 8000
CMD node server.js
# Run the application.
CMD node app.js

BIN
DweebUI.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

190
README.md
View file

@ -1,95 +1,95 @@
<h3 align="center"><img width="150" src="https://raw.githubusercontent.com/lllllllillllllillll/DweebUI/main/public/img/logo.png"></h3>
<h4 align="center">DweebUI Beta v0.60 ( :fire: Experimental :fire: )</h4>
<h3 align="center">Free and Open-Source WebUI For Managing Your Containers.</h3>
<p align="center">
<a href=""><img src="https://img.shields.io/github/stars/lllllllillllllillll/DweebUI?style=flat"/></a>
<a href="https://github.com/lllllllillllllillll/DweebUI%2Fdev"><img src="https://img.shields.io/github/commit-activity/y/lllllllillllllillll/DweebUI%2Fdev"/></a>
<a href="https://github.com/lllllllillllllillll/DweebUI%2Fdev"><img src="https://img.shields.io/github/last-commit/lllllllillllllillll/DweebUI%2Fdev"/></a>
<a href="https://hub.docker.com/r/lllllllillllllillll/dweebui"><img src="https://img.shields.io/docker/pulls/lllllllillllllillll/dweebui"/></a>
<a href="https://github.com/lllllllillllllillll/DweebUI/blob/main/LICENSE"><img src="https://img.shields.io/github/license/lllllllillllllillll/DweebUI"/></a>
<a href="https://www.reddit.com/r/dweebui"><img src="https://img.shields.io/badge/reddit-orange"/></a>
<a href="https://www.buymeacoffee.com/lllllllillllllillll"><img src="https://img.shields.io/badge/-buy_me_a%C2%A0coffee-gray?logo=buy-me-a-coffee"/></a>
</p>
<h3 align="center"><img width="800" src="https://raw.githubusercontent.com/lllllllillllllillll/DweebUI/main/screenshots/dashboard1.png"></h3>
## Features
* [x] A dynamically updating dashboard that displays server metrics along with container metrics and container controls.
* [x] Multi-user support with permissions system.
* [x] Container actions: Start, Stop, Pause, Restart, View Details, View Logs.
* [x] Windows, Linux, and MacOS compatable.
* [x] Light/Dark Mode.
* [x] Mobile Friendly.
* [x] Manage your Docker networks, images, and volumes.
* [x] Easy to install app templates.
* [x] Docker Compose Support.
* [ ] Update containers (planned).
* [x] Templates.json maintains compatability with Portainer, allowing you to use the template without needing to use DweebUI.
* [ ] Preset variables (planned).
* [ ] Themes (planned).
## About
* I started this as a personal project to get more familiar with Javascript and Node.js, so there may be some rough edges and spaghetti code.
* I'm open to any contributions but you may want to wait until I reach v1.0 first.
* Please post issues and discussions so I know what bugs and features to focus on.
* DweebUI is a management interface and should not be directly exposed to the internet.
## Setup
Docker Compose:
```
version: "3.9"
services:
dweebui:
container_name: dweebui
image: lllllllillllllillll/dweebui
environment:
PORT: 8000
SECRET: MrWiskers
restart: unless-stopped
ports:
- 8000:8000
volumes:
- dweebui:/app
# Docker socket
- /var/run/docker.sock:/var/run/docker.sock
# Podman socket
#- /run/podman/podman.sock:/var/run/docker.sock
networks:
- dweebui_net
volumes:
dweebui:
networks:
dweebui_net:
driver: bridge
```
[Windows and MacOS Setup](https://github.com/lllllllillllllillll/DweebUI/wiki/Setup)
Compose setup:
* Paste the above content into a file named ```docker-compose.yml``` then place it in a folder named ```dweebui```.
* Open a terminal in the ```dweebui``` folder, then enter ```docker compose up -d```.
* You may need to use ```docker-compose up -d``` or execute the command as root with either ```sudo docker compose up -d``` or ```sudo docker-compose up -d```.
Configuration:
* `PORT` - Specifies which port the service binds to on startup. Default is `8000`.
* `SECRET` - A shared secret used by the registration page.
## Credits
* Dockerode and dockerode-compose by Apocas: https://github.com/apocas/dockerode
* UI was built using HTML and CSS elements from https://tabler.io/
* Apps template based on Portainer template provided by Lissy93: https://github.com/Lissy93/portainer-templates
* Icons from Walkxcode with some renames and additions: https://github.com/walkxcode/dashboard-icons
## Supporters
* MM (Patreon)
* PD (Buymeacoffee)
# DweebUI
DweebUI is a simple Docker web interface created with javascript and node.js
Pre-Pre-Pre-Pre-Pre Alpha v0.05 ( :fire: Experimental. Don't install on any servers you care about :fire: )
[![GitHub License](https://img.shields.io/github/license/lllllllillllllillll/DweebUI)](https://github.com/lllllllillllllillll/DweebUI/blob/main/LICENSE)
[![GitHub activity](https://img.shields.io/github/commit-activity/y/lllllllillllllillll/DweebUI)](https://github.com/lllllllillllllillll)
* I haven't used Github very much and I'm still new to javascript.
* This is the first project I've ever released and I'm sure it's full of plenty of bugs and mistakes.
* I probably should have waited a lot longer to share this :|
<a href="https://raw.githubusercontent.com//lllllllillllllillll/DweebUI/main/screenshots/dashboard.png"><img src="https://raw.githubusercontent.com/lllllllillllllillll/DweebUI/main/screenshots/dashboard.png" width="50%"/></a>
<a href="https://raw.githubusercontent.com/lllllllillllllillll/DweebUI/main/screenshots/apps.png"><img src="https://raw.githubusercontent.com/lllllllillllllillll/DweebUI/main/screenshots/apps.png" width="50%"/></a>
## Features
* [x] Dashboard provides server metrics (cpu, ram, network, disk) and container controls on a single page.
* [x] Light/Dark Mode.
* [x] Easy to install app templates.
* [x] Automatically persists data in docker volumes if bind mount isn't used.
* [x] Proxy manager for Caddy.
* [x] Partial Portainer Template Support (Network Mode, Ports, Volumes, Enviroment Variables, Labels, Commands, Restart Policy, Nvidia Hardware Acceleration).
* [x] Multi-User built-in.
* [ ] User pages: Shortcuts, Requests, Support. (planned)
* [x] Support for Windows, Linux, and MacOS.
* [ ] Import compose files. (planned)
* [x] Pure javascript. No frameworks or typescript.
* [x] Templates.json maintains compatability with Portainer, allowing you to use the template without needing to use DweebUI.
* [ ] Manage your Docker networks, images, and volumes. (planned)
* [ ] Preset variables. (planned)
## Setup
* Docker compose.yaml:
```
services:
dweebui:
container_name: DweebUI
image: lllllllillllllillll/dweebui:v0.05
restart: unless-stopped
ports:
- 8000:8000
depends_on:
- cache
links:
- cache
volumes:
- dweebui:/app
- ./caddyfiles/Caddyfile:/app/caddyfiles/Caddyfile
- ./caddyfiles/sites:/app/caddyfiles/sites
- /var/run/docker.sock:/var/run/docker.sock
cache:
container_name: DweebCache
image: redis:6.2-alpine
restart: always
command: redis-server --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81
volumes:
- cache:/data
proxy:
container_name: DweebProxy
image: caddy:2.4.5-alpine
depends_on:
- dweebui
restart: unless-stopped
network_mode: host
volumes:
- caddy:/data
- caddy:/config
- ./caddyfiles/Caddyfile:/etc/caddy/Caddyfile
- ./caddyfiles/sites:/etc/caddy/sites
volumes:
dweebui:
cache:
caddy:
```
* Using setup.sh:
```
Extract DweebUI.zip and navigate to /DweebUI
cd DweebUI
chmod +x setup.sh
sudo ./setup.sh
```
## Credit
* UI was built using HTML and CSS elements from https://tabler.io/
* Apps template based on Portainer template provided by Lissy93 here: https://github.com/Lissy93/portainer-templates
* Most of the app icons were sourced from Walkxcode's dashboard icons here: https://github.com/walkxcode/dashboard-icons

104
app.js Normal file
View file

@ -0,0 +1,104 @@
const express = require("express");
const session = require("express-session");
const redis = require('connect-redis');
const app = express();
const routes = require("./routes");
const { serverStats, containerList, containerStats, containerAction } = require('./functions/system_information');
const { RefreshSites } = require('./controllers/site_actions');
let sent_list, clicked;
app.locals.site_list = '';
const redisClient = require('redis').createClient({
url: 'redis://DweebCache:6379',
password:'eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81',
legacyMode:true
});
redisClient.connect().catch(console.log);
const RedisStore = redis(session);
const sessionMiddleware = session({
store:new RedisStore({client:redisClient}),
secret: "keyboard cat",
resave: false,
saveUninitialized: false,
cookie:{
secure:false, // Only set to true if you are using HTTPS.
httpOnly:false, // Only set to true if you are using HTTPS.
maxAge:3600000 * 8// Session max age in milliseconds. 3600000 = 1 hour.
}
})
app.set('view engine', 'ejs');
app.use([
express.static("public"),
express.json(),
express.urlencoded({ extended: true }),
sessionMiddleware,
routes
]);
const server = app.listen(8000, async () => {
console.log(`App listening on port 8000`);
});
const io = require('socket.io')(server);
io.engine.use(sessionMiddleware);
io.on('connection', (socket) => {
// set user session
const user_session = socket.request.session;
console.log(`${user_session.user} connected from ${socket.handshake.headers.host} ${socket.handshake.address}`);
// check if a list of containers needs to be sent
if (sent_list != null) { socket.emit('cards', sent_list); }
// check if an install card has to be sent
if((app.locals.install != '') && (app.locals.install != null)){ socket.emit('install', app.locals.install); }
// send server metrics
let ServerStats = setInterval(async () => {
socket.emit('metrics', await serverStats());
}, 1000);
// send container list
let ContainerList = setInterval(async () => {
let card_list = await containerList();
if (sent_list !== card_list) {
sent_list = card_list;
app.locals.install = '';
socket.emit('cards', card_list);
}
}, 1000);
// send container metrics
let ContainerStats = setInterval(async () => {
let container_stats = await containerStats();
for (let i = 0; i < container_stats.length; i++) {
socket.emit('container_stats', container_stats[i]);
}
}, 1000);
// play/pause/stop/restart container
socket.on('clicked', (data) => {
if (clicked == true) { return; } clicked = true;
let buttonPress = {
user: socket.request.session.user,
role: socket.request.session.role,
action: data.action,
container: data.container,
state: data.state
}
containerAction(buttonPress);
clicked = false;
});
socket.on('disconnect', () => {
clearInterval(ServerStats);
clearInterval(ContainerList);
clearInterval(ContainerStats);
});
});

1
caddyfiles/Caddyfile Normal file
View file

@ -0,0 +1 @@
import ./sites/*

994
components/appCard.js Normal file
View file

@ -0,0 +1,994 @@
function appCard(data) {
// make data.title lowercase
let app_name = data.name || data.title.toLowerCase();
let shortened_name = "";
let shortened_desc = data.description.slice(0, 60) + "...";
let modal = app_name.replaceAll(" ", "-");
let form_id = app_name.replaceAll("-", "_");
let note = data.note ? data.note.replaceAll(". ", ".\n") : "no notes available";
let description = data.description.replaceAll(". ", ".\n") || "no description available";
let command = data.command ? data.command : "";
let command_check = command ? "checked" : "";
let privileged = data.privileged || "";
let privileged_check = privileged ? "checked" : "";
let repository = data.repository || "";
let source = data.image || "";
// if data.network is set to host, bridge, or docker set the radio button to checked
let net_host, net_bridge, net_docker = '';
let net_name = 'AppBridge';
if (data.network == 'host') {
net_host = 'checked';
} else if (data.network) {
net_bridge = 'checked';
net_name = data.network;
} else {
net_docker = 'checked';
}
if (data.title.length > 28) {
shortened_name = (data.title).slice(0, 25) + "...";
}
else {
shortened_name = data.title;
}
if (repository != "") {
source = (`${repository.url}/raw/master/${repository.stackfile}`);
}
function CatagoryColor(category) {
switch (category) {
case 'Other':
return '<span class="badge bg-blue-lt">Other</span> ';
case 'Productivity':
return '<span class="badge bg-blue-lt">Productivity</span> ';
case 'Tools':
return '<span class="badge bg-blue-lt">Tools</span> ';
case 'Dashboard':
return '<span class="badge bg-blue-lt">Dashboard</span> ';
case 'Communication':
return '<span class="badge bg-azure-lt">Communication</span> ';
case 'Media':
return '<span class="badge bg-azure-lt">Media</span> ';
case 'CMS':
return '<span class="badge bg-azure-lt">CMS</span> ';
case 'Monitoring':
return '<span class="badge bg-indigo-lt">Monitoring</span> ';
case 'LDAP':
return '<span class="badge bg-purple-lt">LDAP</span> ';
case 'Arr':
return '<span class="badge bg-purple-lt">Arr</span> ';
case 'Database':
return '<span class="badge bg-red-lt">Database</span> ';
case 'Paid':
return '<span class="badge bg-red-lt" title="This is a paid product or contains paid features.">Paid</span> ';
case 'Gaming':
return '<span class="badge bg-pink-lt">Gaming</span> ';
case 'Finance':
return '<span class="badge bg-orange-lt">Finance</span> ';
case 'Networking':
return '<span class="badge bg-yellow-lt">Networking</span> ';
case 'Authentication':
return '<span class="badge bg-lime-lt">Authentication</span> ';
case 'Development':
return '<span class="badge bg-green-lt">Development</span> ';
case 'Media Server':
return '<span class="badge bg-teal-lt">Media Server</span> ';
case 'Downloaders':
return '<span class="badge bg-cyan-lt">Downloaders</span> ';
default:
return ''; // default to other if the category is not recognized
}
}
// set data.catagories to 'other' if data.catagories is empty or undefined
if (data.categories == null || data.categories == undefined || data.categories == '') {
data.categories = ['Other'];
}
let categories = '';
for (let i = 0; i < data.categories.length; i++) {
categories += CatagoryColor(data.categories[i]);
}
if (data.restart_policy == null) {
data.restart_policy = 'unless-stopped';
}
let ports_data = [], volumes_data = [], env_data = [], label_data = [];
for (let i = 0; i < 12; i++) {
// Get port details
try {
let ports = data.ports[i];
let port_check = ports ? "checked" : "";
let port_external = ports.split(":")[0] ? ports.split(":")[0] : ports.split("/")[0];
let port_internal = ports.split(":")[1] ? ports.split(":")[1].split("/")[0] : ports.split("/")[0];
let port_protocol = ports.split("/")[1] ? ports.split("/")[1] : "";
// remove /tcp or /udp from port_external if it exists
if (port_external.includes("/")) {
port_external = port_external.split("/")[0];
}
ports_data.push({
check: port_check,
external: port_external,
internal: port_internal,
protocol: port_protocol
});
} catch {
ports_data.push({
check: "",
external: "",
internal: "",
protocol: ""
});
}
// Get volume details
try {
let volumes = data.volumes[i];
let volume_check = volumes ? "checked" : "";
let volume_bind = volumes.bind ? volumes.bind : "";
let volume_container = volumes.container ? volumes.container.split(":")[0] : "";
let volume_readwrite = "rw";
if (volumes.readonly == true) {
volume_readwrite = "ro";
}
volumes_data.push({
check: volume_check,
bind: volume_bind,
container: volume_container,
readwrite: volume_readwrite
});
} catch {
volumes_data.push({
check: "",
bind: "",
container: "",
readwrite: ""
});
}
// Get environment details
try {
let env = data.env[i];
let env_check = "";
let env_default = env.default ? env.default : "";
if (env.set) { env_default = env.set;}
let env_description = env.description ? env.description : "";
let env_label = env.label ? env.label : "";
let env_name = env.name ? env.name : "";
env_data.push({
check: env_check,
default: env_default,
description: env_description,
label: env_label,
name: env_name
});
} catch {
env_data.push({
check: "",
default: "",
description: "",
label: "",
name: ""
});
}
// Get label details
try {
let label = data.labels[i];
let label_check = "";
let label_name = label.name ? label.name : "";
let label_value = label.value ? label.value : "";
label_data.push({
check: label_check,
name: label_name,
value: label_value
});
} catch {
label_data.push({
check: "",
name: "",
value: ""
});
}
}
return `
<div class="col-md-6 col-lg-3">
<div class="card">
<div class="card-body p-4 text-center">
<span class="avatar avatar-xlplus mb-3 rounded"><img src='${data.logo}' width="144px" height="144px" loading="lazy"></img></span>
<h3 class="m-0 mb-1"><a href="#">${shortened_name}</a></h3>
<div class="text-secondary">${shortened_desc}</div>
<div class="mt-3">
${categories}
</div>
</div>
<div class="d-flex">
<a href="#" class="card-btn" data-bs-toggle="modal" data-bs-target="#${modal}-info"><!-- Download SVG icon from http://tabler-icons.io/i/mail -->
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-article" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z"></path> <path d="M7 8h10"></path> <path d="M7 12h10"></path> <path d="M7 16h10"></path></svg>
  Learn More
</a>
<a href="#" class="card-btn" data-bs-toggle="modal" data-bs-target="#${modal}-install"><!-- Download SVG icon from http://tabler-icons.io/i/phone -->
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-arrow-bar-to-down" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path d="M4 20l16 0"></path> <path d="M12 14l0 -10"></path> <path d="M12 14l4 -4"></path> <path d="M12 14l-4 -4"></path></svg>
  Install
</a>
</div>
</div>
</div>
<div class="modal modal-blur fade" id="${modal}-info" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body">
<div class="modal-title">${data.title}</div>
<div>${description}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link link-secondary me-auto" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Okay</button>
</div>
</div>
</div>
</div>
<div class="modal modal-blur fade" id="${modal}-install" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Install ${data.title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre class="text-secondary">${note}</pre>
<form action="/install" name="${form_id}_install" id="${form_id}_install" method="POST">
<div class="row mb-3 align-items-end">
<div class="col-lg-6">
<label class="form-label">Container Name: </label>
<input type="text" class="form-control" name="service_name" value="${app_name}" hidden/>
<input type="text" class="form-control" name="name" value="${app_name}"/>
</div>
<div class="col-lg-3">
<label class="form-label">Image: </label>
<input type="text" class="form-control" name="image" value="${source}"/>
</div>
<div class="col-lg-3">
<label class="form-label">Restart Policy: </label>
<select class="form-select" name="restart_policy">
<option value="${data.restart_policy}" selected hidden>${data.restart_policy}</option>
<option value="unless-stopped">unless-stopped</option>
<option value="on-failure">on-failure</option>
<option value="never">never</option>
<option value="always">always</option>
</select>
</div>
</div>
<label class="form-label">Network Mode</label>
<div class="form-selectgroup-boxes row mb-3">
<div class="col">
<label class="form-selectgroup-item">
<input type="radio" name="net_mode" value="host" class="form-selectgroup-input" ${net_host}>
<span class="form-selectgroup-label d-flex align-items-center p-3">
<span class="me-3">
<span class="form-selectgroup-check"></span>
</span>
<span class="form-selectgroup-label-content">
<span class="form-selectgroup-title strong mb-1">Host Network</span>
<span class="d-block text-secondary">Same as host. No isolation. ex.127.0.0.1</span>
</span>
</span>
</label>
</div>
<div class="col">
<label class="form-selectgroup-item">
<input type="radio" name="net_mode" value="${net_name}" class="form-selectgroup-input" ${net_bridge}>
<span class="form-selectgroup-label d-flex align-items-center p-3">
<span class="me-3">
<span class="form-selectgroup-check"></span>
</span>
<span class="form-selectgroup-label-content">
<span class="form-selectgroup-title strong mb-1">Bridge Network</span>
<span class="d-block text-secondary">Containers can communicate using names.</span>
</span>
</span>
</label>
</div>
<div class="col">
<label class="form-selectgroup-item">
<input type="radio" name="net_mode" value="docker" class="form-selectgroup-input" ${net_docker}>
<span class="form-selectgroup-label d-flex align-items-center p-3">
<span class="me-3">
<span class="form-selectgroup-check"></span>
</span>
<span class="form-selectgroup-label-content">
<span class="form-selectgroup-title strong mb-1">Docker Network</span>
<span class="d-block text-secondary">Isolated on the docker network. ex.172.0.34.2</span>
</span>
</span>
</label>
</div>
</div>
<div class="accordion" id="${modal}-accordion">
<div class="accordion-item">
<h2 class="accordion-header" id="heading-1">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-1" aria-expanded="false">
Ports
</button>
</h2>
<div id="collapse-1" class="accordion-collapse collapse" data-bs-parent="#${modal}-accordion">
<div class="accordion-body pt-0">
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="port0" type="checkbox" ${ports_data[0].check}>
</div>
<div class="col">
<label class="form-label">External Port</label>
<input type="text" class="form-control" name="port_0_external" value="${ports_data[0].external}"/>
</div>
<div class="col">
<label class="form-label">Internal Port</label>
<input type="text" class="form-control" name="port_0_internal" value="${ports_data[0].internal}"/>
</div>
<div class="col-lg-2">
<label class="form-label">Protocol</label>
<select class="form-select" name="port_0_protocol">
<option value="${ports_data[0].protocol}" selected hidden>${ports_data[0].protocol}</option>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="port1" type="checkbox" ${ports_data[1].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="port_1_external" value="${ports_data[1].external}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="port_1_internal" value="${ports_data[1].internal}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="port_1_protocol">
<option value="${ports_data[1].protocol}" selected hidden>${ports_data[1].protocol}</option>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="port2" type="checkbox" ${ports_data[2].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="port_2_external" value="${ports_data[2].external}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="port_2_internal" value="${ports_data[2].internal}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="port_2_protocol">
<option value="${ports_data[2].protocol}" selected hidden>${ports_data[2].protocol}</option>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="port3" type="checkbox" ${ports_data[3].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="port_3_external" value="${ports_data[3].external}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="port_3_internal" value="${ports_data[3].internal}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="port_3_protocol">
<option value="${ports_data[3].protocol}" selected hidden>${ports_data[3].protocol}</option>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="port4" type="checkbox" ${ports_data[4].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="port_4_external" value="${ports_data[4].external}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="port_4_internal" value="${ports_data[4].internal}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="port_4_protocol">
<option value="${ports_data[4].protocol}" selected hidden>${ports_data[4].protocol}</option>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="port5" type="checkbox" ${ports_data[5].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="port_5_external" value="${ports_data[5].external}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="port_5_internal" value="${ports_data[5].internal}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="port_5_protocol">
<option value="${ports_data[5].protocol}" selected hidden>${ports_data[5].protocol}</option>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="heading-2">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-2" aria-expanded="false">
Volumes
</button>
</h2>
<div id="collapse-2" class="accordion-collapse collapse" data-bs-parent="#${modal}-accordion">
<div class="accordion-body pt-0">
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="volume0" type="checkbox" ${volumes_data[0].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_0_bind" value="${volumes_data[0].bind}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_0_container" value="${volumes_data[0].container}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="volume_0_readwrite">
<option value="${volumes_data[0].readwrite}" selected hidden>${volumes_data[0].readwrite}</option>
<option value="rw">rw</option>
<option value="ro">ro</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="volume1" type="checkbox" ${volumes_data[1].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_1_bind" value="${volumes_data[1].bind}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_1_container" value="${volumes_data[1].container}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="volume_1_readwrite">
<option value="${volumes_data[1].readwrite}" selected hidden>${volumes_data[1].readwrite}</option>
<option value="rw">rw</option>
<option value="ro">ro</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="volume2" type="checkbox" ${volumes_data[2].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_2_bind" value="${volumes_data[2].bind}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_2_container" value="${volumes_data[2].container}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="volume_2_readwrite">
<option value="${volumes_data[2].readwrite}" selected hidden>${volumes_data[2].readwrite}</option>
<option value="rw">rw</option>
<option value="ro">ro</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="volume3" type="checkbox" ${volumes_data[3].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_3_bind" value="${volumes_data[3].bind}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_3_container" value="${volumes_data[3].container}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="volume_3_readwrite">
<option value="${volumes_data[3].readwrite}" selected hidden>${volumes_data[3].readwrite}</option>
<option value="rw">rw</option>
<option value="ro">ro</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="volume4" type="checkbox" ${volumes_data[4].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_4_bind" value="${volumes_data[4].bind}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_4_container" value="${volumes_data[4].container}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="volume_4_readwrite">
<option value="${volumes_data[4].readwrite}" selected hidden>${volumes_data[4].readwrite}</option>
<option value="rw">rw</option>
<option value="ro">ro</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="volume5" type="checkbox" ${volumes_data[5].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_5_bind" value="${volumes_data[5].bind}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="volume_5_container" value="${volumes_data[5].container}"/>
</div>
<div class="col-lg-2">
<select class="form-select" name="volume_5_readwrite">
<option value="${volumes_data[5].readwrite}" selected hidden>${volumes_data[5].readwrite}</option>
<option value="rw">rw</option>
<option value="ro">ro</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="heading-3">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-3" aria-expanded="false">
Environment Variables
</button>
</h2>
<div id="collapse-3" class="accordion-collapse collapse" data-bs-parent="#${modal}-accordion">
<div class="accordion-body pt-0">
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env0" ${env_data[0].check}>
</div>
<div class="col">
<label class="form-label">Variable</label>
<input type="text" class="form-control" name="env_0_name" value="${env_data[0].name}"/>
</div>
<div class="col">
<label class="form-label">Value</label>
<input type="text" class="form-control" name="env_0_default" value="${env_data[0].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env1" ${env_data[1].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_1_name" value="${env_data[1].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_1_default" value="${env_data[1].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env2" ${env_data[2].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_2_name" value="${env_data[2].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_2_default" value="${env_data[2].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env3" ${env_data[3].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_3_name" value="${env_data[3].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_3_default" value="${env_data[3].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env4" ${env_data[4].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_4_name" value="${env_data[4].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_4_default" value="${env_data[4].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env5" ${env_data[5].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_5_name" value="${env_data[5].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_5_default" value="${env_data[5].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env6" ${env_data[6].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_6_name" value="${env_data[6].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_6_default" value="${env_data[6].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env7" ${env_data[7].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_7_name" value="${env_data[7].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_7_default" value="${env_data[7].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env8" ${env_data[8].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_8_name" value="${env_data[8].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_8_default" value="${env_data[8].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env9" ${env_data[9].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_9_name" value="${env_data[9].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_9_default" value="${env_data[9].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env10" ${env_data[10].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_10_name" value="${env_data[10].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_10_default" value="${env_data[10].default}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="env11" ${env_data[11].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="env_11_name" value="${env_data[11].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="env_11_default" value="${env_data[11].default}"/>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="heading-4">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-4" aria-expanded="false">
Labels
</button>
</h2>
<div id="collapse-4" class="accordion-collapse collapse" data-bs-parent="#${modal}-accordion">
<div class="accordion-body pt-0">
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label0" ${label_data[0].check}>
</div>
<div class="col">
<label class="form-label">Variable</label>
<input type="text" class="form-control" name="label_0_name" value="${label_data[0].name}"/>
</div>
<div class="col">
<label class="form-label">Value</label>
<input type="text" class="form-control" name="label_0_value" value="${label_data[0].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label1" ${label_data[1].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_1_name" value="${label_data[1].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_1_value" value="${label_data[1].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label2" ${label_data[2].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_2_name" value="${label_data[2].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_2_value" value="${label_data[2].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label3" ${label_data[3].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_3_name" value="${label_data[3].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_3_value" value="${label_data[3].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label4" ${label_data[4].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_4_name" value="${label_data[4].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_4_value" value="${label_data[4].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label5" ${label_data[5].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_5_name" value="${label_data[5].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_5_value" value="${label_data[5].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label6" ${label_data[6].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_6_name" value="${label_data[6].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_6_value" value="${label_data[6].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label7" ${label_data[7].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_7_name" value="${label_data[7].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_7_value" value="${label_data[7].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label8" ${label_data[8].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_8_name" value="${label_data[8].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_8_value" value="${label_data[8].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label9" ${label_data[9].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_9_name" value="${label_data[9].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_9_value" value="${label_data[9].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label10" ${label_data[10].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_10_name" value="${label_data[10].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_10_value" value="${label_data[10].value}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" type="checkbox" name="label11" ${label_data[11].check}>
</div>
<div class="col">
<input type="text" class="form-control" name="label_11_name" value="${label_data[11].name}"/>
</div>
<div class="col">
<input type="text" class="form-control" name="label_11_value" value="${label_data[11].value}"/>
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="heading-5">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-5" aria-expanded="false">
Extras
</button>
</h2>
<div id="collapse-5" class="accordion-collapse collapse" data-bs-parent="#${modal}-accordion">
<div class="accordion-body pt-0">
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="command_check" type="checkbox" ${command_check}>
</div>
<div class="col">
<label class="form-label">Command</label>
<input type="text" class="form-control" name="command" value="${command}"/>
</div>
</div>
<div class="row mb-1 align-items-end">
<div class="col-auto">
<input class="form-check-input" name="privileged" type="checkbox" ${privileged_check}>
</div>
<div class="col">
<label class="form-label">Privileged Mode</label>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn me-auto" data-bs-dismiss="modal">Close</button>
<input type="submit" form="${form_id}_install" class="btn btn-success" value="Install"/>
</div>
</div>
</div>
</div>`;
}
module.exports = { appCard };

1162
components/dashCard.js Normal file

File diff suppressed because it is too large Load diff

18
components/siteCard.js Normal file
View file

@ -0,0 +1,18 @@
function siteCard(type, domain, host, port, id) {
let site = `<tr>`
site += `<td><input class="form-check-input m-0 align-middle" name="select${id}" value="${domain}" type="checkbox" aria-label="Select invoice"></td>`
site += `<td><span class="text-muted">${id}</span></td>`
site += `<td><a href="https://${domain}" class="text-reset" tabindex="-1" target="_blank">${domain}</a></td>`
site += `<td>${type}</td>`
site += `<td>${host}</td>`
site += `<td>${port}</td>`
site += `<td><span class="badge bg-success me-1"></span> Enabled</td>`
site += `<td><span class="badge bg-success me-1"></span> Enabled</td>`
site += `<td class="text-end"><a class="btn" href="#"> Edit </a></td>`
site += `</tr>`
return site;
}
module.exports = { siteCard };

View file

@ -1,27 +1,40 @@
version: "3.9"
services:
dweebui:
container_name: dweebui
image: lllllllillllllillll/dweebui:v0.60
environment:
PORT: 8000
SECRET: MrWiskers
container_name: DweebUI
image: lllllllillllllillll/dweebui:v0.05
restart: unless-stopped
ports:
- 8000:8000
depends_on:
- cache
links:
- cache
volumes:
- dweebui:/app/config
# Docker socket
- dweebui:/app
- ./caddyfiles/Caddyfile:/app/caddyfiles/Caddyfile
- ./caddyfiles/sites:/app/caddyfiles/sites
- /var/run/docker.sock:/var/run/docker.sock
# Podman socket
#- /run/podman/podman.sock:/var/run/docker.sock
networks:
- dweebui_net
cache:
container_name: DweebCache
image: redis:6.2-alpine
restart: always
command: redis-server --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81
volumes:
- cache:/data
proxy:
container_name: DweebProxy
image: caddy:2.4.5-alpine
depends_on:
- dweebui
restart: unless-stopped
network_mode: host
volumes:
- caddy:/data
- caddy:/config
- ./caddyfiles/Caddyfile:/etc/caddy/Caddyfile
- ./caddyfiles/sites:/etc/caddy/sites
volumes:
dweebui:
networks:
dweebui_net:
driver: bridge
cache:
caddy:

View file

@ -1,20 +1,22 @@
import { User } from "../database/models.js";
export const Account = async (req, res) => {
let user = await User.findOne({ where: { UUID: req.session.UUID }});
res.render("account", {
first_name: user.name,
last_name: user.name,
name: user.name,
id: user.id,
email: user.email,
role: user.role,
avatar: req.session.user.charAt(0).toUpperCase(),
alert: '',
});
const User = require('../database/UserModel');
exports.Account = async function(req, res) {
if (req.session.user) {
// Get the user.
let user = await User.findOne({ where: { UUID: req.session.UUID }});
// Render the home page
res.render("pages/account", {
first_name: user.first_name,
last_name: user.last_name,
name: user.first_name + ' ' + user.last_name,
id: user.id,
email: user.email,
role: user.role,
avatar: user.avatar,
isLoggedIn: true
});
} else {
// Redirect to the login page
res.redirect("/login");
}
}

View file

@ -1,587 +1,192 @@
import { readFileSync, readdirSync, renameSync, mkdirSync, unlinkSync, existsSync } from 'fs';
import { parse } from 'yaml';
import multer from 'multer';
import AdmZip from 'adm-zip';
const User = require('../database/UserModel');
const { appCard } = require('../components/appCard')
const { dashCard } = require('../components/dashCard');
const upload = multer({storage: multer.diskStorage({
destination: function (req, file, cb) { cb(null, 'templates/tmp/') },
filename: function (req, file, cb) { cb(null, file.originalname) },
})});
const { install, uninstall } = require('../functions/package_manager');
let alert = '';
let templates_global = '';
let json_templates = '';
let remove_button = '';
// import { install, uninstall } from '../functions/package_manager';
export const Apps = async (req, res) => {
let page = Number(req.params.page) || 1;
let template_param = req.params.template || 'default';
const templates_json = require('../templates.json');
let templates = templates_json.templates;
if ((template_param != 'default') && (template_param != 'compose')) {
remove_button = `<a href="/remove_template/${template_param}" class="btn" hx-confirm="Are you sure you want to remove this template?">Remove</a>`;
} else {
remove_button = '';
}
json_templates = '';
let json_files = readdirSync('templates/json/');
for (let i = 0; i < json_files.length; i++) {
if (json_files[i] != 'default.json') {
let filename = json_files[i].split('.')[0];
let link = `<li><a class="dropdown-item" href="/apps/1/${filename}">${filename}</a></li>`
json_templates += link;
// sort templates alphabetically
templates = templates.sort((a, b) => {
if (a.name < b.name) {
return -1;
}
}
let apps_list = '';
let app_count = '';
let list_start = (page - 1) * 28;
let list_end = (page * 28);
let last_page = '';
let pages = `<li class="page-item"><a class="page-link" href="/apps/1/${template_param}">1</a></li>
<li class="page-item"><a class="page-link" href="/apps/2/${template_param}">2</a></li>
<li class="page-item"><a class="page-link" href="/apps/3/${template_param}">3</a></li>
<li class="page-item"><a class="page-link" href="/apps/4/${template_param}">4</a></li>
<li class="page-item"><a class="page-link" href="/apps/5/${template_param}">5</a></li>`
let prev = '/apps/' + (page - 1) + '/' + template_param;
let next = '/apps/' + (page + 1) + '/' + template_param;
if (page == 1) { prev = '/apps/' + (page) + '/' + template_param; }
if (page == last_page) { next = '/apps/' + (page) + '/' + template_param;}
if (template_param == 'compose') {
let compose_files = readdirSync('templates/compose/');
app_count = compose_files.length;
last_page = Math.ceil(compose_files.length/28);
compose_files.forEach(file => {
if (file == '.gitignore') { return; }
let compose = readFileSync(`templates/compose/${file}/compose.yaml`, 'utf8');
let compose_data = parse(compose);
let service_name = Object.keys(compose_data.services)
let container = compose_data.services[service_name].container_name;
let image = compose_data.services[service_name].image;
let appCard = readFileSync('./views/partials/appCard.html', 'utf8');
appCard = appCard.replace(/AppName/g, service_name);
appCard = appCard.replace(/AppShortName/g, service_name);
appCard = appCard.replace(/AppDesc/g, 'Compose File');
appCard = appCard.replace(/AppLogo/g, `https://raw.githubusercontent.com/lllllllillllllillll/DweebUI-Icons/main/${service_name}.png`);
appCard = appCard.replace(/AppCategories/g, '<span class="badge bg-orange-lt">Compose</span> ');
appCard = appCard.replace(/AppType/g, 'compose');
apps_list += appCard;
});
} else {
let template_file = readFileSync(`./templates/json/${template_param}.json`);
let templates = JSON.parse(template_file).templates;
templates = templates.sort((a, b) => { if (a.name < b.name) { return -1; } });
app_count = templates.length;
templates_global = templates;
apps_list = '';
for (let i = list_start; i < list_end && i < templates.length; i++) {
let appCard = readFileSync('./views/partials/appCard.html', 'utf8');
let name = templates[i].name || templates[i].title.toLowerCase();
let title = templates[i].title || templates[i].name;
let desc = templates[i].description.slice(0, 60) + "...";
let description = templates[i].description.replaceAll(". ", ".\n") || "no description available";
let note = templates[i].note ? templates[i].note.replaceAll(". ", ".\n") : "no notes available";
let image = templates[i].image;
let logo = templates[i].logo;
let categories = '';
// set data.catagories to 'other' if data.catagories is empty or undefined
if (templates[i].categories == null || templates[i].categories == undefined || templates[i].categories == '') {
templates[i].categories = ['Other'];
}
// loop through the categories and add the badge to the card
for (let j = 0; j < templates[i].categories.length; j++) {
categories += CatagoryColor(templates[i].categories[j]);
}
appCard = appCard.replace(/AppName/g, name);
appCard = appCard.replace(/AppTitle/g, title);
appCard = appCard.replace(/AppShortName/g, name);
appCard = appCard.replace(/AppDesc/g, desc);
appCard = appCard.replace(/AppLogo/g, logo);
appCard = appCard.replace(/AppCategories/g, categories);
appCard = appCard.replace(/AppType/g, 'json');
apps_list += appCard;
}
}
res.render("apps", {
name: req.session.user,
role: req.session.role,
avatar: req.session.user.charAt(0).toUpperCase(),
list_start: list_start + 1,
list_end: list_end,
app_count: app_count,
prev: prev,
next: next,
apps_list: apps_list,
alert: alert,
template_list: '',
json_templates: json_templates,
pages: pages,
remove_button: remove_button
});
alert = '';
}
export const removeTemplate = async (req, res) => {
let template = req.params.template;
unlinkSync(`templates/json/${template}.json`);
res.redirect('/apps');
}
export const appSearch = async (req, res) => {
let search = req.body.search;
let page = Number(req.params.page) || 1;
let template_param = req.params.template || 'default';
let template_file = readFileSync(`./templates/json/${template_param}.json`);
let templates = JSON.parse(template_file).templates;
templates = templates.sort((a, b) => {
if (a.name < b.name) { return -1; }
});
let pages = `<li class="page-item"><a class="page-link" href="/apps/1/${template_param}">1</a></li>
<li class="page-item"><a class="page-link" href="/apps/2/${template_param}">2</a></li>
<li class="page-item"><a class="page-link" href="/apps/3/${template_param}">3</a></li>
<li class="page-item"><a class="page-link" href="/apps/4/${template_param}">4</a></li>
<li class="page-item"><a class="page-link" href="/apps/5/${template_param}">5</a></li>`
let list_start = (page-1)*28;
let list_end = (page*28);
let last_page = Math.ceil(templates.length/28);
let prev = '/apps/' + (page-1);
let next = '/apps/' + (page+1);
if (page == 1) { prev = '/apps/' + (page); }
if (page == last_page) { next = '/apps/' + (page); }
exports.Apps = async function(req, res) {
if (req.session.role == "admin") {
let apps_list = '';
let results = [];
let [cat_1, cat_2, cat_3] = ['','',''];
// Get the user.
let user = await User.findOne({ where: { UUID: req.session.UUID }});
function searchTemplates(terms) {
terms = terms.toLowerCase();
for (let i = 0; i < templates.length; i++) {
if (templates[i].categories) {
if (templates[i].categories[0]) {
cat_1 = (templates[i].categories[0]).toLowerCase();
let page = Number(req.query.page) || 1;
let list_start = (page - 1) * 28;
let list_end = (page * 28);
let last_page = Math.ceil(templates.length / 28);
let prev = '/apps?page=' + (page - 1);
let next = '/apps?page=' + (page + 1);
if (page == 1) {
prev = '/apps?page=' + (page);
}
if (templates[i].categories[1]) {
cat_2 = (templates[i].categories[1]).toLowerCase();
if (page == last_page) {
next = '/apps?page=' + (page);
}
if (templates[i].categories[2]) {
cat_3 = (templates[i].categories[2]).toLowerCase();
}
}
if ((templates[i].description.includes(terms)) || (templates[i].name.includes(terms)) || (templates[i].title.includes(terms)) || (cat_1.includes(terms)) || (cat_2.includes(terms)) || (cat_3.includes(terms))){
results.push(templates[i]);
}
}
}
searchTemplates(search);
for (let i = 0; i < results.length; i++) {
let appCard = readFileSync('./views/partials/appCard.html', 'utf8');
let name = results[i].name || results[i].title.toLowerCase();
let desc = results[i].description.slice(0, 60) + "...";
let description = results[i].description.replaceAll(". ", ".\n") || "no description available";
let note = results[i].note ? results[i].note.replaceAll(". ", ".\n") : "no notes available";
let image = results[i].image;
let logo = results[i].logo;let categories = '';
// set data.catagories to 'other' if data.catagories is empty or undefined
if (results[i].categories == null || results[i].categories == undefined || results[i].categories == '') {
results[i].categories = ['Other'];
}
// loop through the categories and add the badge to the card
for (let j = 0; j < results[i].categories.length; j++) {
categories += CatagoryColor(results[i].categories[j]);
}
appCard = appCard.replace(/AppName/g, name);
appCard = appCard.replace(/AppShortName/g, name);
appCard = appCard.replace(/AppDesc/g, desc);
appCard = appCard.replace(/AppLogo/g, logo);
appCard = appCard.replace(/AppCategories/g, categories);
appCard = appCard.replace(/AppType/g, 'json');
let apps_list = '';
for (let i = list_start; i < list_end && i < templates.length; i++) {
let app_card = appCard(templates[i]);
apps_list += appCard;
}
res.render("apps", {
name: req.session.user,
role: req.session.role,
avatar: req.session.user.charAt(0).toUpperCase(),
list_start: list_start + 1,
list_end: list_end,
app_count: results.length,
prev: prev,
next: next,
apps_list: apps_list,
alert: alert,
template_list: '',
json_templates: json_templates,
pages: pages,
remove_button: remove_button
});
}
function CatagoryColor(category) {
switch (category) {
case 'Other':
return '<span class="badge bg-blue-lt">Other</span> ';
case 'Productivity':
return '<span class="badge bg-blue-lt">Productivity</span> ';
case 'Tools':
return '<span class="badge bg-blue-lt">Tools</span> ';
case 'Dashboard':
return '<span class="badge bg-blue-lt">Dashboard</span> ';
case 'Communication':
return '<span class="badge bg-azure-lt">Communication</span> ';
case 'Media':
return '<span class="badge bg-azure-lt">Media</span> ';
case 'CMS':
return '<span class="badge bg-azure-lt">CMS</span> ';
case 'Monitoring':
return '<span class="badge bg-indigo-lt">Monitoring</span> ';
case 'LDAP':
return '<span class="badge bg-purple-lt">LDAP</span> ';
case 'Arr':
return '<span class="badge bg-purple-lt">Arr</span> ';
case 'Database':
return '<span class="badge bg-red-lt">Database</span> ';
case 'Paid':
return '<span class="badge bg-red-lt" title="This is a paid product or contains paid features.">Paid</span> ';
case 'Gaming':
return '<span class="badge bg-pink-lt">Gaming</span> ';
case 'Finance':
return '<span class="badge bg-orange-lt">Finance</span> ';
case 'Networking':
return '<span class="badge bg-yellow-lt">Networking</span> ';
case 'Authentication':
return '<span class="badge bg-lime-lt">Authentication</span> ';
case 'Development':
return '<span class="badge bg-green-lt">Development</span> ';
case 'Media Server':
return '<span class="badge bg-teal-lt">Media Server</span> ';
case 'Downloaders':
return '<span class="badge bg-cyan-lt">Downloaders</span> ';
default:
return ''; // default to other if the category is not recognized
}
}
export const InstallModal = async (req, res) => {
let input = req.header('hx-trigger-name');
let type = req.header('hx-trigger');
if (type == 'compose') {
let compose = readFileSync(`templates/compose/${input}/compose.yaml`, 'utf8');
let modal = readFileSync('./views/modals/compose.html', 'utf8');
modal = modal.replace(/AppName/g, input);
modal = modal.replace(/COMPOSE_CONTENT/g, compose);
res.send(modal);
return;
} else {
let result = templates_global.find(t => t.name == input);
let name = result.name || result.title.toLowerCase();
let short_name = name.slice(0, 25) + "...";
let desc = result.description.replaceAll(". ", ".\n") || "no description available";
let short_desc = desc.slice(0, 60) + "...";
let modal_name = name.replaceAll(" ", "-");
let form_id = name.replaceAll("-", "_");
let note = result.note ? result.note.replaceAll(". ", ".\n") : "no notes available";
let command = result.command ? result.command : "";
let command_check = command ? "checked" : "";
let privileged = result.privileged || "";
let privileged_check = privileged ? "checked" : "";
let repository = result.repository || "";
let image = result.image || "";
let net_host, net_bridge, net_docker = '';
let net_name = 'AppBridge';
let restart_policy = result.restart_policy || 'unless-stopped';
switch (result.network) {
case 'host':
net_host = 'checked';
break;
case 'bridge':
net_bridge = 'checked';
net_name = result.network;
break;
default:
net_docker = 'checked';
}
if (repository != "") {
image = (`${repository.url}/raw/master/${repository.stackfile}`);
}
let [ports_data, volumes_data, env_data, label_data] = [[], [], [], []];
for (let i = 0; i < 12; i++) {
// Get port details
try {
let ports = result.ports[i];
let port_check = ports ? "checked" : "";
let port_external = ports.split(":")[0] ? ports.split(":")[0] : ports.split("/")[0];
let port_internal = ports.split(":")[1] ? ports.split(":")[1].split("/")[0] : ports.split("/")[0];
let port_protocol = ports.split("/")[1] ? ports.split("/")[1] : "";
// remove /tcp or /udp from port_external if it exists
if (port_external.includes("/")) {
port_external = port_external.split("/")[0];
apps_list += app_card;
}
ports_data.push({
check: port_check,
external: port_external,
internal: port_internal,
protocol: port_protocol
// Render the home page
res.render("pages/apps", {
name: user.first_name + ' ' + user.last_name,
role: user.role,
avatar: user.avatar,
isLoggedIn: true,
list_start: list_start + 1,
list_end: list_end,
app_count: templates.length,
prev: prev,
next: next,
apps_list: apps_list
});
} catch {
ports_data.push({
check: "",
external: "",
internal: "",
protocol: ""
});
}
// Get volume details
try {
let volumes = result.volumes[i];
let volume_check = volumes ? "checked" : "";
let volume_bind = volumes.bind ? volumes.bind : "";
let volume_container = volumes.container ? volumes.container.split(":")[0] : "";
let volume_readwrite = "rw";
if (volumes.readonly == true) {
volume_readwrite = "ro";
}
volumes_data.push({
check: volume_check,
bind: volume_bind,
container: volume_container,
readwrite: volume_readwrite
});
} catch {
volumes_data.push({
check: "",
bind: "",
container: "",
readwrite: ""
});
}
// Get environment details
try {
let env = result.env[i];
let env_check = "";
let env_default = env.default ? env.default : "";
if (env.set) { env_default = env.set;}
let env_description = env.description ? env.description : "";
let env_label = env.label ? env.label : "";
let env_name = env.name ? env.name : "";
env_data.push({
check: env_check,
default: env_default,
description: env_description,
label: env_label,
name: env_name
});
} catch {
env_data.push({
check: "",
default: "",
description: "",
label: "",
name: ""
});
}
// Get label details
try {
let label = result.labels[i];
let label_check = "";
let label_name = label.name ? label.name : "";
let label_value = label.value ? label.value : "";
label_data.push({
check: label_check,
name: label_name,
value: label_value
});
} catch {
label_data.push({
check: "",
name: "",
value: ""
});
}
} else {
// Redirect to the login page
res.redirect("/login");
}
let modal = readFileSync('./views/modals/json.html', 'utf8');
modal = modal.replace(/AppName/g, name);
modal = modal.replace(/AppNote/g, note);
modal = modal.replace(/AppImage/g, image);
modal = modal.replace(/RestartPolicy/g, restart_policy);
modal = modal.replace(/NetHost/g, net_host);
modal = modal.replace(/NetBridge/g, net_bridge);
modal = modal.replace(/NetDocker/g, net_docker);
modal = modal.replace(/NetName/g, net_name);
modal = modal.replace(/ModalName/g, modal_name);
modal = modal.replace(/FormId/g, form_id);
modal = modal.replace(/CommandCheck/g, command_check);
modal = modal.replace(/CommandValue/g, command);
modal = modal.replace(/PrivilegedCheck/g, privileged_check);
}
for (let i = 0; i < 12; i++) {
modal = modal.replaceAll(`Port${i}Check`, ports_data[i].check);
modal = modal.replaceAll(`Port${i}External`, ports_data[i].external);
modal = modal.replaceAll(`Port${i}Internal`, ports_data[i].internal);
modal = modal.replaceAll(`Port${i}Protocol`, ports_data[i].protocol);
modal = modal.replaceAll(`Volume${i}Check`, volumes_data[i].check);
modal = modal.replaceAll(`Volume${i}Bind`, volumes_data[i].bind);
modal = modal.replaceAll(`Volume${i}Container`, volumes_data[i].container);
modal = modal.replaceAll(`Volume${i}RW`, volumes_data[i].readwrite);
modal = modal.replaceAll(`Env${i}Check`, env_data[i].check);
modal = modal.replaceAll(`Env${i}Default`, env_data[i].default);
modal = modal.replaceAll(`Env${i}Description`, env_data[i].description);
modal = modal.replaceAll(`Env${i}Label`, env_data[i].label);
modal = modal.replaceAll(`Env${i}Name`, env_data[i].name);
exports.searchApps = async function(req, res) {
if (req.session.role == "admin") {
modal = modal.replaceAll(`Label${i}Check`, label_data[i].check);
modal = modal.replaceAll(`Label${i}Name`, label_data[i].name);
modal = modal.replaceAll(`Label${i}Value`, label_data[i].value);
// Get the user.
let user = await User.findOne({ where: { UUID: req.session.UUID }});
let page = Number(req.query.page) || 1;
let list_start = (page - 1) * 28;
let list_end = (page * 28);
let last_page = Math.ceil(templates.length / 28);
let prev = '/apps?page=' + (page - 1);
let next = '/apps?page=' + (page + 1);
if (page == 1) {
prev = '/apps?page=' + (page);
}
if (page == last_page) {
next = '/apps?page=' + (page);
}
let apps_list = '';
let search_results = [];
let search = req.body.search;
// split value of search into an array of words
search = search.split(' ');
try {console.log(search[0]);} catch (error) {}
try {console.log(search[1]);} catch (error) {}
try {console.log(search[2]);} catch (error) {}
function searchTemplates(word) {
for (let i = 0; i < templates.length; i++) {
if ((templates[i].description.includes(word)) || (templates[i].name.includes(word)) || (templates[i].title.includes(word))) {
search_results.push(templates[i]);
}
}
// console.log(search_results);
}
searchTemplates(search);
for (let i = 0; i < search_results.length; i++) {
let app_card = appCard(search_results[i]);
apps_list += app_card;
}
// Render the home page
res.render("pages/apps", {
name: user.first_name + ' ' + user.last_name,
role: user.role,
avatar: user.avatar,
isLoggedIn: true,
list_start: list_start + 1,
list_end: list_end,
app_count: templates.length,
prev: prev,
next: next,
apps_list: apps_list
});
} else {
// Redirect to the login page
res.redirect("/login");
}
res.send(modal);
}
}
export const LearnMore = async (req, res) => {
let name = req.header('hx-trigger-name');
let id = req.header('hx-trigger');
if (id == 'compose') {
let modal = readFileSync('./views/modals/learnmore.html', 'utf8');
modal = modal.replace(/AppName/g, name);
modal = modal.replace(/AppDesc/g, 'Compose File');
res.send(modal);
return;
}
let result = templates_global.find(t => t.name == name);
let modal = readFileSync('./views/modals/learnmore.html', 'utf8');
modal = modal.replace(/AppName/g, result.title);
modal = modal.replace(/AppDesc/g, result.description);
res.send(modal);
}
export const ImportModal = async (req, res) => {
let modal = readFileSync('./views/modals/import.html', 'utf8');
res.send(modal);
}
export const Upload = (req, res) => {
upload.array('files', 10)(req, res, () => {
alert = `<div class="alert alert-success alert-dismissible mb-0 py-2" role="alert">
<div class="d-flex">
<div><svg xmlns="http://www.w3.org/2000/svg" class="icon alert-icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M5 12l5 5l10 -10"></path></svg> </div>
<div>Template(s) Uploaded!</div>
</div>
<a class="btn-close" data-bs-dismiss="alert" aria-label="close" style="padding-top: 0.5rem;"></a>
</div>`;
let exists_alert = `<div class="alert alert-danger alert-dismissible mb-0 py-2" role="alert">
<div class="d-flex">
<div><svg xmlns="http://www.w3.org/2000/svg" class="icon alert-icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M5 12l5 5l10 -10"></path></svg> </div>
<div>Template already exists</div>
</div>
<a class="btn-close" data-bs-dismiss="alert" aria-label="close" style="padding-top: 0.5rem;"></a>
</div>`;
exports.Install = async function (req, res) {
let files = readdirSync('templates/tmp/');
if (req.session.role == "admin") {
for (let i = 0; i < files.length; i++) {
if (files[i].endsWith('.zip')) {
let zip = new AdmZip(`templates/tmp/${files[i]}`);
zip.extractAllTo('templates/compose', true);
unlinkSync(`templates/tmp/${files[i]}`);
} else if (files[i].endsWith('.json')) {
if (existsSync(`templates/json/${files[i]}`)) {
unlinkSync(`templates/tmp/${files[i]}`);
alert = exists_alert;
res.redirect('/apps');
return;
console.log(`Starting install for: ${req.body.name}`)
install(req.body);
let container_info = {
name: req.body.name,
service: req.body.service_name,
state: 'installing',
image: req.body.image,
restart_policy: req.body.restart_policy
}
renameSync(`templates/tmp/${files[i]}`, `templates/json/${files[i]}`);
} else if (files[i].endsWith('.yml')) {
let compose = readFileSync(`templates/tmp/${files[i]}`, 'utf8');
let compose_data = parse(compose);
let service_name = Object.keys(compose_data.services);
if (existsSync(`templates/compose/${service_name}`)) {
unlinkSync(`templates/tmp/${files[i]}`);
alert = exists_alert;
res.redirect('/apps');
return;
let installCard = dashCard(container_info);
req.app.locals.install = installCard;
// Redirect to the home page
res.redirect("/");
} else {
// Redirect to the login page
res.redirect("/login");
}
}
exports.Uninstall = async function (req, res) {
if (req.session.role == "admin") {
if (req.body.confirm == 'Yes') {
uninstall(req.body);
}
mkdirSync(`templates/compose/${service_name}`);
renameSync(`templates/tmp/${files[i]}`, `templates/compose/${service_name}/compose.yaml`);
} else if (files[i].endsWith('.yaml')) {
let compose = readFileSync(`templates/tmp/${files[i]}`, 'utf8');
let compose_data = parse(compose);
let service_name = Object.keys(compose_data.services);
if (existsSync(`templates/compose/${service_name}`)) {
unlinkSync(`templates/tmp/${files[i]}`);
alert = exists_alert;
res.redirect('/apps');
return;
}
mkdirSync(`templates/compose/${service_name}`);
renameSync(`templates/tmp/${files[i]}`, `templates/compose/${service_name}/compose.yaml`);
} else {
// unsupported file type
unlinkSync(`templates/tmp/${files[i]}`);
}
}
res.redirect('/apps');
});
};
// Redirect to the home page
res.redirect("/");
} else {
// Redirect to the login page
res.redirect("/login");
}
}

View file

@ -1,448 +1,23 @@
import { Readable } from 'stream';
import { Permission, User } from '../database/models.js';
import { docker } from '../server.js';
import { dockerContainerStats } from 'systeminformation';
import { readFileSync } from 'fs';
import { currentLoad, mem, networkStats, fsSize } from 'systeminformation';
import { Op } from 'sequelize';
let hidden = '';
let alert = '';
let [ cardList, newCards, stats ] = [ '', '', {}];
let [ports_data, volumes_data, env_data, label_data] = [[], [], [], []];
// The page
export const Dashboard = (req, res) => {
let name = req.session.user;
let role = req.session.role;
alert = req.session.alert;
res.render("dashboard", {
name: name,
avatar: name.charAt(0).toUpperCase(),
role: role,
alert: alert,
});
}
// The page actions
export const DashboardAction = async (req, res) => {
let name = req.header('hx-trigger-name');
let value = req.header('hx-trigger');
let action = req.params.action;
let modal = '';
switch (action) {
case 'permissions':
let title = name.charAt(0).toUpperCase() + name.slice(1);
let permissions_list = '';
let permissions_modal = readFileSync('./views/modals/permissions.html', 'utf8');
permissions_modal = permissions_modal.replace(/PermissionsTitle/g, title);
permissions_modal = permissions_modal.replace(/PermissionsContainer/g, name);
let users = await User.findAll({ attributes: ['username', 'UUID']});
for (let i = 0; i < users.length; i++) {
let user_permissions = readFileSync('./views/partials/user_permissions.html', 'utf8');
let exists = await Permission.findOne({ where: {containerName: name, user: users[i].username}});
if (!exists) { const newPermission = await Permission.create({ containerName: name, user: users[i].username, userID: users[i].UUID}); }
let permissions = await Permission.findOne({ where: {containerName: name, user: users[i].username}});
if (permissions.uninstall == true) { user_permissions = user_permissions.replace(/data-UninstallCheck/g, 'checked'); }
if (permissions.edit == true) { user_permissions = user_permissions.replace(/data-EditCheck/g, 'checked'); }
if (permissions.upgrade == true) { user_permissions = user_permissions.replace(/data-UpgradeCheck/g, 'checked'); }
if (permissions.start == true) { user_permissions = user_permissions.replace(/data-StartCheck/g, 'checked'); }
if (permissions.stop == true) { user_permissions = user_permissions.replace(/data-StopCheck/g, 'checked'); }
if (permissions.pause == true) { user_permissions = user_permissions.replace(/data-PauseCheck/g, 'checked'); }
if (permissions.restart == true) { user_permissions = user_permissions.replace(/data-RestartCheck/g, 'checked'); }
if (permissions.logs == true) { user_permissions = user_permissions.replace(/data-LogsCheck/g, 'checked'); }
if (permissions.view == true) { user_permissions = user_permissions.replace(/data-ViewCheck/g, 'checked'); }
user_permissions = user_permissions.replace(/EntryNumber/g, i);
user_permissions = user_permissions.replace(/EntryNumber/g, i);
user_permissions = user_permissions.replace(/EntryNumber/g, i);
user_permissions = user_permissions.replace(/PermissionsUsername/g, users[i].username);
user_permissions = user_permissions.replace(/PermissionsUsername/g, users[i].username);
user_permissions = user_permissions.replace(/PermissionsUsername/g, users[i].username);
user_permissions = user_permissions.replace(/PermissionsContainer/g, name);
user_permissions = user_permissions.replace(/PermissionsContainer/g, name);
user_permissions = user_permissions.replace(/PermissionsContainer/g, name);
permissions_list += user_permissions;
}
permissions_modal = permissions_modal.replace(/PermissionsList/g, permissions_list);
res.send(permissions_modal);
return;
case 'uninstall':
modal = readFileSync('./views/modals/uninstall.html', 'utf8');
modal = modal.replace(/AppName/g, name);
res.send(modal);
return;
case 'details':
modal = readFileSync('./views/modals/details.html', 'utf8');
let details = await containerInfo(name);
modal = modal.replace(/AppName/g, details.name);
modal = modal.replace(/AppImage/g, details.image);
for (let i = 0; i <= 6; i++) {
modal = modal.replaceAll(`Port${i}Check`, details.ports[i]?.check || '');
modal = modal.replaceAll(`Port${i}External`, details.ports[i]?.external || '');
modal = modal.replaceAll(`Port${i}Internal`, details.ports[i]?.internal || '');
modal = modal.replaceAll(`Port${i}Protocol`, details.ports[i]?.protocol || '');
}
for (let i = 0; i <= 6; i++) {
modal = modal.replaceAll(`Vol${i}Source`, details.volumes[i]?.Source || '');
modal = modal.replaceAll(`Vol${i}Destination`, details.volumes[i]?.Destination || '');
modal = modal.replaceAll(`Vol${i}RW`, details.volumes[i]?.RW || '');
}
const User = require('../database/UserModel');
for (let i = 0; i <= 19; i++) {
modal = modal.replaceAll(`Label${i}Key`, Object.keys(details.labels)[i] || '');
modal = modal.replaceAll(`Label${i}Value`, Object.values(details.labels)[i] || '');
}
exports.Dashboard = async function (req, res) {
// console.log(details.env);
for (let i = 0; i <= 19; i++) {
modal = modal.replaceAll(`Env${i}Key`, details.env[i]?.split('=')[0] || '');
modal = modal.replaceAll(`Env${i}Value`, details.env[i]?.split('=')[1] || '');
}
if (req.session.role == "admin") {
res.send(modal);
return;
case 'updates':
res.send(newCards);
newCards = '';
return;
case 'card':
await userCards(req.session);
if (!req.session.container_list.find(c => c.container === name)) {
res.send('');
return;
} else {
let details = await containerInfo(name);
let card = await createCard(details);
res.send(card);
return;
}
case 'logs':
let logString = '';
let options = { follow: true, stdout: true, stderr: false, timestamps: false };
docker.getContainer(name).logs(options, function (err, stream) {
if (err) { console.log(err); return; }
const readableStream = Readable.from(stream);
readableStream.on('data', function (chunk) {
logString += chunk.toString('utf8');
});
readableStream.on('end', function () {
res.send(`<pre>${logString}</pre>`);
});
});
return;
case 'hide':
let user = req.session.user;
let exists = await Permission.findOne({ where: {containerName: name, user: user}});
if (!exists) { const newPermission = await Permission.create({ containerName: name, user: user, hide: true, userID: req.session.UUID}); }
else { exists.update({ hide: true }); }
hidden = await Permission.findAll({ where: {user: user, hide: true}}, { attributes: ['containerName'] });
hidden = hidden.map((container) => container.containerName);
res.send("ok");
return;
case 'reset':
await Permission.update({ hide: false }, { where: { user: req.session.user } });
res.send("ok");
return;
case 'alert':
req.session.alert = '';
res.send('');
return;
// get user data with matching UUID from sqlite database
let user = await User.findOne({ where: { UUID: req.session.UUID } });
// Render the home page
res.render("pages/dashboard", {
name: user.first_name + ' ' + user.last_name,
role: user.role,
avatar: user.avatar,
isLoggedIn: true,
site_list: req.app.locals.site_list,
});
} else {
// Redirect to the login page
res.redirect("/login");
}
function status (state) {
return(`<span class="text-yellow align-items-center lh-1"><svg xmlns="http://www.w3.org/2000/svg" class="icon-tabler icon-tabler-point-filled" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path d="M12 7a5 5 0 1 1 -4.995 5.217l-.005 -.217l.005 -.217a5 5 0 0 1 4.995 -4.783z" stroke-width="0" fill="currentColor"></path></svg>
${state}
</span>`);
}
// Container actions
if ((action == 'start') && (value == 'stopped')) {
docker.getContainer(name).start();
res.send(status('starting'));
} else if ((action == 'start') && (value == 'paused')) {
docker.getContainer(name).unpause();
res.send(status('starting'));
} else if ((action == 'stop') && (value != 'stopped')) {
docker.getContainer(name).stop();
res.send(status('stopping'));
} else if ((action == 'pause') && (value == 'paused')) {
docker.getContainer(name).unpause();
res.send(status('starting'));
} else if ((action == 'pause') && (value == 'running')) {
docker.getContainer(name).pause();
res.send(status('pausing'));
} else if (action == 'restart') {
docker.getContainer(name).restart();
res.send(status('restarting'));
}
}
async function containerInfo (containerName) {
// get the container info
let container = docker.getContainer(containerName);
let info = await container.inspect();
let image = info.Config.Image;
// grab the service name from the end of the image name
let service = image.split('/').pop();
// remove the tag from the service name if it exists
try { service = service.split(':')[0]; } catch {}
let ports_list = [];
let external = 0;
let internal = 0;
try {
for (const [key, value] of Object.entries(info.HostConfig.PortBindings)) {
let ports = {
check: 'checked',
external: value[0].HostPort,
internal: key.split('/')[0],
protocol: key.split('/')[1]
}
ports_list.push(ports);
}
} catch {}
try {
external = ports_list[0].external;
internal = ports_list[0].internal;
} catch {}
// console.log(ports_list);
// console.log(info.HostConfig.PortBindings);
// console.log(info.HostConfig.Binds);
// console.log(info.Config.Env);
// console.log(info.Config.Labels);
let details = {
name: containerName,
image: image,
service: service,
state: info.State.Status,
external_port: external,
internal_port: internal,
ports: ports_list,
volumes: info.Mounts,
env: info.Config.Env,
labels: info.Config.Labels,
link: 'localhost',
}
return details;
}
async function createCard (details) {
let shortname = details.name.slice(0, 10) + '...';
let trigger = 'data-hx-trigger="load, every 3s"';
let state = details.state;
let card = readFileSync('./views/partials/containerFull.html', 'utf8');
let state_color = '';
switch (state) {
case 'running':
state_color = 'green';
break;
case 'exited':
state = 'stopped';
state_color = 'red';
trigger = 'data-hx-trigger="load"';
break;
case 'paused':
state_color = 'orange';
trigger = 'data-hx-trigger="load"';
break;
case 'installing':
state_color = 'blue';
trigger = 'data-hx-trigger="load"';
break;
}
// if (name.startsWith('dweebui')) { disable = 'disabled=""'; }
card = card.replace(/AppName/g, details.name);
card = card.replace(/AppShortName/g, shortname);
card = card.replace(/AppIcon/g, details.service);
card = card.replace(/AppState/g, state);
card = card.replace(/StateColor/g, state_color);
card = card.replace(/ExternalPort/g, details.external_port);
card = card.replace(/InternalPort/g, details.internal_port);
card = card.replace(/ChartName/g, details.name.replace(/-/g, ''));
card = card.replace(/AppNameState/g, `${details.name}State`);
card = card.replace(/data-trigger=""/, trigger);
return card;
}
async function userCards (session) {
session.container_list = [];
// check what containers the user wants hidden
let hidden = await Permission.findAll({ where: {user: session.user, hide: true}}, { attributes: ['containerName'] });
hidden = hidden.map((container) => container.containerName);
// check what containers the user has permission to view
let visable = await Permission.findAll({ where: { user: session.user, [Op.or]: [{ uninstall: true }, { edit: true }, { upgrade: true }, { start: true }, { stop: true }, { pause: true }, { restart: true }, { logs: true }, { view: true }] } });
visable = visable.map((container) => container.containerName);
// get all containers
let containers = await docker.listContainers({ all: true });
// loop through containers
for (let i = 0; i < containers.length; i++) {
let container_name = containers[i].Names[0].replace('/', '');
// skip hidden containers
if (hidden.includes(container_name)) { continue; }
// admin can see all containers that they don't have hidden
if (session.role == 'admin') { session.container_list.push({ container: container_name, state: containers[i].State }); }
// user can see any containers that they have any permissions for
else if (visable.includes(container_name)){ session.container_list.push({ container: container_name, state: containers[i].State }); }
}
// create a sent list if it doesn't exist
if (!session.sent_list) { session.sent_list = []; }
if (!session.update_list) { session.update_list = []; }
if (!session.new_cards) { session.new_cards = []; }
}
async function updateDashboard (session) {
let container_list = session.container_list;
let sent_list = session.sent_list;
session.new_cards = [];
session.update_list = [];
// loop through the containers list
container_list.forEach(info => {
let { container, state } = info;
let sent = sent_list.find(c => c.container === container);
if (!sent) { session.new_cards.push(container);}
else if (sent.state !== state) { session.update_list.push(container); }
});
// loop through the sent list to see if any containers have been removed
sent_list.forEach(info => {
let { container } = info;
let exists = container_list.find(c => c.container === container);
if (!exists) { session.update_list.push(container); }
});
}
// HTMX server-side events
export const SSE = async (req, res) => {
// set the headers for server-sent events
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
// check for container changes every 500ms
let eventCheck = setInterval(async () => {
await userCards(req.session);
// check if the cards displayed are the same as what's in the session
if ((JSON.stringify(req.session.container_list) === JSON.stringify(req.session.sent_list))) { return; }
await updateDashboard(req.session);
for (let i = 0; i < req.session.new_cards.length; i++) {
let details = await containerInfo(req.session.new_cards[i]);
let card = await createCard(details);
newCards += card;
req.session.alert = '';
}
for (let i = 0; i < req.session.update_list.length; i++) {
res.write(`event: ${req.session.update_list[i]}\n`);
res.write(`data: 'update cards'\n\n`);
}
res.write(`event: update\n`);
res.write(`data: 'update cards'\n\n`);
req.session.sent_list = req.session.container_list.slice();
}, 500);
req.on('close', () => {
clearInterval(eventCheck);
});
};
// Server metrics (CPU, RAM, TX, RX, DISK)
export const Stats = async (req, res) => {
let name = req.header('hx-trigger-name');
let color = req.header('hx-trigger');
let value = 0;
switch (name) {
case 'CPU':
await currentLoad().then(data => { value = Math.round(data.currentLoad); });
break;
case 'RAM':
await mem().then(data => { value = Math.round((data.active / data.total) * 100); });
break;
case 'NET':
let [down, up, percent] = [0, 0, 0];
await networkStats().then(data => { down = Math.round(data[0].rx_bytes / (1024 * 1024)); up = Math.round(data[0].tx_bytes / (1024 * 1024)); percent = Math.round((down / 1000) * 100); });
let net = `<div class="font-weight-medium"><label class="cpu-text mb-1">Down:${down}MB Up:${up}MB</label></div>
<div class="cpu-bar meter animate ${color}"><span style="width:20%"><span></span></span></div>`;
res.send(net);
return;
case 'DISK':
await fsSize().then(data => { value = data[0].use; });
break;
}
let info = `<div class="font-weight-medium"> <label class="cpu-text mb-1">${name} ${value}%</label></div>
<div class="cpu-bar meter animate ${color}"> <span style="width:${value}%"><span></span></span> </div>`;
res.send(info);
}
// Imported by utils/install.js
export async function addAlert (session, type, message) {
session.alert = `<div class="alert alert-${type} alert-dismissible py-2 mb-0" role="alert" id="alert">
<div class="d-flex">
<div class="spinner-border text-info nav-link">
<span class="visually-hidden">Loading...</span>
</div>
<div>
  ${message}
</div>
</div>
<button class="btn-close" data-hx-post="/dashboard/alert" data-hx-trigger="click" data-hx-target="#alert" data-hx-swap="outerHTML" style="padding-top: 0.5rem;" ></button>
</div>`;
}
export const UpdatePermissions = async (req, res) => {
let { user, container, reset_permissions } = req.body;
let id = req.header('hx-trigger');
if (reset_permissions) {
await Permission.update({ uninstall: false, edit: false, upgrade: false, start: false, stop: false, pause: false, restart: false, logs: false, view: false }, { where: { containerName: container} });
return;
}
await Permission.update({ uninstall: false, edit: false, upgrade: false, start: false, stop: false, pause: false, restart: false, logs: false }, { where: { containerName: container, user: user } });
Object.keys(req.body).forEach(async function(key) {
if (key != 'user' && key != 'container') {
let permissions = req.body[key];
if (permissions.includes('uninstall')) { await Permission.update({ uninstall: true }, { where: {containerName: container, user: user}}); }
if (permissions.includes('edit')) { await Permission.update({ edit: true }, { where: {containerName: container, user: user}}); }
if (permissions.includes('upgrade')) { await Permission.update({ upgrade: true }, { where: {containerName: container, user: user}}); }
if (permissions.includes('start')) { await Permission.update({ start: true }, { where: {containerName: container, user: user}}); }
if (permissions.includes('stop')) { await Permission.update({ stop: true }, { where: {containerName: container, user: user}}); }
if (permissions.includes('pause')) { await Permission.update({ pause: true }, { where: {containerName: container, user: user}}); }
if (permissions.includes('restart')) { await Permission.update({ restart: true }, { where: {containerName: container, user: user}}); }
if (permissions.includes('logs')) { await Permission.update({ logs: true }, { where: {containerName: container, user: user}}); }
if (permissions.includes('view')) { await Permission.update({ view: true }, { where: {containerName: container, user: user}}); }
}
});
if (id == 'submit') {
res.send('<button class="btn" type="button" id="confirmed" hx-post="/updatePermissions" hx-swap="outerHTML" hx-trigger="load delay:2s">Update ✔️</button>');
return;
} else if (id == 'confirmed') {
res.send('<button class="btn" type="button" id="submit" hx-post="/updatePermissions" hx-vals="#updatePermissions" hx-swap="outerHTML">Update </button>');
return;
}
}
// Container charts
export const Chart = async (req, res) => {
let name = req.header('hx-trigger-name');
if (!stats[name]) { stats[name] = { cpuArray: Array(15).fill(0), ramArray: Array(15).fill(0) }; }
const info = await dockerContainerStats(name);
stats[name].cpuArray.push(Math.round(info[0].cpuPercent));
stats[name].ramArray.push(Math.round(info[0].memPercent));
stats[name].cpuArray = stats[name].cpuArray.slice(-15);
stats[name].ramArray = stats[name].ramArray.slice(-15);
let chart = `
<script>
${name}chart.updateSeries([{
data: [${stats[name].cpuArray}]
}, {
data: [${stats[name].ramArray}]
}])
</script>`
res.send(chart);
}

View file

@ -1,110 +0,0 @@
import { docker } from '../server.js';
import { addAlert } from './dashboard.js';
export const Images = async function(req, res) {
let action = req.params.action;
if (action == "remove") {
let images = req.body.select;
if (typeof(images) == 'string') {
images = [images];
}
for (let i = 0; i < images.length; i++) {
if (images[i] != 'on') {
try {
console.log(`Removing image: ${images[i]}`);
let image = docker.getImage(images[i]);
await image.remove();
} catch (error) {
console.log(`Unable to remove image: ${images[i]}`);
}
}
}
res.redirect("/images");
return;
} else if (action == "add") {
let image = req.body.image;
let tag = req.body.tag || 'latest';
try {
console.log(`Pulling image: ${image}:${tag}`);
await docker.pull(`${image}:${tag}`);
} catch (error) {
console.log(`Unable to pull image: ${image}:${tag}`);
}
res.redirect("/images");
return;
}
let containers = await docker.listContainers({ all: true });
let container_images = [];
for (let i = 0; i < containers.length; i++) {
container_images.push(containers[i].Image);
}
let images = await docker.listImages({ all: true });
let image_list = `
<thead>
<tr>
<th class="w-1"><input class="form-check-input m-0 align-middle" name="select" type="checkbox" aria-label="Select all" onclick="selectAll()"></th>
<th><label class="table-sort" data-sort="sort-name">Name</label></th>
<th><label class="table-sort" data-sort="sort-type">Tag</label></th>
<th><label class="table-sort" data-sort="sort-city">ID</label></th>
<th><label class="table-sort" data-sort="sort-score">Status</label></th>
<th><label class="table-sort" data-sort="sort-date">Created</label></th>
<th><label class="table-sort" data-sort="sort-quantity">Size</label></th>
<th><label class="table-sort" data-sort="sort-progress">Action</label></th>
</tr>
</thead>
<tbody class="table-tbody">`
for (let i = 0; i < images.length; i++) {
let name = '';
let tag = '';
try { name = images[i].RepoTags[0].split(':')[0]; } catch {}
try { tag = images[i].RepoTags[0].split(':')[1]; } catch {}
let date = new Date(images[i].Created * 1000);
let created = date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
let size = images[i].Size / 1000 / 1000; // to match docker desktop
size = size.toFixed(2);
let status = '';
if (container_images.includes(images[i].RepoTags[0])) {
status = 'In use';
}
let details = `
<tr>
<td><input class="form-check-input m-0 align-middle" name="select" value="${images[i].Id}" type="checkbox" aria-label="Select"></td>
<td class="sort-name">${name}</td>
<td class="sort-type">${tag}</td>
<td class="sort-city">${images[i].Id}</td>
<td class="sort-score text-green">${status}</td>
<td class="sort-date" data-date="1628122643">${created}</td>
<td class="sort-quantity">${size} MB</td>
<td class="text-end"><a class="btn" href="#"><svg xmlns="http://www.w3.org/2000/svg" class="icon-tabler icon-tabler-player-play" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M7 4v16l13 -8z"></path></svg></a></td>
</tr>`
image_list += details;
}
image_list += `</tbody>`
res.render("images", {
name: req.session.user,
role: req.session.role,
avatar: req.session.user.charAt(0).toUpperCase(),
image_list: image_list,
image_count: images.length,
alert: '',
});
}

View file

@ -1,71 +1,60 @@
import { User, Syslog } from '../database/models.js';
import bcrypt from 'bcrypt';
const User = require('../database/UserModel');
const bcrypt = require('bcrypt');
export const Login = function(req,res){
if (req.session.user) { res.redirect("/logout"); }
else { res.render("login",{ "error":"", }); }
}
exports.Login = function(req,res){
export const submitLogin = async function(req,res){
let { email, password } = req.body;
email = email.toLowerCase();
if (email && password) {
let existingUser = await User.findOne({ where: {email:email}});
if (existingUser) {
let match = await bcrypt.compare(password,existingUser.password);
if (match) {
let currentDate = new Date();
let newLogin = currentDate.toLocaleString();
await User.update({lastLogin: newLogin}, {where: {UUID:existingUser.UUID}});
req.session.user = existingUser.username;
req.session.UUID = existingUser.UUID;
req.session.role = existingUser.role;
req.session.avatar = existingUser.avatar;
const syslog = await Syslog.create({
user: req.session.user,
email: email,
event: "Successful Login",
message: "User logged in successfully",
ip: req.socket.remoteAddress
});
res.redirect("/dashboard");
} else {
const syslog = await Syslog.create({
user: null,
email: email,
event: "Bad Login",
message: "Invalid password",
ip: req.socket.remoteAddress
});
res.render("login",{
"error":"Invalid password",
});
}
} else {
res.render("login",{
"error":"User with that email does not exist.",
});
}
} else {
res.status(400);
res.render("login",{
"error":"Please fill in all the fields.",
// check whether we have a session
if(req.session.user){
// Redirect to log out.
res.redirect("/logout");
}else{
// Render the login page.
res.render("pages/login",{
"error":"",
"isLoggedIn": false
});
}
}
exports.processLogin = async function(req,res){
// get the data.
let email = req.body.email;
let password = req.body.password;
// check if we have data.
if(email && password){
// check if the user exists.
let existingUser = await User.findOne({ where: {email:email}});
if(existingUser){
// compare the password.
let match = await bcrypt.compare(password,existingUser.password);
if(match){
// set the session.
req.session.user = existingUser.username;
req.session.UUID = existingUser.UUID;
req.session.role = existingUser.role;
export const Logout = function(req,res){
req.session.destroy(() => {
res.redirect("/login");
});
// Redirect to the home page.
res.redirect("/");
}else{
// return an error.
res.render("pages/login",{
"error":"Invalid password",
isLoggedIn: false
});
}
}else{
// return an error.
res.render("pages/login",{
"error":"User with that email does not exist.",
isLoggedIn:false
});
}
}else{
res.status(400);
res.render("pages/login",{
"error":"Please fill in all the fields.",
isLoggedIn:false
});
}
}

6
controllers/logout.js Normal file
View file

@ -0,0 +1,6 @@
exports.Logout = function(req,res){
// clear the session.
req.session.destroy();
// Redirect to the login page.
res.redirect("/login");
}

View file

@ -1,89 +0,0 @@
import { docker } from '../server.js';
export const Networks = async function(req, res) {
let container_networks = [];
// List all containers
let containers = await docker.listContainers({ all: true });
for (let i = 0; i < containers.length; i++) {
let network_name = containers[i].HostConfig.NetworkMode;
try { container_networks.push(containers[i].NetworkSettings.Networks[network_name].NetworkID) } catch {}
}
let networks = await docker.listNetworks({ all: true });
let network_list = `
<thead>
<tr>
<th class="w-1"><input class="form-check-input m-0 align-middle" name="select" type="checkbox" aria-label="Select all" onclick="selectAll()"></th>
<th><label class="table-sort" data-sort="sort-name">Name</label></th>
<th><label class="table-sort" data-sort="sort-city">ID</label></th>
<th><label class="table-sort" data-sort="sort-score">Status</label></th>
<th><label class="table-sort" data-sort="sort-date">Created</label></th>
<th><label class="table-sort" data-sort="sort-progress">Action</label></th>
</tr>
</thead>
<tbody class="table-tbody">`
for (let i = 0; i < networks.length; i++) {
// let date = new Date(images[i].Created * 1000);
// let created = date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
let status = '';
if (container_networks.includes(networks[i].Id)) {
status = `In use`;
}
let details = `
<tr>
<td><input class="form-check-input m-0 align-middle" name="select" value="${networks[i].Id}" type="checkbox" aria-label="Select"></td>
<td class="sort-name">${networks[i].Name}</td>
<td class="sort-city">${networks[i].Id}</td>
<td class="sort-score text-green">${status}</td>
<td class="sort-date" data-date="1628122643">${networks[i].Created}</td>
<td class="text-end"><a class="btn" href="#">Details</a></td>
</tr>`
network_list += details;
}
network_list += `</tbody>`
res.render("networks", {
name: req.session.user,
role: req.session.role,
avatar: req.session.user.charAt(0).toUpperCase(),
network_list: network_list,
network_count: networks.length,
alert: '',
});
}
export const removeNetwork = async function(req, res) {
let networks = req.body.select;
if (typeof(networks) == 'string') {
networks = [networks];
}
for (let i = 0; i < networks.length; i++) {
if (networks[i] != 'on') {
try {
console.log(`Removing network: ${networks[i]}`);
let network = docker.getNetwork(networks[i]);
await network.remove();
} catch (error) {
console.log(`Unable to remove network: ${networks[i]}`);
}
}
}
res.redirect("/networks");
}

View file

@ -1,389 +0,0 @@
import { Readable } from 'stream';
import { Permission, Container, User } from '../database/models.js';
import { docker } from '../server.js';
import { readFileSync } from 'fs';
let hidden = '';
// The actual page
export const Portal = (req, res) => {
let name = req.session.user;
let role = req.session.role;
let avatar = name.charAt(0).toUpperCase();
res.render("portal", {
name: name,
avatar: avatar,
role: role,
alert: '',
});
}
async function CardList () {
let name = req.session.user;
let containers = await Permission.findAll({ attributes: ['containerName'], where: { user: name }});
for (let i = 0; i < containers.length; i++) {
let details = await containerInfo(containers[i].containerName);
let card = await createCard(details);
cardList += card;
}
}
export const UserContainers = async (req, res) => {
let cardList = '';
let name = req.session.user;
let containers = await Permission.findAll({ attributes: ['containerName'], where: { user: name }});
for (let i = 0; i < containers.length; i++) {
if (containers[i].containerName == null) { continue; }
let details = await containerInfo(containers[i].containerName);
let card = await createCard(details);
cardList += card;
}
res.send(cardList);
}
async function containerInfo (containerName) {
let container = docker.getContainer(containerName);
let info = await container.inspect();
let image = info.Config.Image.split('/');
let ports_list = [];
try {
for (const [key, value] of Object.entries(info.HostConfig.PortBindings)) {
let ports = {
check: 'checked',
external: value[0].HostPort,
internal: key.split('/')[0],
protocol: key.split('/')[1]
}
ports_list.push(ports);
}
} catch {
// no exposed ports
}
let external = 0;
let internal = 0;
try {
external = ports_list[0].external;
internal = ports_list[0].internal;
} catch {
// no exposed ports
}
let details = {
name: containerName,
image: image,
service: image[image.length - 1].split(':')[0],
state: info.State.Status,
external_port: external,
internal_port: internal,
ports: ports_list,
link: 'localhost',
}
return details;
}
async function createCard (details) {
if (hidden.includes(details.name)) { return;}
let shortname = details.name.slice(0, 10) + '...';
let trigger = 'data-hx-trigger="load, every 3s"';
let state = details.state;
let state_color = '';
switch (state) {
case 'running':
state_color = 'green';
break;
case 'exited':
state = 'stopped';
state_color = 'red';
trigger = 'data-hx-trigger="load"';
break;
case 'paused':
state_color = 'orange';
trigger = 'data-hx-trigger="load"';
break;
case 'installing':
state_color = 'blue';
trigger = 'data-hx-trigger="load"';
break;
}
// if (name.startsWith('dweebui')) { disable = 'disabled=""'; }
let card = readFileSync('./views/partials/containerSimple.html', 'utf8');
card = card.replace(/AppName/g, details.name);
card = card.replace(/AppShortName/g, shortname);
card = card.replace(/AppIcon/g, details.service);
card = card.replace(/AppState/g, state);
card = card.replace(/StateColor/g, state_color);
card = card.replace(/ExternalPort/g, details.external_port);
card = card.replace(/InternalPort/g, details.internal_port);
card = card.replace(/ChartName/g, details.name.replace(/-/g, ''));
card = card.replace(/AppNameState/g, `${details.name}State`);
card = card.replace(/data-trigger=""/, trigger);
return card;
}
let [ cardList, newCards, containersArray, sentArray, updatesArray ] = [ '', '', [], [], [] ];
export async function addCard (name, state) {
console.log(`Adding card for ${name}: ${state}`);
let details = {
name: name,
image: name,
service: name,
state: 'installing',
external_port: 0,
internal_port: 0,
ports: [],
link: 'localhost',
}
createCard(details).then(card => {
cardList += card;
});
}
// HTMX server-side events
export const SSE = async (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
let eventCheck = setInterval(async () => {
// builds array of containers and their states
containersArray = [];
await docker.listContainers({ all: true }).then(containers => {
containers.forEach(container => {
let name = container.Names[0].replace('/', '');
if (!hidden.includes(name)) { // if not hidden
containersArray.push({ container: name, state: container.State });
}
});
});
if ((JSON.stringify(containersArray) !== JSON.stringify(sentArray))) {
cardList = '';
newCards = '';
containersArray.forEach(container => {
const { container: containerName, state } = container;
const existingContainer = sentArray.find(c => c.container === containerName);
if (!existingContainer) {
containerInfo(containerName).then(details => {
createCard(details).then(card => {
newCards += card;
});
});
res.write(`event: update\n`);
res.write(`data: 'update cards'\n\n`);
} else if (existingContainer.state !== state) {
updatesArray.push(containerName);
}
containerInfo(containerName).then(details => {
createCard(details).then(card => {
cardList += card;
});
});
});
sentArray.forEach(container => {
const { container: containerName } = container;
const existingContainer = containersArray.find(c => c.container === containerName);
if (!existingContainer) {
updatesArray.push(containerName);
}
});
for (let i = 0; i < updatesArray.length; i++) {
res.write(`event: ${updatesArray[i]}\n`);
res.write(`data: 'update cards'\n\n`);
}
updatesArray = [];
sentArray = containersArray.slice();
}
}, 500);
req.on('close', () => {
clearInterval(eventCheck);
});
};
export const updateCards = async (req, res) => {
console.log('updateCards called');
res.send(newCards);
newCards = '';
}
export const Containers = async (req, res) => {
CardList();
// res.send(cardList);
}
export const Card = async (req, res) => {
let name = req.header('hx-trigger-name');
console.log(`${name} requesting updated card`);
// return nothing if in hidden or not found in containersArray
if (hidden.includes(name) || !containersArray.find(c => c.container === name)) {
res.send('');
return;
} else {
let details = await containerInfo(name);
let card = await createCard(details);
res.send(card);
}
}
function status (state) {
let status = `<span class="text-yellow align-items-center lh-1">
<svg xmlns="http://www.w3.org/2000/svg" class="icon-tabler icon-tabler-point-filled" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path d="M12 7a5 5 0 1 1 -4.995 5.217l-.005 -.217l.005 -.217a5 5 0 0 1 4.995 -4.783z" stroke-width="0" fill="currentColor"></path></svg>
${state}
</span>`;
return status;
}
export const Logs = (req, res) => {
let name = req.header('hx-trigger-name');
function containerLogs (data) {
return new Promise((resolve, reject) => {
let logString = '';
var options = { follow: false, stdout: true, stderr: false, timestamps: false };
var containerName = docker.getContainer(data);
containerName.logs(options, function (err, stream) {
if (err) { reject(err); return; }
const readableStream = Readable.from(stream);
readableStream.on('data', function (chunk) {
logString += chunk.toString('utf8');
});
readableStream.on('end', function () {
resolve(logString);
});
});
});
};
containerLogs(name).then((data) => {
res.send(`<pre>${data}</pre> `)
});
}
export const Action = async (req, res) => {
let name = req.header('hx-trigger-name');
let state = req.header('hx-trigger');
let action = req.params.action;
// Start
if ((action == 'start') && (state == 'stopped')) {
var containerName = docker.getContainer(name);
containerName.start();
res.send(status('starting'));
} else if ((action == 'start') && (state == 'paused')) {
var containerName = docker.getContainer(name);
containerName.unpause();
res.send(status('starting'));
// Stop
} else if ((action == 'stop') && (state != 'stopped')) {
var containerName = docker.getContainer(name);
containerName.stop();
res.send(status('stopping'));
// Pause
} else if ((action == 'pause') && (state == 'paused')) {
var containerName = docker.getContainer(name);
containerName.unpause();
res.send(status('starting'));
} else if ((action == 'pause') && (state == 'running')) {
var containerName = docker.getContainer(name);
containerName.pause();
res.send(status('pausing'));
// Restart
} else if (action == 'restart') {
var containerName = docker.getContainer(name);
containerName.restart();
res.send(status('restarting'));
// Hide
} else if (action == 'hide') {
let exists = await Container.findOne({ where: {name: name}});
if (!exists) {
const newContainer = await Container.create({ name: name, visibility: false, });
} else {
exists.update({ visibility: false });
}
hidden = await Container.findAll({ where: {visibility:false}});
hidden = hidden.map((container) => container.name);
res.send("ok");
// Reset View
} else if (action == 'reset') {
await Container.update({ visibility: true }, { where: {} });
hidden = await Container.findAll({ where: {visibility:false}});
hidden = hidden.map((container) => container.name);
res.send("ok");
}
}
export const Modals = async (req, res) => {
let name = req.header('hx-trigger-name');
let id = req.header('hx-trigger');
let title = name.charAt(0).toUpperCase() + name.slice(1);
if (id == 'permissions') {
let permissions_list = '';
let permissions_modal = readFileSync('./views/modals/permissions.html', 'utf8');
permissions_modal = permissions_modal.replace(/PermissionsTitle/g, title);
let users = await User.findAll({ attributes: ['username', 'UUID']});
for (let i = 0; i < users.length; i++) {
let user_permissions = readFileSync('./views/partials/user_permissions.html', 'utf8');
let exists = await Permission.findOne({ where: {containerName: name, user: users[i].username}});
if (!exists) {
const newPermission = await Permission.create({ containerName: name, user: users[i].username, userID: users[i].UUID});
}
let permissions = await Permission.findOne({ where: {containerName: name, user: users[i].username}});
if (permissions.uninstall == true) { user_permissions = user_permissions.replace(/data-UninstallCheck/g, 'checked'); }
if (permissions.edit == true) { user_permissions = user_permissions.replace(/data-EditCheck/g, 'checked'); }
if (permissions.upgrade == true) { user_permissions = user_permissions.replace(/data-UpgradeCheck/g, 'checked'); }
if (permissions.start == true) { user_permissions = user_permissions.replace(/data-StartCheck/g, 'checked'); }
if (permissions.stop == true) { user_permissions = user_permissions.replace(/data-StopCheck/g, 'checked'); }
if (permissions.pause == true) { user_permissions = user_permissions.replace(/data-PauseCheck/g, 'checked'); }
if (permissions.restart == true) { user_permissions = user_permissions.replace(/data-RestartCheck/g, 'checked'); }
if (permissions.logs == true) { user_permissions = user_permissions.replace(/data-LogsCheck/g, 'checked'); }
user_permissions = user_permissions.replace(/EntryNumber/g, i);
user_permissions = user_permissions.replace(/PermissionsUsername/g, users[i].username);
user_permissions = user_permissions.replace(/PermissionsContainer/g, name);
permissions_list += user_permissions;
}
permissions_modal = permissions_modal.replace(/PermissionsList/g, permissions_list);
res.send(permissions_modal);
return;
}
if (id == 'uninstall') {
let modal = readFileSync('./views/modals/uninstall.html', 'utf8');
modal = modal.replace(/AppName/g, name);
// let containerPermissions = await Permission.findAll({ where: {containerName: name}});
res.send(modal);
return;
}
let modal = readFileSync('./views/modals/details.html', 'utf8');
let details = await containerInfo(name);
modal = modal.replace(/AppName/g, details.name);
modal = modal.replace(/AppImage/g, details.image);
res.send(modal);
}

View file

@ -1,104 +1,83 @@
import { User, Syslog, Permission } from '../database/models.js';
import bcrypt from 'bcrypt';
const User = require('../database/UserModel');
const bcrypt = require('bcrypt');
let SECRET = process.env.SECRET || "MrWiskers"
export const Register = function(req,res){
exports.Register = function(req,res){
// Check whether we have a session
if(req.session.user){
// Redirect to log out.
res.redirect("/logout");
} else {
res.render("register",{
// Render the signup page.
res.render("pages/register",{
"error":"",
isLoggedIn:false
});
}
}
exports.processRegister = async function(req,res){
// Get the data.
let { first_name, last_name, username, email, password, avatar, tos } = req.body;
let role = "user";
export const submitRegister = async function(req,res){
// Check the data.
if(first_name && last_name && email && password && username && tos){
let { name, username, email, password, confirmPassword, secret } = req.body;
email = email.toLowerCase();
// Check if there is an existing user with that username.
let existingUser = await User.findOne({ where: {username:username}});
let adminUser = await User.findOne({ where: {role:"admin"}});
if (secret != SECRET) {
const syslog = await Syslog.create({
user: username,
email: email,
event: "Failed Registration",
message: "Invalid secret",
ip: req.socket.remoteAddress
});
}
if((name && email && password && confirmPassword && username) && (secret == SECRET) && (password == confirmPassword)){
async function userRole () {
let userCount = await User.count();
if(userCount == 0){
return "admin";
}else{
return "user";
}
}
let existingUser = await User.findOne({ where: {email:email}});
if(!existingUser){
// hash the password.
let hashedPassword = bcrypt.hashSync(password,10);
if(!adminUser){
console.log('Creating admin User');
role = "admin";
}
try {
let currentDate = new Date();
let newLogin = currentDate.toLocaleString();
const user = await User.create({
name: name,
first_name: first_name,
last_name: last_name,
username: username,
email: email,
password: bcrypt.hashSync(password,10),
role: await userRole(),
password: hashedPassword,
role: role,
group: 'all',
lastLogin: newLogin,
});
avatar: `<img src="./static/avatars/${avatar}">`
});
// make sure the user was created and get the UUID.
let newUser = await User.findOne({ where: {email:email}});
let match = await bcrypt.compare(password,newUser.password);
if(match){
req.session.user = newUser.username;
req.session.UUID = newUser.UUID;
req.session.role = newUser.role;
const permission = await Permission.create({
user: newUser.username,
userID: newUser.UUID
});
const syslog = await Syslog.create({
user: req.session.user,
email: email,
event: "Successful Registration",
message: "User registered successfully",
ip: req.socket.remoteAddress
});
res.redirect("/dashboard");
}
} catch(err) {
res.render("register",{
"error":"Something went wrong when creating account.",
});
// set the session.
req.session.user = user.username;
req.session.UUID = user.UUID;
req.session.role = user.role;
// Redirect to the home page.
res.redirect("/");
}
} else {
catch (err) {
// return an error.
res.render("register",{
"error":"User with that email already exists.",
res.render("pages/register",{
"error":"Something went wrong when creating account.",
isLoggedIn:false
});
}
} else {
}else{
// return an error.
res.render("pages/register",{
"error":"User with that username already exists.",
isLoggedIn:false
});
}
}else{
// Redirect to the signup page.
res.render("register",{
"error":"Please fill in all the fields.",
res.render("pages/register",{
"error":"Please fill in all the fields and accept TOS.",
isLoggedIn:false
});
}
}

View file

@ -1,10 +1,22 @@
const User = require('../database/UserModel.js');
const Server = require('../database/ServerSettings.js');
export const Settings = (req, res) => {
exports.Settings = async function(req, res) {
if (req.session.role == "admin") {
// Get the user.
let user = await User.findOne({ where: { UUID: req.session.UUID }});
res.render("settings", {
name: req.session.user,
role: req.session.role,
avatar: req.session.user.charAt(0).toUpperCase(),
alert: '',
});
// Render the home page
res.render("pages/settings", {
name: user.first_name + ' ' + user.last_name,
role: user.role,
avatar: user.avatar,
isLoggedIn: true
});
} else {
// Redirect to the login page
res.redirect("/login");
}
}

214
controllers/site_actions.js Normal file
View file

@ -0,0 +1,214 @@
const { readFileSync, writeFileSync, appendFileSync, readdirSync } = require('fs');
const { execSync } = require("child_process");
const { siteCard } = require('../components/siteCard');
const { containerExec } = require('../functions/system_information')
exports.AddSite = async function (req, res) {
let { domain, type, host, port } = req.body;
if ((req.session.role == "admin") && ( domain && type && host && port)) {
let { domain, type, host, port } = req.body;
// build caddyfile
let caddyfile = `${domain} {`
caddyfile += `\n\t${type} ${host}:${port}`
caddyfile += `\n\theader {`
caddyfile += `\n\t\tStrict-Transport-Security "max-age=31536000; includeSubDomains; preload"`
caddyfile += `\n\t}`
caddyfile += `\n}`
// save caddyfile
writeFileSync(`./caddyfiles/sites/${domain}.Caddyfile`, caddyfile, function (err) { console.log(err) });
// format caddyfile
let format = {
container: 'DweebProxy',
command: `caddy fmt --overwrite /etc/caddy/sites/${domain}.Caddyfile`
}
await containerExec(format, function(err, data) {
if (err) {
console.error(err);
return;
}
console.log(`Formatted ${domain}.Caddyfile`);
});
///////////////// convert caddyfile to json
let convert = {
container: 'DweebProxy',
command: `caddy adapt --config /etc/caddy/sites/${domain}.Caddyfile --pretty >> /etc/caddy/sites/${domain}.json`
}
await containerExec(convert, function(err, data) {
if (err) {
console.error(err);
return;
}
console.log(`Converted ${domain}.Caddyfile to JSON`);
});
////////////// reload caddy
let reload = {
container: 'DweebProxy',
command: `caddy reload --config /etc/caddy/Caddyfile`
}
await containerExec(reload, function(err, data) {
if (err) {
console.error(err);
return;
}
console.log(`Reloaded Caddy Config`);
});
let site = siteCard(type, domain, host, port, 0);
req.app.locals.site_list += site;
res.redirect("/");
} else {
// Redirect
console.log('not admin or missing info')
res.redirect("/");
}
}
exports.RemoveSite = async function (req, res) {
if (req.session.role == "admin") {
for (const [key, value] of Object.entries(req.body)) {
execSync(`rm ./caddyfiles/sites/${value}.Caddyfile`, (err, stdout, stderr) => {
if (err) { console.error(`error: ${err.message}`); return; }
if (stderr) { console.error(`stderr: ${stderr}`); return; }
console.log(`removed ${value}.Caddyfile`);
});
}
let reload = {
container: 'DweebProxy',
command: `caddy reload --config /etc/caddy/Caddyfile`
}
await containerExec(reload);
console.log('Removed Site(s)')
res.redirect("/refreshsites");
} else {
res.redirect("/");
}
}
exports.RefreshSites = async function (req, res) {
let domain, type, host, port;
let id = 1;
if (req.session.role == "admin") {
// Clear site_list.ejs
req.app.locals.site_list = "";
// check if ./caddyfiles/sites contains any .json files, then delete them
try {
let files = readdirSync('./caddyfiles/sites/');
files.forEach(file => {
if (file.includes(".json")) {
execSync(`rm ./caddyfiles/sites/${file}`, (err, stdout, stderr) => {
if (err) { console.error(`error: ${err.message}`); return; }
if (stderr) { console.error(`stderr: ${stderr}`); return; }
console.log(`removed ${file}`);
});
}
});
} catch (error) { console.log("No .json files to delete") }
// get list of Caddyfiles
let sites = readdirSync('./caddyfiles/sites/');
sites.forEach(site_name => {
// convert the caddyfile of each site to json
let convert = {
container: 'DweebProxy',
command: `caddy adapt --config ./caddyfiles/sites/${site_name} --pretty >> ./caddyfiles/sites/${site_name}.json`
}
containerExec(convert);
try {
// read the json file
let site_file = readFileSync(`./caddyfiles/sites/${site_name}.json`, 'utf8');
// fix whitespace and parse the json file
site_file = site_file.replace(/ /g, " ");
site_file = JSON.parse(site_file);
} catch (error) { console.log("No .json file to read") }
// get the domain, type, host, and port from the json file
try { domain = site_file.apps.http.servers.srv0.routes[0].match[0].host[0] } catch (error) { console.log("No Domain") }
try { type = site_file.apps.http.servers.srv0.routes[0].handle[0].routes[0].handle[1].handler } catch (error) { console.log("No Type") }
try { host = site_file.apps.http.servers.srv0.routes[0].handle[0].routes[0].handle[1].upstreams[0].dial.split(":")[0] } catch (error) { console.log("Not Localhost") }
try { port = site_file.apps.http.servers.srv0.routes[0].handle[0].routes[0].handle[1].upstreams[0].dial.split(":")[1] } catch (error) { console.log("No Port") }
// build the site card
let site = siteCard(type, domain, host, port, id);
// append the site card to site_list
req.app.locals.site_list += site;
id++;
});
res.redirect("/");
} else {
// Redirect to the login page
res.redirect("/");
}
}
exports.DisableSite = async function (req, res) {
if (req.session.role == "admin") {
console.log(req.body)
console.log('Disable Site')
res.redirect("/");
} else {
// Redirect to the login page
res.redirect("/login");
}
}
exports.EnableSite = async function (req, res) {
if (req.session.role == "admin") {
console.log(req.body)
console.log('Enable Site')
res.redirect("/");
} else {
// Redirect to the login page
res.redirect("/login");
}
}

View file

@ -1,31 +0,0 @@
import { User } from "../database/models.js";
export const Supporters = async (req, res) => {
let user = await User.findOne({ where: { UUID: req.session.UUID }});
res.render("supporters", {
first_name: user.name,
last_name: user.name,
name: user.name,
id: user.id,
email: user.email,
role: user.role,
avatar: req.session.user.charAt(0).toUpperCase(),
alert: '',
});
}
let thanks = 0;
export const Thanks = async (req, res) => {
thanks++;
let data = thanks.toString();
if (thanks > 999) {
data = 'Did you really click 1000 times?!';
}
res.send(data);
}

View file

@ -1,37 +0,0 @@
import { Syslog } from '../database/models.js';
export const Syslogs = async function(req, res) {
let logs = '';
const syslogs = await Syslog.findAll({
order: [
['id', 'DESC']
]
});
for (const log of syslogs) {
let date = (log.createdAt).toDateString();
let time = (log.createdAt).toLocaleTimeString();
let datetime = `${time} ${date}`;
logs += `<tr>
<td class="sort-id">${log.id}</td>
<td class="sort-user">${log.user}</td>
<td class="sort-email">${log.email}</td>
<td class="sort-event">${log.event}</td>
<td class="sort-message">${log.message}</td>
<td class="sort-ip">${log.ip}</td>
<td class="sort-datetime">${datetime}</td>
</tr>`
}
res.render("syslogs", {
name: req.session.user || 'Dev',
role: req.session.role || 'Dev',
avatar: req.session.user.charAt(0).toUpperCase(),
logs: logs,
alert: '',
});
}

View file

@ -1,62 +1,54 @@
import { User } from '../database/models.js';
const User = require('../database/UserModel');
export const Users = async (req, res) => {
let user_list = `
<tr>
<th><input class="form-check-input" type="checkbox"></th>
<th>ID</th>
<th>Avatar</th>
<th>Name</th>
<th>Username</th>
<th>Email</th>
<th>UUID</th>
<th>Role</th>
<th>Last Login</th>
<th>Status</th>
<th>Actions</th>
</tr>`
exports.Users = async function(req, res) {
if (req.session.role == "admin") {
let allUsers = await User.findAll();
allUsers.forEach((account) => {
let active = '<span class="badge badge-outline text-green" title="User has logged-in within the last 30 days.">Active</span>'
let lastLogin = new Date(account.lastLogin);
let currentDate = new Date();
let days = Math.floor((currentDate - lastLogin) / (1000 * 60 * 60 * 24));
let avatar = account.username.charAt(0);
if (days > 30) {
active = '<span class="badge badge-outline text-grey" title="User has not logged-in within the last 30 days.">Inactive</span>';
}
let info = `
// Get the user.
let user = await User.findOne({ where: { UUID: req.session.UUID }});
let user_list = `
<tr>
<td><input class="form-check-input" type="checkbox"></td>
<td>${account.id}</td>
<td><span class="avatar avatar-sm bg-green-lt">${avatar}</span></span>
<td>${account.name}</td>
<td>${account.username}</td>
<td>${account.email}</td>
<td>${account.UUID}</td>
<td>${account.role}</td>
<td>${account.lastLogin}</td>
<td>${active}</td>
<td><a href="#" class="btn">View</a></td>
<th><input class="form-check-input" type="checkbox"></th>
<th>ID</th>
<th>Avatar</th>
<th>Name</th>
<th>Username</th>
<th>Email</th>
<th>UUID</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>`
user_list += info;
});
let users = await User.findAll();
users.forEach((account) => {
full_name = account.first_name + ' ' + account.last_name;
user_info = `
<tr>
<td><input class="form-check-input" type="checkbox"></td>
<td>${user.id}</td>
<td><span class="avatar me-2">${account.avatar}</span></td>
<td>${full_name}</td>
<td>${account.username}</td>
<td>${account.email}</td>
<td>${account.UUID}</td>
<td>${account.role}</td>
<td><span class="badge badge-outline text-green">Active</span></td>
<td><a href="#" class="btn">Edit</a></td>
</tr>`
user_list += user_info;
});
res.render("users", {
name: req.session.user,
role: req.session.role,
avatar: req.session.user.charAt(0).toUpperCase(),
user_list: user_list,
alert: ''
});
// Render the home page
res.render("pages/users", {
name: user.first_name + ' ' + user.last_name,
role: user.role,
avatar: user.avatar,
isLoggedIn: true,
user_list: user_list
});
} else {
// Redirect to the login page
res.redirect("/login");
}
}

View file

@ -1,9 +0,0 @@
export const Variables = (req, res) => {
res.render("variables", {
name: req.session.user,
role: req.session.role,
avatar: req.session.avatar,
});
}

View file

@ -1,119 +0,0 @@
import { docker } from '../server.js';
export const Volumes = async function(req, res) {
let container_volumes = [];
let volume_list = '';
// Table header
volume_list = `<thead>
<tr>
<th class="w-1"><input class="form-check-input m-0 align-middle" name="select" type="checkbox" aria-label="Select all" onclick="selectAll()"></th>
<th><label class="table-sort" data-sort="sort-type">Type</label></th>
<th><label class="table-sort" data-sort="sort-name">Name</label></th>
<th><label class="table-sort" data-sort="sort-city">Mount point</label></th>
<th><label class="table-sort" data-sort="sort-score">Status</label></th>
<th><label class="table-sort" data-sort="sort-date">Created</label></th>
<th><label class="table-sort" data-sort="sort-quantity">Size</label></th>
<th><label class="table-sort" data-sort="sort-progress">Action</label></th>
</tr>
</thead>
<tbody class="table-tbody">`
// List all containers
let containers = await docker.listContainers({ all: true });
// Get the first 6 volumes from each container
for (let i = 0; i < containers.length; i++) {
try { container_volumes.push({type: containers[i].Mounts[0].Type, source: containers[i].Mounts[0].Source}); } catch { }
try { container_volumes.push({type: containers[i].Mounts[1].Type, source: containers[i].Mounts[1].Source}); } catch { }
try { container_volumes.push({type: containers[i].Mounts[2].Type, source: containers[i].Mounts[2].Source}); } catch { }
try { container_volumes.push({type: containers[i].Mounts[3].Type, source: containers[i].Mounts[3].Source}); } catch { }
try { container_volumes.push({type: containers[i].Mounts[4].Type, source: containers[i].Mounts[4].Source}); } catch { }
try { container_volumes.push({type: containers[i].Mounts[5].Type, source: containers[i].Mounts[5].Source}); } catch { }
}
// List ALL volumes
let list = await docker.listVolumes({ all: true });
let volumes = list.Volumes;
// Create a table row for each volume
for (let i = 0; i < volumes.length; i++) {
let volume = volumes[i];
let name = "" + volume.Name;
let mount = "" + volume.Mountpoint;
let type = "Bind";
// Check if the volume is being used by any of the containers
let status = '';
if (container_volumes.some(volume => volume.source === mount)) { status = "In use"; }
if (container_volumes.some(volume => volume.source === mount && volume.type === 'volume')) { type = "Volume"; }
let row = `
<tr>
<td><input class="form-check-input m-0 align-middle" name="select" value="${name}" type="checkbox" aria-label="Select"></td>
<td class="sort-type">${type}</td>
<td class="sort-name">${name}</td>
<td class="sort-city">${mount}</td>
<td class="sort-score text-green">${status}</td>
<td class="sort-date" data-date="1628122643">${volume.CreatedAt}</td>
<td class="sort-quantity">MB</td>
<td class="text-end"><a class="btn" href="#">Details</a></td>
</tr>`
volume_list += row;
}
volume_list += `</tbody>`
res.render("volumes", {
name: req.session.user,
role: req.session.role,
avatar: req.session.user.charAt(0).toUpperCase(),
volume_list: volume_list,
volume_count: volumes.length,
alert: '',
});
}
export const addVolume = async function(req, res) {
let volume = req.body.volume;
docker.createVolume({
Name: volume
});
res.redirect("/volumes");
}
export const removeVolume = async function(req, res) {
let volumes = req.body.select;
if (typeof(volumes) == 'string') {
volumes = [volumes];
}
for (let i = 0; i < volumes.length; i++) {
if (volumes[i] != 'on') {
try {
console.log(`Removing volume: ${volumes[i]}`);
let volume = docker.getVolume(volumes[i]);
await volume.remove();
} catch (error) {
console.log(`Unable to remove volume: ${volumes[i]}`);
}
}
}
res.redirect("/volumes");
}
// docker.df(volume.Name).then((data) => {
// for (let key in data) {
// console.log(data[key]);
// }
// });

View file

@ -0,0 +1,42 @@
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: './database/db.sqlite',
logging: false
});
const Server = sequelize.define('Server', {
// Model attributes are defined here
timezone: {
type: DataTypes.STRING,
allowNull: false
},
hwa: {
type: DataTypes.STRING
// allowNull defaults to true
},
media: {
type: DataTypes.STRING
// allowNull defaults to true
},
pgid: {
type: DataTypes.STRING
// allowNull defaults to true
},
puid: {
type: DataTypes.STRING
// allowNull defaults to true
}
});
async function syncModel() {
await sequelize.sync();
console.log('Server model synced');
}
syncModel();
module.exports = Server;

63
database/UserModel.js Normal file
View file

@ -0,0 +1,63 @@
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: './database/db.sqlite',
logging: false
});
const User = sequelize.define('User', {
// Model attributes are defined here
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
first_name: {
type: DataTypes.STRING,
allowNull: false
},
last_name: {
type: DataTypes.STRING
// allowNull defaults to true
},
username: {
type: DataTypes.STRING
// allowNull defaults to true
},
email: {
type: DataTypes.STRING
// allowNull defaults to true
},
password: {
type: DataTypes.STRING,
// allowNull: false
},
role: {
type: DataTypes.STRING
// allowNull defaults to true
},
group: {
type: DataTypes.STRING
// allowNull defaults to true
},
avatar: {
type: DataTypes.STRING
// allowNull defaults to true
},
UUID: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4
}
});
async function syncModel() {
await sequelize.sync();
console.log('User model synced');
}
syncModel();
module.exports = User;

View file

@ -1,256 +0,0 @@
import { Sequelize, DataTypes } from 'sequelize';
export const sequelize = new Sequelize({
dialect: 'sqlite',
storage: './database/db.sqlite',
logging: false,
});
export const User = sequelize.define('User', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
name: {
type: DataTypes.STRING
},
username: {
type: DataTypes.STRING,
allowNull: false
},
email: {
type: DataTypes.STRING,
allowNull: false
},
password: {
type: DataTypes.STRING,
allowNull: false
},
role: {
type: DataTypes.STRING
},
group: {
type: DataTypes.STRING
},
avatar: {
type: DataTypes.STRING
},
lastLogin: {
type: DataTypes.STRING
},
UUID: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
}
});
export const Container = sequelize.define('Container', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
visibility: {
type: DataTypes.STRING
},
service: {
type: DataTypes.STRING
},
state: {
type: DataTypes.STRING
},
image: {
type: DataTypes.STRING
},
external_port: {
type: DataTypes.STRING
},
internal_port: {
type: DataTypes.STRING
},
ports: {
type: DataTypes.STRING
},
volumes: {
type: DataTypes.STRING
},
environment_variables: {
type: DataTypes.STRING
},
labels: {
type: DataTypes.STRING
},
IPv4: {
type: DataTypes.STRING
},
style: {
type: DataTypes.STRING
},
cpu: {
// store the last 15 values from dockerContainerStats
type: DataTypes.STRING
},
ram: {
// store the last 15 values from dockerContainerStats
type: DataTypes.STRING
},
});
export const Permission = sequelize.define('Permission', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
containerName: {
type: DataTypes.STRING,
},
containerID: {
type: DataTypes.STRING,
},
user: {
type: DataTypes.STRING,
allowNull: false
},
userID: {
type: DataTypes.STRING,
allowNull: false
},
install: {
type: DataTypes.STRING,
defaultValue: false
},
uninstall: {
type: DataTypes.STRING,
defaultValue: false
},
edit: {
type: DataTypes.STRING,
defaultValue: false
},
upgrade: {
type: DataTypes.STRING,
defaultValue: false
},
start: {
type: DataTypes.STRING,
defaultValue: false
},
stop: {
type: DataTypes.STRING,
defaultValue: false
},
restart: {
type: DataTypes.STRING,
defaultValue: false
},
pause: {
type: DataTypes.STRING,
defaultValue: false
},
logs: {
type: DataTypes.STRING,
defaultValue: false
},
hide: {
type: DataTypes.STRING,
defaultValue: false
},
reset_view: {
type: DataTypes.STRING,
defaultValue: false
},
view: {
type: DataTypes.STRING,
defaultValue: false
},
});
export const Syslog = sequelize.define('Syslog', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
user: {
type: DataTypes.STRING
},
email: {
type: DataTypes.STRING
},
event: {
type: DataTypes.STRING,
allowNull: false
},
message: {
type: DataTypes.STRING,
allowNull: false
},
ip : {
type: DataTypes.STRING
},
});
export const Notification = sequelize.define('Notification', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
title: {
type: DataTypes.STRING
},
message: {
type: DataTypes.STRING
},
icon: {
type: DataTypes.STRING,
},
color: {
type: DataTypes.STRING,
},
read: {
type: DataTypes.STRING,
},
createdAt : {
type: DataTypes.STRING
},
createdBy : {
type: DataTypes.STRING
},
});
export const Settings = sequelize.define('Settings', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
key: {
type: DataTypes.STRING,
allowNull: false
},
value: {
type: DataTypes.STRING,
allowNull: false
}
});
export const Variables = sequelize.define('Variables', {
find: {
type: DataTypes.STRING,
},
replace: {
type: DataTypes.STRING,
}
});

View file

@ -0,0 +1,199 @@
const { writeFileSync, mkdirSync, readFileSync } = require("fs");
const yaml = require('js-yaml');
const { exec, execSync } = require("child_process");
const { docker } = require('./system_information');
var DockerodeCompose = require('dockerode-compose');
module.exports.install = async function (data) {
console.log(`[Start of install function]`);
let { service_name, name, image, command_check, command, net_mode, restart_policy } = data;
let { port0, port1, port2, port3, port4, port5 } = data;
let { volume0, volume1, volume2, volume3, volume4, volume5 } = data;
let { env0, env1, env2, env3, env4, env5, env6, env7, env8, env9, env10, env11 } = data;
let { label0, label1, label2, label3, label4, label5, label6, label7, label8, label9, label10, label11 } = data;
let docker_volumes = [];
if (image.startsWith('https://')){
mkdirSync(`./appdata/${name}`, { recursive: true });
execSync(`curl -o ./appdata/${name}/${name}_stack.yml -L ${image}`);
console.log(`Downloaded stackfile: ${image}`);
let stackfile = yaml.load(readFileSync(`./appdata/${name}/${name}_stack.yml`, 'utf8'));
let services = Object.keys(stackfile.services);
for ( let i = 0; i < services.length; i++ ) {
try {
console.log(stackfile.services[Object.keys(stackfile.services)[i]].environment);
} catch { console.log('no env') }
}
} else {
let compose_file = `version: '3'`;
compose_file += `\nservices:`
compose_file += `\n ${service_name}:`
compose_file += `\n container_name: ${name}`;
compose_file += `\n image: ${image}`;
// Command
if (command_check == 'on') {
compose_file += `\n command: ${command}`
}
// Network mode
if (net_mode == 'host') {
compose_file += `\n network_mode: 'host'`
}
else if (net_mode != 'host' && net_mode != 'docker') {
compose_file += `\n network_mode: '${net_mode}'`
}
// Restart policy
if (restart_policy != '') {
compose_file += `\n restart: ${restart_policy}`
}
// Ports
if ((port0 == 'on' || port1 == 'on' || port2 == 'on' || port3 == 'on' || port4 == 'on' || port5 == 'on') && (net_mode != 'host')) {
compose_file += `\n ports:`
for (let i = 0; i < 6; i++) {
if (data[`port${i}`] == 'on') {
compose_file += `\n - ${data[`port_${i}_external`]}:${data[`port_${i}_internal`]}/${data[`port_${i}_protocol`]}`
}
}
}
// Volumes
if (volume0 == 'on' || volume1 == 'on' || volume2 == 'on' || volume3 == 'on' || volume4 == 'on' || volume5 == 'on') {
compose_file += `\n volumes:`
for (let i = 0; i < 6; i++) {
// if volume is on and neither bind or container is empty, it's a bind mount (ex /mnt/user/appdata/config:/config )
if ((data[`volume${i}`] == 'on') && (data[`volume_${i}_bind`] != '') && (data[`volume_${i}_container`] != '')) {
compose_file += `\n - ${data[`volume_${i}_bind`]}:${data[`volume_${i}_container`]}:${data[`volume_${i}_readwrite`]}`
}
// if bind is empty create a docker volume (ex container_name_config:/config) convert any '/' in container name to '_'
else if ((data[`volume${i}`] == 'on') && (data[`volume_${i}_bind`] == '') && (data[`volume_${i}_container`] != '')) {
let volume_name = data[`volume_${i}_container`].replace(/\//g, '_');
compose_file += `\n - ${name}_${volume_name}:${data[`volume_${i}_container`]}:${data[`volume_${i}_readwrite`]}`
docker_volumes.push(`${name}_${volume_name}`);
}
}
}
// Environment variables
if (env0 == 'on' || env1 == 'on' || env2 == 'on' || env3 == 'on' || env4 == 'on' || env5 == 'on' || env6 == 'on' || env7 == 'on' || env8 == 'on' || env9 == 'on' || env10 == 'on' || env11 == 'on') {
compose_file += `\n environment:`
}
for (let i = 0; i < 12; i++) {
if (data[`env${i}`] == 'on') {
compose_file += `\n - ${data[`env_${i}_name`]}=${data[`env_${i}_default`]}`
}
}
// Add labels
if (label0 == 'on' || label1 == 'on' || label2 == 'on' || label3 == 'on' || label4 == 'on' || label5 == 'on' || label6 == 'on' || label7 == 'on' || label8 == 'on' || label9 == 'on' || label10 == 'on' || label11 == 'on') {
compose_file += `\n labels:`
}
for (let i = 0; i < 12; i++) {
if (data[`label${i}`] == 'on') {
compose_file += `\n - ${data[`label_${i}_name`]}=${data[`label_${i}_value`]}`
}
}
// Add privileged mode
if (data.privileged == 'on') {
compose_file += `\n privileged: true`
}
// Add hardware acceleration to the docker-compose file if one of the environment variables has the label DRINODE
if (env0 == 'on' || env1 == 'on' || env2 == 'on' || env3 == 'on' || env4 == 'on' || env5 == 'on' || env6 == 'on' || env7 == 'on' || env8 == 'on' || env9 == 'on' || env10 == 'on' || env11 == 'on') {
for (let i = 0; i < 12; i++) {
if (data[`env${i}`] == 'on') {
if (data[`env_${i}_name`] == 'DRINODE') {
compose_file += `\n deploy:`
compose_file += `\n resources:`
compose_file += `\n reservations:`
compose_file += `\n devices:`
compose_file += `\n - driver: nvidia`
compose_file += `\n count: 1`
compose_file += `\n capabilities: [gpu]`
}
}
}
}
// add any docker volumes to the docker-compose file
if ( docker_volumes.length > 0 ) {
compose_file += `\n`
compose_file += `\nvolumes:`
// check docker_volumes for duplicates and remove them completely
docker_volumes = docker_volumes.filter((item, index) => docker_volumes.indexOf(item) === index)
for (let i = 0; i < docker_volumes.length; i++) {
if ( docker_volumes[i] != '') {
compose_file += `\n ${docker_volumes[i]}:`
}
}
}
try {
mkdirSync(`./appdata/${name}`, { recursive: true });
writeFileSync(`./appdata/${name}/docker-compose.yml`, compose_file, function (err) { console.log(err) });
} catch { console.log('error creating directory or compose file') }
try {
var compose = new DockerodeCompose(docker, `./appdata/${name}/docker-compose.yml`, `${name}`);
(async () => {
await compose.pull();
await compose.up();
})();
} catch { console.log('error running compose file')}
}
}
module.exports.uninstall = async function (data) {
if (data.confirm == 'Yes') {
var containerName = docker.getContainer(`${data.service_name}`);
try {
containerName.stop(function (err, data) {
if (data) {
containerName.remove(function (err, data) {
});
}
});
} catch {
containerName.remove(function (err, data) {
});
}
}
}

View file

@ -0,0 +1,256 @@
const { currentLoad, mem, networkStats, fsSize, dockerContainerStats } = require('systeminformation');
var Docker = require('dockerode');
var docker = new Docker({ socketPath: '/var/run/docker.sock' });
const { dashCard } = require('../components/dashCard');
// export docker
module.exports.docker = docker;
module.exports.serverStats = async function () {
const cpuUsage = await currentLoad();
const ramUsage = await mem();
const netUsage = await networkStats();
const diskUsage = await fsSize();
const info = {
cpu: Math.round(cpuUsage.currentLoad),
ram: Math.round((ramUsage.active / ramUsage.total) * 100),
tx: netUsage[0].tx_bytes,
rx: netUsage[0].rx_bytes,
disk: diskUsage[0].use,
};
return info;
}
module.exports.containerList = async function () {
let card_list = '';
const data = await docker.listContainers({ all: true });
for (const container of data) {
if ((container.Names[0].slice(1) != 'DweebUI') && (container.Names[0].slice(1) != 'DweebCache') && (container.Names[0].slice(1) != 'DweebProxy')) {
let imageVersion = container.Image.split('/');
let service = imageVersion[imageVersion.length - 1].split(':')[0];
let containerId = docker.getContainer(container.Id);
let containerInfo = await containerId.inspect();
// Get ports //////////////////////////
let ports_list = [];
try {
for (const [key, value] of Object.entries(containerInfo.HostConfig.PortBindings)) {
let ports = {
check : 'checked',
external: value[0].HostPort,
internal: key.split('/')[0],
protocol: key.split('/')[1]
}
ports_list.push(ports);
}
} catch { console.log('no ports') }
for (let i = 0; i < 12; i++) {
if (ports_list[i] == undefined) {
let ports = {
check : '',
external: '',
internal: '',
protocol: ''
}
ports_list[i] = ports;
}
} /////////////////////////////////////
// Get volumes ////////////////////////
let volumes_list = [];
try { for (const [key, value] of Object.entries(containerInfo.HostConfig.Binds)) {
let volumes = {
check : 'checked',
bind: value.split(':')[0],
container: value.split(':')[1],
readwrite: value.split(':')[2]
}
volumes_list.push(volumes);
}} catch { console.log('no volumes') }
for (let i = 0; i < 12; i++) {
if (volumes_list[i] == undefined) {
let volumes = {
check : '',
bind: '',
container: '',
readwrite: ''
}
volumes_list[i] = volumes;
}
} /////////////////////////////////////
// Get environment variables.
let environment_variables = [];
try { for (const [key, value] of Object.entries(containerInfo.Config.Env)) {
let env = {
check : 'checked',
name: value.split('=')[0],
default: value.split('=')[1]
}
environment_variables.push(env);
}} catch { console.log('no env') }
for (let i = 0; i < 12; i++) {
if (environment_variables[i] == undefined) {
let env = {
check : '',
name: '',
default: ''
}
environment_variables[i] = env;
}
}
// Get labels.
let labels = [];
for (const [key, value] of Object.entries(containerInfo.Config.Labels)) {
let label = {
check : 'checked',
name: key,
value: value
}
labels.push(label);
}
for (let i = 0; i < 12; i++) {
if (labels[i] == undefined) {
let label = {
check : '',
name: '',
value: ''
}
labels[i] = label;
}
}
let container_info = {
name: container.Names[0].slice(1),
service: service,
id: container.Id,
state: container.State,
image: container.Image,
external_port: ports_list[0].external || 0,
internal_port: ports_list[0].internal || 0,
ports: ports_list,
volumes: volumes_list,
environment_variables: environment_variables,
labels: labels,
}
let dockerCard = dashCard(container_info);
card_list += dockerCard;
}
}
return card_list;
}
module.exports.containerStats = async function () {
let container_stats = [];
const data = await docker.listContainers({ all: true });
for (const container of data) {
if ((container.Names[0].slice(1) != 'DweebUI') && (container.Names[0].slice(1) != 'DweebCache') && (container.Names[0].slice(1) != 'DweebProxy')) {
const stats = await dockerContainerStats(container.Id);
let container_stat = {
name: container.Names[0].slice(1),
cpu: Math.round(stats[0].cpuPercent),
ram: Math.round(stats[0].memPercent)
}
//push stats to an array
container_stats.push(container_stat);
}
}
return container_stats;
}
module.exports.containerAction = async function (data) {
let { user, role, action, container, state } = data;
console.log(`${user} wants to: ${action} ${container}`);
if (role == 'admin') {
var containerName = docker.getContainer(container);
if ((action == 'start') && (state == 'stopped')) {
containerName.start();
} else if ((action == 'start') && (state == 'paused')) {
containerName.unpause();
} else if ((action == 'stop') && (state != 'stopped')) {
containerName.stop();
} else if ((action == 'pause') && (state == 'running')) {
containerName.pause();
} else if ((action == 'pause') && (state == 'paused')) {
containerName.unpause();
} else if (action == 'restart') {
containerName.restart();
}
} else {
console.log('User is not an admin');
}
}
module.exports.containerExec = async function (data) {
let { container, command } = data;
var containerName = docker.getContainer(container);
var options = {
Cmd: ['/bin/sh', '-c', command],
AttachStdout: true,
AttachStderr: true,
Tty: true
};
containerName.exec(options, function (err, exec) {
if (err) return;
exec.start(function (err, stream) {
if (err) return;
containerName.modem.demuxStream(stream, process.stdout, process.stderr);
exec.inspect(function (err, data) {
if (err) return;
});
});
});
}

1097
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,25 @@
{
"name": "dweebui",
"version": "0.60",
"description": "Free and Open-Source WebUI For Managing Your Containers.",
"main": "server.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"name": "dweeb-ui",
"version": "1.0.0",
"main": "app.js",
"keywords": [],
"author": "lllllllillllllillll",
"license": "MIT",
"author": "",
"license": "ISC",
"dependencies": {
"adm-zip": "^0.5.12",
"bcrypt": "^5.1.1",
"dockerode": "^4.0.2",
"bcrypt": "^5.1.0",
"child_process": "^1.0.2",
"connect-redis": "^6.1.3",
"dockerode": "^3.3.5",
"dockerode-compose": "^1.4.0",
"ejs": "^3.1.10",
"express": "^4.19.2",
"express-session": "^1.18.0",
"memorystore": "^1.6.7",
"multer": "^1.4.5-lts.1",
"sequelize": "^6.37.3",
"sqlite3": "^5.1.7",
"systeminformation": "^5.22.9",
"yaml": "^2.4.2"
}
"ejs": "^3.1.9",
"express": "^4.18.2",
"express-session": "^1.17.3",
"js-yaml": "^4.1.0",
"redis": "^4.6.5",
"sequelize": "^6.32.1",
"socket.io": "^4.6.1",
"sqlite3": "^5.1.6",
"systeminformation": "^5.17.12"
},
"description": ""
}

View file

@ -2,7 +2,7 @@
.meter {
box-sizing: content-box;
height: 15px;
height: 15px; /* Can be anything */
margin-left: auto;
margin-right: auto;
position: relative;
@ -83,10 +83,6 @@
.blue > span {
background-image: linear-gradient(#2478f5, #22017e);
}
.purple > span {
background-image: linear-gradient(#bd14d3, #670370);
}
.nostripes > span > span,
.nostripes > span::after {

View file

@ -6036,7 +6036,7 @@ fieldset:disabled .btn {
color: var(--tblr-alert-color);
background-color: var(--tblr-alert-bg);
border: var(--tblr-alert-border);
border-radius: var(--tblr-alert-border-radius);
border-radius: var(--tblr-alert-border-radius)
}
.alert-heading {
@ -11462,38 +11462,47 @@ fieldset:disabled .btn {
}
.column-gap-0 {
-moz-column-gap: 0 !important;
column-gap: 0 !important
}
.column-gap-1 {
-moz-column-gap: .25rem !important;
column-gap: .25rem !important
}
.column-gap-2 {
-moz-column-gap: .5rem !important;
column-gap: .5rem !important
}
.column-gap-3 {
-moz-column-gap: 1rem !important;
column-gap: 1rem !important
}
.column-gap-4 {
-moz-column-gap: 1.5rem !important;
column-gap: 1.5rem !important
}
.column-gap-5 {
-moz-column-gap: 2rem !important;
column-gap: 2rem !important
}
.column-gap-6 {
-moz-column-gap: 3rem !important;
column-gap: 3rem !important
}
.column-gap-7 {
-moz-column-gap: 5rem !important;
column-gap: 5rem !important
}
.column-gap-8 {
-moz-column-gap: 8rem !important;
column-gap: 8rem !important
}
@ -12901,14 +12910,17 @@ fieldset:disabled .btn {
}
.columns-2 {
-moz-columns: 2 !important;
columns: 2 !important
}
.columns-3 {
-moz-columns: 3 !important;
columns: 3 !important
}
.columns-4 {
-moz-columns: 4 !important;
columns: 4 !important
}
@ -13809,38 +13821,47 @@ fieldset:disabled .btn {
}
.column-gap-sm-0 {
-moz-column-gap: 0 !important;
column-gap: 0 !important
}
.column-gap-sm-1 {
-moz-column-gap: .25rem !important;
column-gap: .25rem !important
}
.column-gap-sm-2 {
-moz-column-gap: .5rem !important;
column-gap: .5rem !important
}
.column-gap-sm-3 {
-moz-column-gap: 1rem !important;
column-gap: 1rem !important
}
.column-gap-sm-4 {
-moz-column-gap: 1.5rem !important;
column-gap: 1.5rem !important
}
.column-gap-sm-5 {
-moz-column-gap: 2rem !important;
column-gap: 2rem !important
}
.column-gap-sm-6 {
-moz-column-gap: 3rem !important;
column-gap: 3rem !important
}
.column-gap-sm-7 {
-moz-column-gap: 5rem !important;
column-gap: 5rem !important
}
.column-gap-sm-8 {
-moz-column-gap: 8rem !important;
column-gap: 8rem !important
}
@ -13857,14 +13878,17 @@ fieldset:disabled .btn {
}
.columns-sm-2 {
-moz-columns: 2 !important;
columns: 2 !important
}
.columns-sm-3 {
-moz-columns: 3 !important;
columns: 3 !important
}
.columns-sm-4 {
-moz-columns: 4 !important;
columns: 4 !important
}
}
@ -14766,38 +14790,47 @@ fieldset:disabled .btn {
}
.column-gap-md-0 {
-moz-column-gap: 0 !important;
column-gap: 0 !important
}
.column-gap-md-1 {
-moz-column-gap: .25rem !important;
column-gap: .25rem !important
}
.column-gap-md-2 {
-moz-column-gap: .5rem !important;
column-gap: .5rem !important
}
.column-gap-md-3 {
-moz-column-gap: 1rem !important;
column-gap: 1rem !important
}
.column-gap-md-4 {
-moz-column-gap: 1.5rem !important;
column-gap: 1.5rem !important
}
.column-gap-md-5 {
-moz-column-gap: 2rem !important;
column-gap: 2rem !important
}
.column-gap-md-6 {
-moz-column-gap: 3rem !important;
column-gap: 3rem !important
}
.column-gap-md-7 {
-moz-column-gap: 5rem !important;
column-gap: 5rem !important
}
.column-gap-md-8 {
-moz-column-gap: 8rem !important;
column-gap: 8rem !important
}
@ -14814,14 +14847,17 @@ fieldset:disabled .btn {
}
.columns-md-2 {
-moz-columns: 2 !important;
columns: 2 !important
}
.columns-md-3 {
-moz-columns: 3 !important;
columns: 3 !important
}
.columns-md-4 {
-moz-columns: 4 !important;
columns: 4 !important
}
}
@ -15723,38 +15759,47 @@ fieldset:disabled .btn {
}
.column-gap-lg-0 {
-moz-column-gap: 0 !important;
column-gap: 0 !important
}
.column-gap-lg-1 {
-moz-column-gap: .25rem !important;
column-gap: .25rem !important
}
.column-gap-lg-2 {
-moz-column-gap: .5rem !important;
column-gap: .5rem !important
}
.column-gap-lg-3 {
-moz-column-gap: 1rem !important;
column-gap: 1rem !important
}
.column-gap-lg-4 {
-moz-column-gap: 1.5rem !important;
column-gap: 1.5rem !important
}
.column-gap-lg-5 {
-moz-column-gap: 2rem !important;
column-gap: 2rem !important
}
.column-gap-lg-6 {
-moz-column-gap: 3rem !important;
column-gap: 3rem !important
}
.column-gap-lg-7 {
-moz-column-gap: 5rem !important;
column-gap: 5rem !important
}
.column-gap-lg-8 {
-moz-column-gap: 8rem !important;
column-gap: 8rem !important
}
@ -15771,14 +15816,17 @@ fieldset:disabled .btn {
}
.columns-lg-2 {
-moz-columns: 2 !important;
columns: 2 !important
}
.columns-lg-3 {
-moz-columns: 3 !important;
columns: 3 !important
}
.columns-lg-4 {
-moz-columns: 4 !important;
columns: 4 !important
}
}
@ -16680,38 +16728,47 @@ fieldset:disabled .btn {
}
.column-gap-xl-0 {
-moz-column-gap: 0 !important;
column-gap: 0 !important
}
.column-gap-xl-1 {
-moz-column-gap: .25rem !important;
column-gap: .25rem !important
}
.column-gap-xl-2 {
-moz-column-gap: .5rem !important;
column-gap: .5rem !important
}
.column-gap-xl-3 {
-moz-column-gap: 1rem !important;
column-gap: 1rem !important
}
.column-gap-xl-4 {
-moz-column-gap: 1.5rem !important;
column-gap: 1.5rem !important
}
.column-gap-xl-5 {
-moz-column-gap: 2rem !important;
column-gap: 2rem !important
}
.column-gap-xl-6 {
-moz-column-gap: 3rem !important;
column-gap: 3rem !important
}
.column-gap-xl-7 {
-moz-column-gap: 5rem !important;
column-gap: 5rem !important
}
.column-gap-xl-8 {
-moz-column-gap: 8rem !important;
column-gap: 8rem !important
}
@ -16728,14 +16785,17 @@ fieldset:disabled .btn {
}
.columns-xl-2 {
-moz-columns: 2 !important;
columns: 2 !important
}
.columns-xl-3 {
-moz-columns: 3 !important;
columns: 3 !important
}
.columns-xl-4 {
-moz-columns: 4 !important;
columns: 4 !important
}
}
@ -17637,38 +17697,47 @@ fieldset:disabled .btn {
}
.column-gap-xxl-0 {
-moz-column-gap: 0 !important;
column-gap: 0 !important
}
.column-gap-xxl-1 {
-moz-column-gap: .25rem !important;
column-gap: .25rem !important
}
.column-gap-xxl-2 {
-moz-column-gap: .5rem !important;
column-gap: .5rem !important
}
.column-gap-xxl-3 {
-moz-column-gap: 1rem !important;
column-gap: 1rem !important
}
.column-gap-xxl-4 {
-moz-column-gap: 1.5rem !important;
column-gap: 1.5rem !important
}
.column-gap-xxl-5 {
-moz-column-gap: 2rem !important;
column-gap: 2rem !important
}
.column-gap-xxl-6 {
-moz-column-gap: 3rem !important;
column-gap: 3rem !important
}
.column-gap-xxl-7 {
-moz-column-gap: 5rem !important;
column-gap: 5rem !important
}
.column-gap-xxl-8 {
-moz-column-gap: 8rem !important;
column-gap: 8rem !important
}
@ -17685,14 +17754,17 @@ fieldset:disabled .btn {
}
.columns-xxl-2 {
-moz-columns: 2 !important;
columns: 2 !important
}
.columns-xxl-3 {
-moz-columns: 3 !important;
columns: 3 !important
}
.columns-xxl-4 {
-moz-columns: 4 !important;
columns: 4 !important
}
}
@ -20221,11 +20293,11 @@ body[data-bs-theme=dark] .hide-theme-dark {
}
.alert {
--tblr-alert-color: var(--tblr-secondary);
--tblr-alert-bg: var(--tblr-surface);
--tblr-alert-color: var(--tblr-muted);
background: #fff;
border: var(--tblr-border-width) var(--tblr-border-style) var(--tblr-border-color-translucent);
border-left: 0.25rem var(--tblr-border-style) var(--tblr-alert-color);
box-shadow: rgba(24, 36, 51, 0.04) 0 2px 4px 0;
border-left: .25rem var(--tblr-border-style) var(--tblr-alert-color);
box-shadow: rgba(24, 36, 51, .04) 0 2px 4px 0
}
.alert>:last-child {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,59 +0,0 @@
/* Variable fonts usage:
:root { font-family: "Inter", sans-serif; }
@supports (font-variation-settings: normal) {
:root { font-family: "InterVariable", sans-serif; font-optical-sizing: auto; }
} */
@font-face {
font-family: InterVariable;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('InterVariable.woff2?v=4.0') format('woff2');
}
@font-face {
font-family: InterVariable;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('InterVariable-Italic.woff2?v=4.0') format('woff2');
}
/* legacy name "Inter var" (Oct 2023) */
@font-face { font-family:'Inter var'; font-style:normal; font-weight:100 900; font-display:swap; src: url('InterVariable.woff2?v=4.0') format('woff2'); }
@font-face { font-family:'Inter var'; font-style:italic; font-weight:100 900; font-display:swap; src: url('InterVariable-Italic.woff2?v=4.0') format('woff2'); }
/* static fonts */
@font-face { font-family:Inter; font-style:normal; font-weight:100; font-display:swap; src:url("Inter-Thin.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:100; font-display:swap; src:url("Inter-ThinItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:200; font-display:swap; src:url("Inter-ExtraLight.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:200; font-display:swap; src:url("Inter-ExtraLightItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:300; font-display:swap; src:url("Inter-Light.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:300; font-display:swap; src:url("Inter-LightItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:400; font-display:swap; src:url("Inter-Regular.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:400; font-display:swap; src:url("Inter-Italic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:500; font-display:swap; src:url("Inter-Medium.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:500; font-display:swap; src:url("Inter-MediumItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:600; font-display:swap; src:url("Inter-SemiBold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:600; font-display:swap; src:url("Inter-SemiBoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:700; font-display:swap; src:url("Inter-Bold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:700; font-display:swap; src:url("Inter-BoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:800; font-display:swap; src:url("Inter-ExtraBold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:800; font-display:swap; src:url("Inter-ExtraBoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:normal; font-weight:900; font-display:swap; src:url("Inter-Black.woff2?v=4.0") format("woff2"); }
@font-face { font-family:Inter; font-style:italic; font-weight:900; font-display:swap; src:url("Inter-BlackItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:100; font-display:swap; src:url("InterDisplay-Thin.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:100; font-display:swap; src:url("InterDisplay-ThinItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:200; font-display:swap; src:url("InterDisplay-ExtraLight.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:200; font-display:swap; src:url("InterDisplay-ExtraLightItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:300; font-display:swap; src:url("InterDisplay-Light.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:300; font-display:swap; src:url("InterDisplay-LightItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:400; font-display:swap; src:url("InterDisplay-Regular.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:400; font-display:swap; src:url("InterDisplay-Italic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:500; font-display:swap; src:url("InterDisplay-Medium.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:500; font-display:swap; src:url("InterDisplay-MediumItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:600; font-display:swap; src:url("InterDisplay-SemiBold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:600; font-display:swap; src:url("InterDisplay-SemiBoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:700; font-display:swap; src:url("InterDisplay-Bold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:700; font-display:swap; src:url("InterDisplay-BoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:800; font-display:swap; src:url("InterDisplay-ExtraBold.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:800; font-display:swap; src:url("InterDisplay-ExtraBoldItalic.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:normal; font-weight:900; font-display:swap; src:url("InterDisplay-Black.woff2?v=4.0") format("woff2"); }
@font-face { font-family:InterDisplay; font-style:italic; font-weight:900; font-display:swap; src:url("InterDisplay-BlackItalic.woff2?v=4.0") format("woff2"); }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View file

@ -1,3 +0,0 @@
<svg width="180" height="50" viewBox="0 0 180 50" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g transform="translate(0 0)">
<rect width="180" height="50" rx="2" transform="translate(0 0)" fill="transparent"></rect><text transform="translate(8 43)" fill="rgba(33, 33, 33, 0.7)" font-size="40" font-family="Roboto-Bold,Roboto" ><tspan x="0" y="0">DweebUI</tspan></text>
</g></svg>

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View file

@ -12,7 +12,7 @@
})((function () { 'use strict';
var themeStorageKey = "tablerTheme";
var defaultTheme = "dark";
var defaultTheme = "light";
var selectedTheme;
var params = new Proxy(new URLSearchParams(window.location.search), {
get: function get(searchParams, prop) {

View file

@ -1,355 +0,0 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api;
htmx.defineExtension("sse", {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef;
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource;
}
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
switch (name) {
case "htmx:beforeCleanupElement":
var internalData = api.getInternalData(evt.target)
// Try to remove remove an EventSource when elements are removed
if (internalData.sseEventSource) {
internalData.sseEventSource.close();
}
return;
// Try to create EventSources when elements are processed
case "htmx:afterProcessNode":
ensureEventSourceOnElement(evt.target);
registerSSE(evt.target);
}
}
});
///////////////////////////////////////////////
// HELPER FUNCTIONS
///////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true });
}
function splitOnWhitespace(trigger) {
return trigger.trim().split(/\s+/);
}
function getLegacySSEURL(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
if (legacySSEValue) {
var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/);
if (value[0] === "connect") {
return value[1];
}
}
}
}
function getLegacySSESwaps(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
var returnArr = [];
if (legacySSEValue != null) {
var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/);
if (value[0] === "swap") {
returnArr.push(value[1]);
}
}
}
return returnArr;
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource);
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null; // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement);
var source = internalData.sseEventSource;
// Add message handlers for every `sse-swap` attribute
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
if (sseSwapAttr) {
var sseEventNames = sseSwapAttr.split(",");
} else {
var sseEventNames = getLegacySSESwaps(child);
}
for (var i = 0; i < sseEventNames.length; i++) {
var sseEventName = sseEventNames[i].trim();
var listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return;
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener);
}
// swap the response into the DOM and trigger a notification
swap(child, event.data);
api.triggerEvent(elt, "htmx:sseMessage", event);
};
// Register the new listener
api.getInternalData(child).sseEventListener = listener;
source.addEventListener(sseEventName, listener);
}
});
// Add message handlers for every `hx-trigger="sse:*"` attribute
queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
var sseEventName = api.getAttributeValue(child, "hx-trigger");
if (sseEventName == null) {
return;
}
// Only process hx-triggers for events with the "sse:" prefix
if (sseEventName.slice(0, 4) != "sse:") {
return;
}
// remove the sse: prefix from here on out
sseEventName = sseEventName.substr(4);
var listener = function() {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener);
}
}
});
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null;
}
// handle extension source creation attribute
queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
var sseURL = api.getAttributeValue(child, "sse-connect");
if (sseURL == null) {
return;
}
ensureEventSource(child, sseURL, retryCount);
});
// handle legacy sse, remove for HTMX2
queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
var sseURL = getLegacySSEURL(child);
if (sseURL == null) {
return;
}
ensureEventSource(child, sseURL, retryCount);
});
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url);
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return;
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0;
var timeout = Math.random() * (2 ^ retryCount) * 500;
window.setTimeout(function() {
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
}, timeout);
}
};
source.onopen = function(evt) {
api.triggerEvent(elt, "htmx:sseOpen", { source: source });
}
api.getInternalData(elt).sseEventSource = source;
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource;
if (source != undefined) {
source.close();
// source = null
return true;
}
}
return false;
}
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(elt, attributeName) {
var result = [];
// If the parent element also contains the requested attribute, then add it to the results too.
if (api.hasAttribute(elt, attributeName)) {
result.push(elt);
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
result.push(node);
});
return result;
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt);
});
var swapSpec = api.getSwapSpecification(elt);
var target = api.getTarget(elt);
var settleInfo = api.makeSettleInfo(elt);
api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.add(htmx.config.settlingClass);
}
api.triggerEvent(elt, 'htmx:beforeSettle');
});
// Handle settle tasks (with delay if requested)
if (swapSpec.settleDelay > 0) {
setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
} else {
doSettle(settleInfo)();
}
}
/**
* doSettle mirrors much of the functionality in htmx that
* settles elements after their content has been swapped.
* TODO: this should be published by htmx, and not duplicated here
* @param {import("../htmx").HtmxSettleInfo} settleInfo
* @returns () => void
*/
function doSettle(settleInfo) {
return function() {
settleInfo.tasks.forEach(function(task) {
task.call();
});
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.remove(htmx.config.settlingClass);
}
api.triggerEvent(elt, 'htmx:afterSettle');
});
}
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null;
}
})();

File diff suppressed because one or more lines are too long

181
public/js/main.js Normal file
View file

@ -0,0 +1,181 @@
// SOCKET IO
const socket = io({
auth: {
token: "abc"
}
});
// ON CONNECT EVENT
socket.on('connect', () => {
console.log('Connected');
});
// SELECT METRICS ELEMENTS
const cpuText = document.getElementById('cpu-text');
const cpuBar = document.getElementById('cpu-bar');
const ramText = document.getElementById('ram-text');
const ramBar = document.getElementById('ram-bar');
const netText = document.getElementById('net-text');
const netBar = document.getElementById('net-bar');
const diskText = document.getElementById('disk-text');
const diskBar = document.getElementById('disk-bar');
const dockerCards = document.getElementById('cards');
// create
//Update usage bars
socket.on('metrics', (data) => {
let {cpu, ram, tx, rx, disk} = data;
cpuText.innerHTML = `<span>CPU ${cpu} %</span>`;
cpuBar.innerHTML = `<span style="width: ${cpu}%"><span></span></span>`;
ramText.innerHTML = `<span>RAM ${ram} %</span>`;
ramBar.innerHTML = `<span style="width: ${ram}%"><span></span></span>`;
tx = Math.round(tx / 1024 / 1024);
rx = Math.round(rx / 1024 / 1024);
netText.innerHTML = `<span>Down: ${rx}MB</span><span> Up: ${tx}MB</span>`;
netBar.innerHTML = `<span style="width: 50%"><span></span></span>`;
diskText.innerHTML = `<span>DISK ${disk} %</span>`;
diskBar.innerHTML = `<span style="width: ${disk}%"><span></span></span>`;
});
function drawCharts(name, cpu_array, ram_array) {
var elements = document.querySelectorAll(`${name}`);
Array.from(elements).forEach(function(element) {
if (window.ApexCharts) {
new ApexCharts(element, {
chart: {
type: "line",
fontFamily: 'inherit',
height: 40.0,
sparkline: {
enabled: true
},
animations: {
enabled: false
}
},
fill: {
opacity: 1
},
stroke: {
width: [2, 1],
dashArray: [0, 3],
lineCap: "round",
curve: "smooth"
},
series: [{
name: "CPU",
data: cpu_array
}, {
name: "RAM",
data: ram_array
}],
tooltip: {
theme: 'dark'
},
grid: {
strokeDashArray: 4
},
xaxis: {
labels: {
padding: 0
},
tooltip: {
enabled: false
},
type: 'datetime'
},
yaxis: {
labels: {
padding: 4
}
},
labels: [
'2020-06-20', '2020-06-21', '2020-06-22', '2020-06-23', '2020-06-24', '2020-06-25', '2020-06-26', '2020-06-27', '2020-06-28', '2020-06-29', '2020-06-30', '2020-07-01', '2020-07-02', '2020-07-03', '2020-07-04', '2020-07-05', '2020-07-06', '2020-07-07', '2020-07-08', '2020-07-09', '2020-07-10', '2020-07-11', '2020-07-12', '2020-07-13', '2020-07-14', '2020-07-15', '2020-07-16', '2020-07-17', '2020-07-18', '2020-07-19'
],
colors: [tabler.getColor("primary"), tabler.getColor("gray-600")],
legend: {
show: false
}
}).render();
}
});
}
// container button actions
function buttonAction(button) {
socket.emit('clicked', {container: button.name, state: button.id, action: button.value});
}
function viewLogs(button) {
console.log(`trying to view logs for ${button.name}`);
}
socket.on('cards', (data) => {
console.log('cards deleted');
let deleteMeElements = document.querySelectorAll('.deleteme');
deleteMeElements.forEach((element) => {
element.parentNode.removeChild(element);
});
dockerCards.insertAdjacentHTML("afterend", data);
// check localStorage for items ending with _cpu and redraw the charts
for (let i = 0; i < localStorage.length; i++) {
if (localStorage.key(i).endsWith('_cpu')) {
let name = localStorage.key(i).split('_')[0];
let cpu_array = JSON.parse(localStorage.getItem(`${name}_cpu`));
let ram_array = JSON.parse(localStorage.getItem(`${name}_ram`));
drawCharts(`#${name}_chart`, cpu_array, ram_array);
}
}
});
socket.on('container_stats', (data) => {
let {name, cpu, ram} = data;
var cpu_array = JSON.parse(localStorage.getItem(`${name}_cpu`));
var ram_array = JSON.parse(localStorage.getItem(`${name}_ram`));
if (cpu_array == null) { cpu_array = Array(30).fill(0); }
if (ram_array == null) { ram_array = Array(30).fill(0); }
cpu_array.push(cpu);
ram_array.push(ram);
cpu_array = cpu_array.slice(-30);
ram_array = ram_array.slice(-30);
localStorage.setItem(`${name}_cpu`, JSON.stringify(cpu_array));
localStorage.setItem(`${name}_ram`, JSON.stringify(ram_array));
// replace the old chart with the new one
let chart = document.getElementById(`${name}_chart`);
if (chart) {
let newChart = document.createElement('div');
newChart.id = `${name}_chart`;
chart.parentNode.replaceChild(newChart, chart);
drawCharts(`#${name}_chart`, cpu_array, ram_array);
} else {
console.log(`Chart element with id ${name}_chart not found in the DOM`);
}
});
socket.on('install', (data) => {
console.log('added install card');
dockerCards.insertAdjacentHTML("afterend", data);
});

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
{"version":3,"file":"list.min.js","sources":["webpack://List/list.min.js"],"mappings":"AAAA","sourceRoot":""}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 68 68">
<path d="M64.6 16.2C63 9.9 58.1 5 51.8 3.4 40 1.5 28 1.5 16.2 3.4 9.9 5 5 9.9 3.4 16.2 1.5 28 1.5 40 3.4 51.8 5 58.1 9.9 63 16.2 64.6c11.8 1.9 23.8 1.9 35.6 0C58.1 63 63 58.1 64.6 51.8c1.9-11.8 1.9-23.8 0-35.6zM33.3 36.3c-2.8 4.4-6.6 8.2-11.1 11-1.5.9-3.3.9-4.8.1s-2.4-2.3-2.5-4c0-1.7.9-3.3 2.4-4.1 2.3-1.4 4.4-3.2 6.1-5.3-1.8-2.1-3.8-3.8-6.1-5.3-2.3-1.3-3-4.2-1.7-6.4s4.3-2.9 6.5-1.6c4.5 2.8 8.2 6.5 11.1 10.9 1 1.4 1 3.3.1 4.7zM49.2 46H37.8c-2.1 0-3.8-1-3.8-3s1.7-3 3.8-3h11.4c2.1 0 3.8 1 3.8 3s-1.7 3-3.8 3z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 599 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 232 68">
<path d="M64.6 16.2C63 9.9 58.1 5 51.8 3.4 40 1.5 28 1.5 16.2 3.4 9.9 5 5 9.9 3.4 16.2 1.5 28 1.5 40 3.4 51.8 5 58.1 9.9 63 16.2 64.6c11.8 1.9 23.8 1.9 35.6 0C58.1 63 63 58.1 64.6 51.8c1.9-11.8 1.9-23.8 0-35.6zM33.3 36.3c-2.8 4.4-6.6 8.2-11.1 11-1.5.9-3.3.9-4.8.1s-2.4-2.3-2.5-4c0-1.7.9-3.3 2.4-4.1 2.3-1.4 4.4-3.2 6.1-5.3-1.8-2.1-3.8-3.8-6.1-5.3-2.3-1.3-3-4.2-1.7-6.4s4.3-2.9 6.5-1.6c4.5 2.8 8.2 6.5 11.1 10.9 1 1.4 1 3.3.1 4.7zM49.2 46H37.8c-2.1 0-3.8-1-3.8-3s1.7-3 3.8-3h11.4c2.1 0 3.8 1 3.8 3s-1.7 3-3.8 3z" fill="#fff"/>
<path d="M105.8 46.1c.4 0 .9.2 1.2.6s.6 1 .6 1.7c0 .9-.5 1.6-1.4 2.2s-2 .9-3.2.9c-2 0-3.7-.4-5-1.3s-2-2.6-2-5.4V31.6h-2.2c-.8 0-1.4-.3-1.9-.8s-.9-1.1-.9-1.9c0-.7.3-1.4.8-1.8s1.2-.7 1.9-.7h2.2v-3.1c0-.8.3-1.5.8-2.1s1.3-.8 2.1-.8 1.5.3 2 .8.8 1.3.8 2.1v3.1h3.4c.8 0 1.4.3 1.9.8s.8 1.2.8 1.9-.3 1.4-.8 1.8-1.2.7-1.9.7h-3.4v13c0 .7.2 1.2.5 1.5s.8.5 1.4.5c.3 0 .6-.1 1.1-.2.5-.2.8-.3 1.2-.3zm28-20.7c.8 0 1.5.3 2.1.8.5.5.8 1.2.8 2.1v20.3c0 .8-.3 1.5-.8 2.1-.5.6-1.2.8-2.1.8s-1.5-.3-2-.8-.8-1.2-.8-2.1c-.8.9-1.9 1.7-3.2 2.4-1.3.7-2.8 1-4.3 1-2.2 0-4.2-.6-6-1.7-1.8-1.1-3.2-2.7-4.2-4.7s-1.6-4.3-1.6-6.9c0-2.6.5-4.9 1.5-6.9s2.4-3.6 4.2-4.8c1.8-1.1 3.7-1.7 5.9-1.7 1.5 0 3 .3 4.3.8 1.3.6 2.5 1.3 3.4 2.1 0-.8.3-1.5.8-2.1.5-.5 1.2-.7 2-.7zm-9.7 21.3c2.1 0 3.8-.8 5.1-2.3s2-3.4 2-5.7-.7-4.2-2-5.8c-1.3-1.5-3-2.3-5.1-2.3-2 0-3.7.8-5 2.3-1.3 1.5-2 3.5-2 5.8s.6 4.2 1.9 5.7 3 2.3 5.1 2.3zm32.1-21.3c2.2 0 4.2.6 6 1.7 1.8 1.1 3.2 2.7 4.2 4.7s1.6 4.3 1.6 6.9-.5 4.9-1.5 6.9-2.4 3.6-4.2 4.8c-1.8 1.1-3.7 1.7-5.9 1.7-1.5 0-3-.3-4.3-.9s-2.5-1.4-3.4-2.3v.3c0 .8-.3 1.5-.8 2.1-.5.6-1.2.8-2.1.8s-1.5-.3-2.1-.8c-.5-.5-.8-1.2-.8-2.1V18.9c0-.8.3-1.5.8-2.1.5-.6 1.2-.8 2.1-.8s1.5.3 2.1.8c.5.6.8 1.3.8 2.1v10c.8-1 1.8-1.8 3.2-2.5 1.3-.7 2.8-1 4.3-1zm-.7 21.3c2 0 3.7-.8 5-2.3s2-3.5 2-5.8-.6-4.2-1.9-5.7-3-2.3-5.1-2.3-3.8.8-5.1 2.3-2 3.4-2 5.7.7 4.2 2 5.8c1.3 1.6 3 2.3 5.1 2.3zm23.6 1.9c0 .8-.3 1.5-.8 2.1s-1.3.8-2.1.8-1.5-.3-2-.8-.8-1.3-.8-2.1V18.9c0-.8.3-1.5.8-2.1s1.3-.8 2.1-.8 1.5.3 2 .8.8 1.3.8 2.1v29.7zm29.3-10.5c0 .8-.3 1.4-.9 1.9-.6.5-1.2.7-2 .7h-15.8c.4 1.9 1.3 3.4 2.6 4.4 1.4 1.1 2.9 1.6 4.7 1.6 1.3 0 2.3-.1 3.1-.4.7-.2 1.3-.5 1.8-.8.4-.3.7-.5.9-.6.6-.3 1.1-.4 1.6-.4.7 0 1.2.2 1.7.7s.7 1 .7 1.7c0 .9-.4 1.6-1.3 2.4-.9.7-2.1 1.4-3.6 1.9s-3 .8-4.6.8c-2.7 0-5-.6-7-1.7s-3.5-2.7-4.6-4.6-1.6-4.2-1.6-6.6c0-2.8.6-5.2 1.7-7.2s2.7-3.7 4.6-4.8 3.9-1.7 6-1.7 4.1.6 6 1.7 3.4 2.7 4.5 4.7c.9 1.9 1.5 4.1 1.5 6.3zm-12.2-7.5c-3.7 0-5.9 1.7-6.6 5.2h12.6v-.3c-.1-1.3-.8-2.5-2-3.5s-2.5-1.4-4-1.4zm30.3-5.2c1 0 1.8.3 2.4.8.7.5 1 1.2 1 1.9 0 1-.3 1.7-.8 2.2-.5.5-1.1.8-1.8.7-.5 0-1-.1-1.6-.3-.2-.1-.4-.1-.6-.2-.4-.1-.7-.1-1.1-.1-.8 0-1.6.3-2.4.8s-1.4 1.3-1.9 2.3-.7 2.3-.7 3.7v11.4c0 .8-.3 1.5-.8 2.1-.5.6-1.2.8-2.1.8s-1.5-.3-2.1-.8c-.5-.6-.8-1.3-.8-2.1V28.8c0-.8.3-1.5.8-2.1.5-.6 1.2-.8 2.1-.8s1.5.3 2.1.8c.5.6.8 1.3.8 2.1v.6c.7-1.3 1.8-2.3 3.2-3 1.3-.7 2.8-1 4.3-1z" fill-rule="evenodd" clip-rule="evenodd" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

1
public/static/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

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