├── .dockerignore ├── .env.example ├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── release.sh ├── requirements.txt ├── server.py └── static ├── assets ├── apple-touch-icon.png ├── dashly.svg ├── favicon-96x96.png ├── favicon.ico ├── favicon.svg ├── hero.png ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png ├── screenshot4.png ├── site.webmanifest ├── web-app-manifest-192x192.png └── web-app-manifest-512x512.png ├── css ├── darkmode.css ├── midnight.css ├── styles.css └── terminal.css ├── index.html └── js ├── component ├── card.js ├── dragdrop.js ├── groups.js ├── inactive.js ├── layout.js ├── refreshDomains.js ├── search.js ├── sorting.js └── theme.js └── core ├── main.js ├── render.js └── settings.js /.dockerignore: -------------------------------------------------------------------------------- 1 | /app/ 2 | /nginx/ -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Path to your Nginx Proxy Manager database file !IMPORTANT 2 | NGINX_DB_PATH=/path/to/your/nginx/database.sqlite 3 | 4 | # (Optional) Path to user settings directory; defaults to ./data 5 | # USER_SETTINGS=/path/to/your/data 6 | 7 | # (Optional) Port to expose the application; defaults to 8080 8 | # PORT=8080 -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI/CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' # Trigger workflow on version tag pushes (e.g., v2.2.3) 7 | 8 | jobs: 9 | build-and-push: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Log in to Docker Hub 19 | uses: docker/login-action@v2 20 | with: 21 | username: ${{ secrets.DOCKER_USERNAME }} 22 | password: ${{ secrets.DOCKER_PASSWORD }} 23 | 24 | - name: Build Docker Image 25 | run: | 26 | docker build -t lklynet/dashly:${{ github.ref_name }} -t lklynet/dashly:latest . 27 | 28 | - name: Push Docker Image 29 | run: | 30 | docker push lklynet/dashly:${{ github.ref_name }} 31 | docker push lklynet/dashly:latest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /app/ 2 | /nginx/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | WORKDIR /app 3 | RUN apt-get update && apt-get install -y \ 4 | sqlite3 \ 5 | git \ 6 | && apt-get clean \ 7 | && rm -rf /var/lib/apt/lists/* 8 | COPY . /app 9 | RUN if [ -d "/app/.git" ]; then echo ".git directory copied successfully"; else echo "Error: .git directory not found"; fi 10 | RUN mkdir -p /app/data 11 | RUN pip install --no-cache-dir -r /app/requirements.txt 12 | EXPOSE 8080 13 | CMD ["waitress-serve", "--host=0.0.0.0", "--port=8080", "server:app"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Dashly Logo 3 |

4 |

Dashly

5 | 6 | ## What Dashly Is 7 | 8 | **Dashly** is a lightweight, real-time dashboard for users of **Nginx Proxy Manager**. It simplifies how you monitor and organize your services by automatically syncing with your NPM database. This means you never have to manually edit dashboard configuration files like YAML—it dynamically tracks and displays all your services based on their domain configurations in NPM. 9 | 10 | ![2025-01-05](/static/assets/hero.png) 11 | 12 | --- 13 | 14 | ## Core Features 15 | 16 | - **Dynamic Updates:** 17 | - **Dashly** reads from the **Nginx Proxy Manager database**, automatically updating your dashboard whenever you add, remove, or modify domains. 18 | - **Interactive UI:** 19 | - Organize services with drag-and-drop groups. 20 | - Toggle between grid and list views. 21 | - Search and filter services for quick access. 22 | - **Customizable Appearance:** 23 | - Multiple themes, including a light and dark mode. 24 | - Visibility toggles for inactive services. 25 | - **Group Management:** 26 | - Categorize services into customizable groups. 27 | - Rename and sort groups for easier navigation. 28 | 29 | --- 30 | 31 | ## Why It's Useful 32 | 33 | If you use **Nginx Proxy Manager**, you likely already have domain names set up for your services. **Dashly** takes that data and creates a clean, automatically updating dashboard. It eliminates the repetitive task of manually maintaining dashboard YAML files for tools like **Dashy** or **Homepage**. **Dashly** is tailored for **NPM** users who value automation and simplicity. 34 | 35 | --- 36 | 37 | ## Screenshots 38 | 39 |

40 | Dashly Screenshot 1 41 | Dashly Screenshot 2 42 | Dashly Screenshot 3 43 | Dashly Screenshot 4 44 |

45 | 46 | --- 47 | 48 | ## Tech Stack 49 | 50 | - **Backend:** Python, Flask 51 | - **Frontend:** HTML, CSS, JavaScript 52 | - **Web Server:** Waitress 53 | - **Database:** SQLite (via Nginx Proxy Manager) 54 | - **Deployment:** Docker, Docker Compose 55 | - **Version Control:** Git, GitHub 56 | 57 | --- 58 | 59 | ## Future Roadmap 60 | 61 | - Allowing multiple nginx databases. 62 | - Add a "Favorites" group for quick access to preferred services. 63 | - Enable hiding groups or individual services. 64 | - Support for custom app icons and renaming services. 65 | - Enhance drag-and-drop functionality for smoother interaction. 66 | - Introduce collapsible groups for better organization. 67 | - Toggle displayed information for a cleaner look. 68 | 69 | --- 70 | 71 | ## Getting Started 72 | 73 | ### Prerequisites 74 | 75 | - [Docker](https://www.docker.com/) 76 | - [Docker Compose](https://docs.docker.com/compose/) 77 | - Access to your Nginx Proxy Manager database. 78 | 79 | ### Deployment Steps 80 | 81 | 1. Create a new directory for Dashly and navigate into it: 82 | 83 | ```bash 84 | mkdir dashly 85 | cd dashly 86 | ``` 87 | 88 | 2. Create the `.env` file with the required variables: 89 | 90 | ```bash 91 | echo "NGINX_DB_PATH=/path/to/your/nginx/database.sqlite" >> .env 92 | echo "USER_SETTINGS=/data/" >> .env # OPTIONAL 93 | echo "PORT=8080" >> .env # OPTIONAL 94 | ``` 95 | 96 | 3. Download the `docker-compose.yml` file: 97 | 98 | ```bash 99 | wget https://raw.githubusercontent.com/lklynet/dashly/refs/heads/main/docker-compose.yml 100 | ``` 101 | 102 | 4. Start the application using Docker Compose: 103 | 104 | ```bash 105 | docker compose up -d 106 | ``` 107 | 108 | Alternatively, if you are using an older version of Docker Compose: 109 | 110 | ```bash 111 | docker-compose up -d 112 | ``` 113 | 114 | 5. Access the dashboard at [http://localhost:8080](http://localhost:8080). 115 | 116 | --- 117 | 118 | ## Troubleshooting 119 | 120 | If the app isn't running, has database errors, or doesn't show any services: 121 | 122 | 1. Double-Check: 123 | 124 | - `.env` variables are correct and point to your Nginx Proxy Manager `database.sqlite`. 125 | - User permissions are correct to read the database. 126 | - The directory is bind-mounted to the Docker host. 127 | 128 | 2. Install Dependencies: 129 | 130 | ```bash 131 | apt update && apt upgrade -y 132 | apt install python3 sqlite3 133 | pip3 install flask waitress 134 | ``` 135 | 136 | Then rebuild or update the container, and it should start right up. 137 | 138 | --- 139 | 140 | ## Contributing 141 | 142 | Dashly is an open-source project, and contributions are welcome! If you're interested in collaborating, here are some ways you can help: 143 | 144 | - Submit pull requests to add features or fix bugs. 145 | - Message on X (Twitter) at [@lklynet](https://twitter.com/lklynet). 146 | - Email at [hi@lkly.net](mailto:hi@lkly.net). 147 | - Donate: [Buy me a coffee! ☕](https://buymeacoffee.com/lkly). 148 | 149 | --- 150 | 151 | ## Live Demo 152 | 153 | Try Dashly at [demo.dashly.lkly.net](demo.dashly.lkly.net) 154 | 155 | --- 156 | 157 | ## Contact 158 | 159 | For feedback, questions, or collaboration opportunities: 160 | 161 | - X (Twitter): [@lklynet](https://twitter.com/lklynet) 162 | - Email: [hi@lkly.net](mailto:hi@lkly.net) 163 | 164 | Thank you for checking out Dashly! Let's make it the best it can be together. 165 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dashly: 3 | image: lklynet/dashly:latest 4 | ports: 5 | - "${PORT:-8080}:8080" # User-configurable via .env 6 | environment: 7 | - NGINX_DB_PATH=/nginx/database.sqlite # Must not change 8 | - USER_SETTINGS_FILE=/data/settings.json # Must not change 9 | volumes: 10 | - ${NGINX_DB_PATH}:/nginx/database.sqlite:ro # External database 11 | - ${USER_SETTINGS:-./data}:/data # Defaults to internal ./data 12 | restart: unless-stopped 13 | env_file: 14 | - .env # Recommended for customization 15 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | version=$1 3 | if [ -z "$version" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | 8 | git add . 9 | git commit -m "Release $version" 10 | git tag -a "$version" -m "Release $version" 11 | git push origin main 12 | git push origin "$version" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | Waitress -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, send_from_directory, request 2 | import json 3 | import os 4 | import sqlite3 5 | import time 6 | import subprocess 7 | 8 | app = Flask(__name__, static_folder="static") 9 | 10 | def get_version_from_git(): 11 | try: 12 | # Get the most recent Git tag (e.g., v2.0.0) 13 | version = subprocess.check_output(["git", "describe", "--tags"]).strip().decode("utf-8") 14 | return version 15 | except Exception as e: 16 | print(f"Error fetching version: {e}") 17 | return "Unknown" 18 | 19 | DASHLY_VERSION = get_version_from_git() 20 | 21 | @app.route("/version") 22 | def get_version(): 23 | return jsonify({"version": DASHLY_VERSION}) 24 | 25 | SETTINGS_FILE = os.getenv("USER_SETTINGS_FILE", "/app/data/settings.json") 26 | READ_ONLY_DB_PATH = os.getenv("NGINX_DB_PATH", "/nginx/database.sqlite") 27 | 28 | os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True) 29 | 30 | print(f"Settings File: {SETTINGS_FILE}") 31 | print(f"Read-Only DB Path: {READ_ONLY_DB_PATH}") 32 | 33 | DEFAULT_SETTINGS = { 34 | "theme": "light", 35 | "hideInactive": False, 36 | "hideSearch": False, 37 | "layoutView": "list", 38 | "sortBy": "domain", 39 | "maxColumns": 3, 40 | "groups": {}, 41 | "renamedGroupNames": {} 42 | } 43 | 44 | cached_domains = { 45 | "domains": [], 46 | "last_updated": None 47 | } 48 | CACHE_EXPIRY_SECONDS = 15 49 | 50 | def load_settings(): 51 | """Load settings from the JSON file or initialize with defaults.""" 52 | if not os.path.exists(SETTINGS_FILE): 53 | save_settings(DEFAULT_SETTINGS) 54 | try: 55 | with open(SETTINGS_FILE, "r") as f: 56 | return json.load(f) 57 | except json.JSONDecodeError: 58 | save_settings(DEFAULT_SETTINGS) 59 | return DEFAULT_SETTINGS 60 | 61 | def save_settings(settings): 62 | """Save settings to the JSON file.""" 63 | settings.pop("domains", None) 64 | with open(SETTINGS_FILE, "w") as f: 65 | json.dump(settings, f, indent=4) 66 | 67 | def refresh_cached_domains(): 68 | """Refresh the cached domains from the database.""" 69 | if not os.path.exists(READ_ONLY_DB_PATH): 70 | return {"error": "Database not found"} 71 | 72 | try: 73 | with sqlite3.connect(READ_ONLY_DB_PATH) as conn: 74 | cursor = conn.cursor() 75 | query = """ 76 | SELECT id, domain_names, forward_host, forward_port, meta, enabled 77 | FROM proxy_host 78 | WHERE is_deleted = 0 79 | """ 80 | cursor.execute(query) 81 | rows = cursor.fetchall() 82 | 83 | cached_domains["domains"] = [ 84 | { 85 | "id": row[0], 86 | "domain_names": json.loads(row[1]), 87 | "forward_host": row[2], 88 | "forward_port": row[3], 89 | "nginx_online": json.loads(row[4]).get("nginx_online", False) if row[4] else False, 90 | "enabled": bool(row[5]) 91 | } 92 | for row in rows 93 | ] 94 | cached_domains["last_updated"] = time.time() 95 | return cached_domains["domains"] 96 | except Exception as e: 97 | return {"error": "Failed to refresh domains", "details": str(e)} 98 | 99 | def get_cached_domains(): 100 | """Return cached domains, refreshing if expired.""" 101 | if ( 102 | not cached_domains["domains"] or 103 | not cached_domains["last_updated"] or 104 | (time.time() - cached_domains["last_updated"] > CACHE_EXPIRY_SECONDS) 105 | ): 106 | result = refresh_cached_domains() 107 | if isinstance(result, dict) and "error" in result: 108 | return result 109 | return cached_domains["domains"] 110 | 111 | settings = load_settings() 112 | 113 | @app.route("/domains") 114 | def get_domains_endpoint(): 115 | """Return cached domain data as a standalone endpoint.""" 116 | cached_domains_result = get_cached_domains() 117 | if isinstance(cached_domains_result, dict) and "error" in cached_domains_result: 118 | return jsonify(cached_domains_result), 500 119 | return jsonify({"allDomains": cached_domains_result}) 120 | 121 | @app.route("/settings", methods=["GET"]) 122 | def get_settings(): 123 | """Return the user settings as JSON.""" 124 | cached_domains_result = get_cached_domains() 125 | if isinstance(cached_domains_result, dict) and "error" in cached_domains_result: 126 | return jsonify(cached_domains_result), 500 127 | settings["allDomains"] = cached_domains_result 128 | return jsonify(settings) 129 | 130 | @app.route("/save-settings", methods=["POST"]) 131 | def update_settings(): 132 | """Update settings in the JSON file.""" 133 | try: 134 | new_settings = request.json 135 | if not isinstance(new_settings, dict): 136 | raise ValueError("Invalid data format") 137 | except (ValueError, TypeError): 138 | return jsonify({"error": "Invalid JSON data"}), 400 139 | 140 | settings.update(new_settings) 141 | save_settings(settings) 142 | return jsonify({"message": "Settings updated successfully"}), 200 143 | 144 | @app.route("/save-groups", methods=["POST"]) 145 | def save_groups(): 146 | """Save groups and optionally renamedGroupNames.""" 147 | try: 148 | data = request.json 149 | if not isinstance(data, dict): 150 | raise ValueError("Invalid data format") 151 | except (ValueError, TypeError): 152 | return jsonify({"error": "Invalid JSON data"}), 400 153 | 154 | settings["groups"] = data.get("groups", settings["groups"]) 155 | settings["renamedGroupNames"] = data.get("renamedGroupNames", settings["renamedGroupNames"]) 156 | save_settings(settings) 157 | return jsonify({"message": "Groups updated successfully"}), 200 158 | 159 | @app.route("/refresh-domains", methods=["POST"]) 160 | def refresh_domains(): 161 | """Manually refresh the domain cache.""" 162 | refresh_result = refresh_cached_domains() 163 | if isinstance(refresh_result, dict) and "error" in refresh_result: 164 | return jsonify(refresh_result), 500 165 | return jsonify({"message": "Domains refreshed successfully", "allDomains": refresh_result}) 166 | 167 | @app.route("/") 168 | def serve_frontend(): 169 | """Serve the main frontend HTML with theme injected.""" 170 | saved_theme = settings.get("theme", "light") 171 | index_path = os.path.join(app.static_folder, "index.html") 172 | 173 | if not os.path.exists(index_path): 174 | return jsonify({"error": "index.html not found"}), 500 175 | 176 | with open(index_path) as f: 177 | html_content = f.read() 178 | 179 | html_content = html_content.replace("{{theme}}", saved_theme) 180 | return html_content 181 | 182 | @app.route("/") 183 | def serve_static_files(path): 184 | """Serve static files (e.g., CSS, JS).""" 185 | return send_from_directory(app.static_folder, path) 186 | 187 | if __name__ == "__main__": 188 | from waitress import serve 189 | serve(app, host="0.0.0.0", port=8080) -------------------------------------------------------------------------------- /static/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lklynet/dashly/bdbf08890d8e672aea3f925af5cdd7d5eac63614/static/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /static/assets/dashly.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /static/assets/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lklynet/dashly/bdbf08890d8e672aea3f925af5cdd7d5eac63614/static/assets/favicon-96x96.png -------------------------------------------------------------------------------- /static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lklynet/dashly/bdbf08890d8e672aea3f925af5cdd7d5eac63614/static/assets/favicon.ico -------------------------------------------------------------------------------- /static/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /static/assets/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lklynet/dashly/bdbf08890d8e672aea3f925af5cdd7d5eac63614/static/assets/hero.png -------------------------------------------------------------------------------- /static/assets/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lklynet/dashly/bdbf08890d8e672aea3f925af5cdd7d5eac63614/static/assets/screenshot1.png -------------------------------------------------------------------------------- /static/assets/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lklynet/dashly/bdbf08890d8e672aea3f925af5cdd7d5eac63614/static/assets/screenshot2.png -------------------------------------------------------------------------------- /static/assets/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lklynet/dashly/bdbf08890d8e672aea3f925af5cdd7d5eac63614/static/assets/screenshot3.png -------------------------------------------------------------------------------- /static/assets/screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lklynet/dashly/bdbf08890d8e672aea3f925af5cdd7d5eac63614/static/assets/screenshot4.png -------------------------------------------------------------------------------- /static/assets/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dashly", 3 | "short_name": "Dashly", 4 | "icons": [ 5 | { 6 | "src": "/assets/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/assets/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#374151", 19 | "background_color": "#374151", 20 | "display": "standalone" 21 | } 22 | -------------------------------------------------------------------------------- /static/assets/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lklynet/dashly/bdbf08890d8e672aea3f925af5cdd7d5eac63614/static/assets/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /static/assets/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lklynet/dashly/bdbf08890d8e672aea3f925af5cdd7d5eac63614/static/assets/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /static/css/darkmode.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-dark: #111827; 3 | --text-dark: #1f2937; 4 | --card-bg-dark: linear-gradient( 5 | 135deg, 6 | rgba(46, 58, 75, 0.4), 7 | rgba(16, 185, 129, 0.08) 8 | ); 9 | --group-bg-dark: #1e293b; 10 | --button-hover-dark: #4b5563; 11 | --border-dark: #374151; 12 | --search-border-dark: #334155; 13 | --highlight-color: #10b981; 14 | --danger-color: #ef4444; 15 | --text-muted: #ccc; 16 | --max-columns-bg: #2e3a4b; 17 | --drop-zone-bg: #374151; 18 | --shadow-light-dark: rgba(0, 0, 0, 0.3); 19 | --shadow-medium-dark: rgba(0, 0, 0, 0.6); 20 | } 21 | 22 | body.dark { 23 | background-color: var(--bg-dark); 24 | color: var(--text-light); 25 | } 26 | 27 | body.dark header { 28 | background: linear-gradient(135deg, var(--group-bg-dark), var(--border-dark)); 29 | background-size: 200% 200%; 30 | animation: gradientShiftDark 7s ease infinite; 31 | box-shadow: 0px 4px 6px var(--shadow-medium-dark); 32 | } 33 | body.dark header h1 { 34 | color: var(--text-light); 35 | } 36 | 37 | body.dark .section h2 { 38 | color: var(--highlight-color); 39 | } 40 | 41 | @keyframes gradientShiftDark { 42 | 0% { 43 | background-position: 0% 50%; 44 | } 45 | 50% { 46 | background-position: 100% 50%; 47 | } 48 | 100% { 49 | background-position: 0% 50%; 50 | } 51 | } 52 | 53 | body.dark .edit-mode-toggle { 54 | background: var(--border-dark); 55 | color: var(--text-light); 56 | transition: background var(--transition-speed), 57 | transform var(--transition-speed); 58 | } 59 | body.dark .edit-mode-toggle:hover { 60 | background: var(--button-hover-dark); 61 | transform: translateY(-2px); 62 | } 63 | 64 | body.dark #search { 65 | background-color: var(--group-bg-dark); 66 | color: var(--text-light); 67 | border-color: var(--search-border-dark); 68 | } 69 | 70 | body.dark .section { 71 | background: var(--group-bg-dark); 72 | box-shadow: 0px 4px 6px var(--shadow-light-dark); 73 | } 74 | body.dark .section h3 { 75 | color: var(--highlight-color); 76 | } 77 | body.dark .section button, 78 | body.dark #theme-toggle { 79 | background: var(--border-dark); 80 | transition: background var(--transition-speed), 81 | transform var(--transition-speed); 82 | } 83 | body.dark .section button:hover, 84 | body.dark #theme-toggle:hover { 85 | background: var(--button-hover-dark); 86 | transform: translateY(-2px); 87 | } 88 | 89 | body.dark .group-container { 90 | background: var(--group-bg-dark); 91 | border-color: var(--border-dark); 92 | transition: transform var(--transition-speed), 93 | background-color var(--transition-speed); 94 | } 95 | body.dark .group-header h2 { 96 | color: var(--highlight-color); 97 | } 98 | 99 | body.dark .card { 100 | background: var(--card-bg-dark); 101 | border-left-color: transparent; 102 | box-shadow: 0 2px 5px var(--shadow-light-dark); 103 | transition: transform var(--transition-speed), 104 | box-shadow var(--transition-speed); 105 | } 106 | body.dark .card h3 { 107 | color: var(--highlight-color); 108 | } 109 | body.dark .card p { 110 | color: var(--text-muted); 111 | } 112 | 113 | body.dark .drop-zone { 114 | border-color: var(--button-hover-dark); 115 | color: var(--button-hover-dark); 116 | background-color: transparent; 117 | transition: background-color var(--transition-speed), 118 | border-color var(--transition-speed); 119 | } 120 | body.dark .drop-zone.highlight { 121 | background-color: var(--drop-zone-bg); 122 | border-color: var(--highlight-color); 123 | color: var(--highlight-color); 124 | } 125 | 126 | body.dark #max-columns { 127 | background-color: var(--max-columns-bg); 128 | color: var(--text-light); 129 | border-color: var(--highlight-color); 130 | transition: background-color var(--transition-speed), 131 | border-color var(--transition-speed); 132 | } 133 | body.dark #max-columns:hover { 134 | background-color: var(--button-hover-dark); 135 | } 136 | body.dark #max-columns:focus { 137 | border-color: var(--highlight-color); 138 | background-color: var(--group-bg-dark); 139 | } 140 | 141 | body.dark .delete-group-button { 142 | color: var(--text-muted); 143 | transition: color var(--transition-speed); 144 | } 145 | body.dark .delete-group-button:hover { 146 | color: var(--danger-color); 147 | } 148 | 149 | body.dark .group-name-input { 150 | color: var(--highlight-color); 151 | transition: border-color var(--transition-speed), 152 | color var(--transition-speed); 153 | } 154 | body.dark .group-name-input:focus { 155 | border-bottom: 1px dashed var(--highlight-color); 156 | } 157 | 158 | body.dark #sort-selector { 159 | background-color: var(--max-columns-bg); 160 | color: var(--text-light); 161 | border-color: var(--highlight-color); 162 | transition: background-color var(--transition-speed), 163 | border-color var(--transition-speed); 164 | } 165 | body.dark #sort-selector:hover { 166 | background-color: var(--button-hover-dark); 167 | } 168 | body.dark #sort-selector:focus { 169 | border-color: var(--highlight-color); 170 | background-color: var(--group-bg-dark); 171 | } 172 | 173 | body.dark .settings-grid label { 174 | color: var(--text-light); 175 | } 176 | body.dark .settings-grid input[type="number"], 177 | body.dark .settings-grid select { 178 | background-color: var(--max-columns-bg); 179 | color: var(--text-light); 180 | border-color: var(--highlight-color); 181 | transition: background-color var(--transition-speed), 182 | border-color var(--transition-speed); 183 | } 184 | body.dark .settings-grid input[type="number"]:hover, 185 | body.dark .settings-grid select:hover { 186 | background-color: var(--button-hover-dark); 187 | } 188 | body.dark .settings-grid input[type="number"]:focus, 189 | body.dark .settings-grid select:focus { 190 | border-color: var(--highlight-color); 191 | background-color: var(--group-bg-dark); 192 | } 193 | -------------------------------------------------------------------------------- /static/css/midnight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-midnight: #141414; 3 | --bg-midnight-alt: #1f1f1f; 4 | --text-midnight: #e2e2e2; 5 | --border-midnight: #333333; 6 | --hover-midnight: #2b2b2b; 7 | --highlight-midnight: #5bc0de; 8 | --danger-midnight: #ef4444; 9 | --text-muted-midnight: #bbb; 10 | --shadow-light-midnight: rgba(0, 0, 0, 0.5); 11 | --shadow-medium-midnight: rgba(0, 0, 0, 0.6); 12 | --drop-zone-highlight-bg-midnight: #2a2a2a; 13 | --card-bg-midnight: #1c1c1c; 14 | } 15 | 16 | body.midnight { 17 | background-color: var(--bg-midnight); 18 | color: var(--text-midnight); 19 | } 20 | 21 | body.midnight header { 22 | background-color: var(--bg-midnight-alt); 23 | box-shadow: 0px 4px 6px var(--shadow-light-midnight); 24 | } 25 | body.midnight header h1 { 26 | color: var(--text-midnight); 27 | } 28 | 29 | body.midnight .section h2 { 30 | color: var(--highlight-midnight); 31 | } 32 | 33 | body.midnight .edit-mode-toggle { 34 | background: var(--hover-midnight); 35 | color: var(--text-midnight); 36 | border: none; 37 | padding: 0.6rem 1.2rem; 38 | border-radius: 8px; 39 | cursor: pointer; 40 | transition: background var(--transition-speed), 41 | transform var(--transition-speed); 42 | } 43 | body.midnight .edit-mode-toggle:hover { 44 | background: #3a3a3a; 45 | transform: translateY(-2px); 46 | } 47 | 48 | body.midnight #search { 49 | background-color: var(--bg-midnight-alt); 50 | color: var(--text-midnight); 51 | border: 0px solid var(--border-midnight); 52 | border-radius: 8px; 53 | transition: border-color var(--transition-speed), 54 | background-color var(--transition-speed); 55 | } 56 | body.midnight #search:focus { 57 | outline: none; 58 | border-color: var(--highlight-midnight); 59 | } 60 | 61 | body.midnight .section { 62 | background: var(--bg-midnight-alt); 63 | box-shadow: 0px 4px 6px var(--shadow-light-midnight); 64 | border-radius: 8px; 65 | transition: background-color var(--transition-speed), 66 | box-shadow var(--transition-speed); 67 | } 68 | body.midnight .section h3 { 69 | margin-top: 0; 70 | color: var(--highlight-midnight); 71 | } 72 | body.midnight .section button, 73 | body.midnight #theme-toggle { 74 | background: var(--hover-midnight); 75 | color: var(--text-midnight); 76 | border-radius: 4px; 77 | cursor: pointer; 78 | transition: background var(--transition-speed), 79 | transform var(--transition-speed); 80 | margin-right: 0.5rem; 81 | margin-top: 0.5rem; 82 | } 83 | body.midnight .section button:hover, 84 | body.midnight #theme-toggle:hover { 85 | background: #3a3a3a; 86 | transform: translateY(-2px); 87 | } 88 | 89 | body.midnight .group-container { 90 | background: var(--bg-midnight-alt); 91 | border-radius: 12px; 92 | padding: 1rem; 93 | display: flex; 94 | flex-direction: column; 95 | box-shadow: 0px 4px 6px var(--shadow-light-midnight); 96 | transition: transform var(--transition-speed), 97 | box-shadow var(--transition-speed), background-color var(--transition-speed); 98 | } 99 | body.midnight .group-container:hover { 100 | transform: translateY(-3px); 101 | box-shadow: 0px 6px 10px var(--shadow-medium-midnight); 102 | } 103 | body.midnight .group-header h2 { 104 | margin: 0; 105 | color: var(--highlight-midnight); 106 | } 107 | 108 | body.midnight .card { 109 | background-color: var(--card-bg-midnight); 110 | border-radius: 8px; 111 | padding: 0.6rem 0.8rem; 112 | margin-bottom: 0.75rem; 113 | box-shadow: 0 2px 4px var(--shadow-light-midnight); 114 | transition: transform var(--transition-speed), 115 | box-shadow var(--transition-speed); 116 | position: relative; 117 | } 118 | body.midnight .card:hover { 119 | transform: translateY(-2px); 120 | box-shadow: 0 4px 8px var(--shadow-medium-midnight); 121 | } 122 | body.midnight .card h3 { 123 | margin: 0 0 0.25rem; 124 | color: var(--text-midnight); 125 | } 126 | body.midnight .card p { 127 | margin: 0; 128 | color: var(--text-muted-midnight); 129 | } 130 | 131 | body.midnight .status-dot.green { 132 | background-color: var(--highlight-midnight); 133 | } 134 | body.midnight .status-dot.yellow { 135 | background-color: #eab308; 136 | } 137 | body.midnight .status-dot.red { 138 | background-color: var(--danger-midnight); 139 | } 140 | 141 | body.midnight .drop-zone { 142 | border: 2px dashed var(--border-midnight); 143 | color: var(--border-midnight); 144 | background-color: transparent; 145 | transition: background-color var(--transition-speed), 146 | border-color var(--transition-speed); 147 | } 148 | body.midnight .drop-zone.highlight { 149 | background-color: var(--drop-zone-highlight-bg-midnight); 150 | border-color: var(--highlight-midnight); 151 | color: var(--highlight-midnight); 152 | } 153 | 154 | body.midnight #max-columns { 155 | background-color: var(--bg-midnight-alt); 156 | color: var(--text-midnight); 157 | transition: background-color var(--transition-speed), 158 | border-color var(--transition-speed); 159 | } 160 | body.midnight #max-columns:hover { 161 | background-color: var(--hover-midnight); 162 | } 163 | body.midnight #max-columns:focus { 164 | outline: none; 165 | border-color: var(--highlight-midnight); 166 | background-color: #1e1e1e; 167 | } 168 | 169 | body.midnight .delete-group-button { 170 | background: transparent; 171 | color: #999; 172 | cursor: pointer; 173 | transition: color var(--transition-speed), transform var(--transition-speed); 174 | margin-left: 0.5rem; 175 | padding: 0.25rem; 176 | border-radius: 4px; 177 | } 178 | body.midnight .delete-group-button:hover { 179 | color: var(--danger-midnight); 180 | transform: scale(1.1); 181 | } 182 | 183 | body.midnight .group-name-input { 184 | color: var(--highlight-midnight); 185 | background: transparent; 186 | border: none; 187 | border-bottom: 1px dashed transparent; 188 | padding: 0.2rem 0; 189 | transition: border-color var(--transition-speed), 190 | color var(--transition-speed); 191 | } 192 | body.midnight .group-name-input:focus { 193 | outline: none; 194 | border-bottom: 1px dashed var(--highlight-midnight); 195 | } 196 | 197 | body.midnight #sort-selector { 198 | background-color: var(--bg-midnight-alt); 199 | color: var(--text-midnight); 200 | transition: background-color var(--transition-speed), 201 | border-color var(--transition-speed); 202 | } 203 | body.midnight #sort-selector:hover { 204 | background-color: var(--hover-midnight); 205 | } 206 | body.midnight #sort-selector:focus { 207 | outline: none; 208 | border-color: var(--highlight-midnight); 209 | background-color: #1e1e1e; 210 | } 211 | 212 | body.midnight .settings-grid label { 213 | color: var(--text-midnight); 214 | } 215 | body.midnight .settings-grid input[type="number"], 216 | body.midnight .settings-grid select { 217 | background-color: var(--bg-midnight-alt); 218 | color: var(--text-midnight); 219 | transition: background-color var(--transition-speed), 220 | border-color var(--transition-speed); 221 | } 222 | body.midnight .settings-grid input[type="number"]:hover, 223 | body.midnight .settings-grid select:hover { 224 | background-color: var(--hover-midnight); 225 | } 226 | body.midnight .settings-grid input[type="number"]:focus, 227 | body.midnight .settings-grid select:focus { 228 | outline: none; 229 | border-color: var(--highlight-midnight); 230 | background-color: #1e1e1e; 231 | } 232 | -------------------------------------------------------------------------------- /static/css/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #8b5cf6; 3 | --secondary-color: #4c1d95; 4 | --highlight-color: #10b981; 5 | --danger-color: #ef4444; 6 | --warning-color: #f59e0b; 7 | --bg-light: #f9fafb; 8 | --text-light: #ffffff; 9 | --text-dark: #555; 10 | --border-color: #ccc; 11 | --shadow-light: rgba(0, 0, 0, 0.1); 12 | --shadow-medium: rgba(0, 0, 0, 0.15); 13 | --shadow-dark: rgba(0, 0, 0, 0.7); 14 | --drop-zone-color: #9ca3af; 15 | --drop-zone-highlight-bg: #e5e7eb; 16 | --base-spacing: 1rem; 17 | --transition-speed: 0.3s; 18 | } 19 | .hidden { 20 | display: none !important; 21 | } 22 | /* Prevent zoom-in on mobile only */ 23 | @media (max-width: 768px) { 24 | #search { 25 | font-size: 16px; 26 | } 27 | } 28 | body { 29 | margin: 0; 30 | font-family: "Inter", sans-serif; 31 | font-weight: 400; 32 | background-color: var(--bg-light); 33 | color: var(--text-dark); 34 | transition: background-color var(--transition-speed), 35 | color var(--transition-speed); 36 | line-height: 1.4; 37 | } 38 | h1 { 39 | font-weight: 600; 40 | color: var(--text-light); 41 | } 42 | h2, 43 | h3, 44 | h4, 45 | h5, 46 | h6 { 47 | font-weight: 600; 48 | color: var(--primary-color); 49 | } 50 | p, 51 | li, 52 | span { 53 | font-weight: 400; 54 | } 55 | .edit-mode-toggle, 56 | button { 57 | font-weight: 500; 58 | } 59 | .card h3 { 60 | font-weight: 600; 61 | } 62 | .card p { 63 | font-weight: 300; 64 | } 65 | * { 66 | box-sizing: border-box; 67 | margin: 0; 68 | padding: 0; 69 | } 70 | header { 71 | display: flex; 72 | justify-content: space-between; 73 | align-items: center; 74 | background: linear-gradient( 75 | 135deg, 76 | var(--primary-color), 77 | var(--secondary-color) 78 | ); 79 | background-size: 200% 200%; 80 | color: var(--text-light); 81 | padding: var(--base-spacing) calc(var(--base-spacing) * 2); 82 | box-shadow: 0px 4px 6px var(--shadow-light); 83 | animation: gradientShift 7s ease infinite; 84 | } 85 | header h1 { 86 | margin: 0; 87 | } 88 | @keyframes gradientShift { 89 | 0% { 90 | background-position: 0% 50%; 91 | } 92 | 50% { 93 | background-position: 100% 50%; 94 | } 95 | 100% { 96 | background-position: 0% 50%; 97 | } 98 | } 99 | .edit-mode-toggle { 100 | background: var(--highlight-color); 101 | color: var(--text-light); 102 | border: none; 103 | padding: 0.6rem 1.2rem; 104 | border-radius: 8px; 105 | cursor: pointer; 106 | transition: background var(--transition-speed), 107 | transform var(--transition-speed); 108 | } 109 | .edit-mode-toggle:hover { 110 | background: var(--highlight-color); 111 | transform: translateY(-2px); 112 | } 113 | footer { 114 | color: #ffffff; /* White text for readability */ 115 | text-align: center; /* Center-align text */ 116 | font-size: 14px; /* Slightly smaller font size */ 117 | position: static; /* Sticks the footer to the bottom */ 118 | bottom: 0; /* Position at the very bottom */ 119 | width: 100%; /* Full width */ 120 | z-index: 10; /* Ensure it stays above other elements */ 121 | } 122 | 123 | footer a { 124 | color: #7d7d7d; /* Use a light blue color for links */ 125 | text-decoration: none; /* Remove underline */ 126 | margin: 0 8px; /* Add spacing between links */ 127 | } 128 | 129 | footer a:hover { 130 | text-decoration: none; /* Add underline on hover */ 131 | } 132 | 133 | footer p { 134 | margin: 0; /* Remove default margins for cleaner layout */ 135 | font-weight: 300; /* Use light font weight for a modern look */ 136 | } 137 | main { 138 | padding: var(--base-spacing) calc(var(--base-spacing) * 2); 139 | max-width: 1200px; 140 | margin: 0 auto; 141 | } 142 | #search { 143 | width: 100%; 144 | margin: var(--base-spacing) auto 2rem; 145 | display: block; 146 | padding: 0.8rem; 147 | border: 0px solid var(--border-color); 148 | border-radius: 8px; 149 | box-shadow: 0px 2px 4px var(--shadow-light); 150 | transition: border-color var(--transition-speed), 151 | background-color var(--transition-speed); 152 | } 153 | #search:focus { 154 | border-color: var(--primary-color); 155 | outline: none; 156 | } 157 | .edit-controls { 158 | display: flex; 159 | flex-wrap: wrap; 160 | gap: 1rem; 161 | margin-bottom: 2rem; 162 | } 163 | .section { 164 | flex: 1 1 300px; 165 | background: var(--bg-light); 166 | border-radius: 8px; 167 | padding: 1rem; 168 | box-shadow: 0px 4px 6px var(--shadow-light); 169 | transition: background-color var(--transition-speed), 170 | box-shadow var(--transition-speed); 171 | } 172 | .section h2 { 173 | margin: 0; 174 | color: var(--primary-color); 175 | } 176 | .section button, 177 | #theme-toggle { 178 | padding: 0.5rem 1rem; 179 | background: var(--primary-color); 180 | color: var(--text-light); 181 | border: none; 182 | border-radius: 4px; 183 | cursor: pointer; 184 | text-align: center; 185 | transition: background var(--transition-speed), 186 | transform var(--transition-speed); 187 | margin-right: 0.5rem; 188 | margin-top: 0.5rem; 189 | } 190 | .section button:hover, 191 | #theme-toggle:hover { 192 | background: var(--secondary-color); 193 | transform: translateY(-2px); 194 | } 195 | .gridcontainer, 196 | .grid-container { 197 | width: 100%; 198 | margin: 10px 0; 199 | transition: all var(--transition-speed); 200 | } 201 | #dashboard.list-view .group-services { 202 | display: grid; 203 | grid-gap: 1rem; 204 | } 205 | #dashboard.grid-view .group-services { 206 | display: grid; 207 | gap: 0.5rem; 208 | grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); 209 | } 210 | .group-container { 211 | background: var(--bg-light); 212 | border-radius: 12px; 213 | padding: var(--base-spacing); 214 | display: flex; 215 | flex-direction: column; 216 | box-shadow: 0px 4px 6px var(--shadow-light); 217 | transition: transform var(--transition-speed), 218 | box-shadow var(--transition-speed), background-color var(--transition-speed); 219 | } 220 | .group-container:hover { 221 | transform: translateY(-3px); 222 | box-shadow: 0px 6px 10px var(--shadow-medium); 223 | } 224 | .group-header { 225 | display: flex; 226 | justify-content: space-between; 227 | align-items: center; 228 | margin-bottom: var(--base-spacing); 229 | } 230 | .group-header h2 { 231 | margin: 0; 232 | color: var(--primary-color); 233 | } 234 | .card { 235 | background: linear-gradient( 236 | 135deg, 237 | rgba(var(--primary-color), 0.05), 238 | rgba(var(--highlight-color), 0.05) 239 | ); 240 | border-radius: 8px; 241 | border-left: 4px solid transparent; 242 | padding: 0.6rem 0.8rem; 243 | margin-bottom: 0.75rem; 244 | box-shadow: 0 2px 4px var(--shadow-light); 245 | transition: transform var(--transition-speed), 246 | box-shadow var(--transition-speed); 247 | position: relative; 248 | } 249 | .card-link { 250 | position: absolute; 251 | top: 0; 252 | left: 0; 253 | width: 100%; 254 | height: 100%; 255 | z-index: 1; 256 | text-decoration: none; 257 | color: inherit; 258 | cursor: pointer; 259 | } 260 | .card:hover { 261 | transform: translateY(-2px); 262 | box-shadow: 0 4px 8px var(--shadow-medium); 263 | } 264 | .card h3 { 265 | margin: 0 0 0.25rem; 266 | color: var(--primary-color); 267 | } 268 | .card p { 269 | margin: 0; 270 | color: var(--text-dark); 271 | } 272 | .card.online { 273 | border-left-color: var(--highlight-color); 274 | } 275 | .card.offline { 276 | border-left-color: var(--danger-color); 277 | } 278 | .card.partial { 279 | border-left-color: var(--warning-color); 280 | } 281 | .status-dot { 282 | width: 14px; 283 | height: 14px; 284 | border-radius: 50%; 285 | position: absolute; 286 | top: 12px; 287 | right: 12px; 288 | } 289 | .status-dot.green { 290 | background-color: var(--highlight-color); 291 | animation: pulse 2s infinite; 292 | } 293 | .status-dot.yellow { 294 | background-color: var(--warning-color); 295 | } 296 | .status-dot.red { 297 | background-color: var(--danger-color); 298 | } 299 | @keyframes pulse { 300 | 0% { 301 | transform: scale(1); 302 | box-shadow: 0 0 0 0 var(--shadow-dark); 303 | } 304 | 70% { 305 | transform: scale(1.3); 306 | box-shadow: 0 0 20px 10px rgba(var(--highlight-color), 0); 307 | } 308 | 100% { 309 | transform: scale(1); 310 | box-shadow: 0 0 0 0 rgba(var(--highlight-color), 0); 311 | } 312 | } 313 | .status-dot.green { 314 | animation: pulse 2s infinite; 315 | } 316 | .drop-zone { 317 | padding: var(--base-spacing); 318 | margin: 0.5rem 0; 319 | border: 2px dashed var(--drop-zone-color); 320 | border-radius: 8px; 321 | text-align: center; 322 | color: var(--drop-zone-color); 323 | transition: background-color var(--transition-speed), 324 | border-color var(--transition-speed), transform var(--transition-speed); 325 | } 326 | .drop-zone.highlight { 327 | background-color: var(--drop-zone-highlight-bg); 328 | border-color: var(--primary-color); 329 | color: var(--secondary-color); 330 | } 331 | #max-columns { 332 | width: 60px; 333 | padding: 0.3rem 0.6rem; 334 | border: 1px solid var(--primary-color); 335 | border-radius: 4px; 336 | background-color: rgba(var(--primary-color), 0.05); 337 | color: var(--text-dark); 338 | transition: background-color var(--transition-speed), 339 | border-color var(--transition-speed), transform var(--transition-speed); 340 | } 341 | #max-columns:hover { 342 | background-color: rgba(var(--primary-color), 0.1); 343 | } 344 | #max-columns:focus { 345 | outline: none; 346 | border-color: var(--secondary-color); 347 | background-color: rgba(var(--primary-color), 0.15); 348 | } 349 | .delete-group-button { 350 | background: transparent; 351 | color: var(--drop-zone-color); 352 | border: none; 353 | cursor: pointer; 354 | transition: color var(--transition-speed), transform var(--transition-speed); 355 | margin-left: 0.5rem; 356 | padding: 0.25rem; 357 | border-radius: 4px; 358 | } 359 | .delete-group-button:hover { 360 | color: var(--danger-color); 361 | transform: scale(1.1); 362 | } 363 | .group-name-input { 364 | font-size: 1.4rem; 365 | font-weight: 600; 366 | color: var(--primary-color); 367 | background: transparent; 368 | border: none; 369 | border-bottom: 1px dashed transparent; 370 | transition: border-color var(--transition-speed), 371 | color var(--transition-speed); 372 | padding: 0.2rem 0; 373 | } 374 | .group-name-input:focus { 375 | outline: none; 376 | border-bottom: 1px dashed var(--primary-color); 377 | } 378 | .filter-sort-container { 379 | display: flex; 380 | align-items: center; 381 | flex-wrap: wrap; 382 | gap: 1rem; 383 | max-width: 600px; 384 | margin: 1rem auto; 385 | } 386 | .filter-sort-container label { 387 | margin-right: 0.3rem; 388 | color: var(--text-dark); 389 | transition: color var(--transition-speed); 390 | } 391 | #sort-selector { 392 | padding: 0.3rem 6rem; 393 | height: 2rem; 394 | border: 1px solid var(--primary-color); 395 | border-radius: 4px; 396 | background-color: rgba(var(--primary-color), 0.05); 397 | color: var(--text-dark); 398 | transition: background-color var(--transition-speed), 399 | border-color var(--transition-speed), transform var(--transition-speed); 400 | } 401 | #sort-selector:hover { 402 | background-color: rgba(var(--primary-color), 0.1); 403 | } 404 | #sort-selector:focus { 405 | outline: none; 406 | border-color: var(--secondary-color); 407 | background-color: rgba(var(--primary-color), 0.15); 408 | } 409 | .settings-grid { 410 | display: grid; 411 | grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); 412 | gap: 1rem; 413 | margin-top: 1rem; 414 | align-items: stretch; 415 | justify-items: stretch; 416 | } 417 | .grid-item { 418 | display: flex; 419 | flex-direction: row; 420 | align-items: center; 421 | gap: 0.5rem; 422 | } 423 | .settings-grid button, 424 | .settings-grid input[type="number"], 425 | select { 426 | width: 100%; 427 | box-sizing: border-box; 428 | min-height: 2.5rem; 429 | } 430 | .settings-grid label { 431 | color: var(--text-dark); 432 | transition: color var(--transition-speed); 433 | white-space: nowrap; 434 | } 435 | .settings-grid input[type="number"], 436 | select { 437 | padding: 0.5rem 1rem; 438 | border: 1px solid var(--primary-color); 439 | border-radius: 4px; 440 | background-color: rgba(var(--primary-color), 0.05); 441 | color: var(--text-dark); 442 | cursor: pointer; 443 | transition: background-color var(--transition-speed), 444 | border-color var(--transition-speed), transform var(--transition-speed); 445 | } 446 | .settings-grid input[type="number"]:hover, 447 | .settings-grid select:hover { 448 | background-color: rgba(var(--primary-color), 0.1); 449 | } 450 | .settings-grid input[type="number"]:focus, 451 | .settings-grid select:focus { 452 | outline: none; 453 | border-color: var(--secondary-color); 454 | background-color: rgba(var(--primary-color), 0.15); 455 | } 456 | -------------------------------------------------------------------------------- /static/css/terminal.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-terminal: #000000; 3 | --bg-terminal-alt: #111111; 4 | --text-terminal: #ffffff; 5 | --text-muted-terminal: #cccccc; 6 | --border-terminal: #333333; 7 | --accent-terminal: #00ff00; 8 | --danger-terminal: #ff5555; 9 | --hover-terminal: #222222; 10 | --drop-zone-highlight-terminal: #1c1c1c; 11 | --transition-speed: 0.2s; 12 | } 13 | 14 | body.terminal { 15 | background-color: var(--bg-terminal); 16 | color: var(--text-terminal); 17 | font-family: "Source Code Pro", monospace; 18 | } 19 | 20 | body.terminal header { 21 | background-color: var(--bg-terminal-alt); 22 | box-shadow: none; 23 | animation: none; 24 | } 25 | body.terminal header h1 { 26 | color: var(--text-terminal); 27 | font-family: "Source Code Pro", monospace; 28 | font-weight: 600; 29 | } 30 | 31 | body.terminal .section h2 { 32 | color: var(--text-terminal); 33 | font-family: "Source Code Pro", monospace; 34 | font-weight: 600; 35 | } 36 | 37 | body.terminal .edit-mode-toggle { 38 | background: var(--bg-terminal-alt); 39 | color: var(--text-terminal); 40 | border: 1px solid var(--border-terminal); 41 | padding: 0.6rem 1.2rem; 42 | border-radius: 4px; 43 | cursor: pointer; 44 | transition: background var(--transition-speed), 45 | transform var(--transition-speed); 46 | } 47 | body.terminal .edit-mode-toggle:hover { 48 | background: var(--hover-terminal); 49 | transform: translateY(-1px); 50 | } 51 | 52 | body.terminal #search { 53 | background-color: var(--bg-terminal-alt); 54 | color: var(--text-terminal); 55 | border: 1px solid var(--border-terminal); 56 | border-radius: 4px; 57 | transition: border-color var(--transition-speed); 58 | } 59 | body.terminal #search:focus { 60 | outline: none; 61 | border-color: var(--accent-terminal); 62 | } 63 | 64 | body.terminal .section { 65 | background-color: var(--bg-terminal-alt); 66 | box-shadow: none; 67 | border-radius: 4px; 68 | border: 1px solid var(--border-terminal); 69 | } 70 | body.terminal .section h3 { 71 | margin-top: 0; 72 | color: var(--text-terminal); 73 | } 74 | body.terminal .section button, 75 | body.terminal #theme-toggle { 76 | background: var(--bg-terminal-alt); 77 | color: var(--text-terminal); 78 | border: 1px solid var(--border-terminal); 79 | border-radius: 4px; 80 | transition: background var(--transition-speed), 81 | transform var(--transition-speed); 82 | margin-right: 0.5rem; 83 | margin-top: 0.5rem; 84 | } 85 | body.terminal .section button:hover, 86 | body.terminal #theme-toggle:hover { 87 | background: var(--hover-terminal); 88 | transform: translateY(-1px); 89 | } 90 | 91 | body.terminal .group-container { 92 | background: var(--bg-terminal-alt); 93 | border: 1px solid var(--border-terminal); 94 | border-radius: 4px; 95 | padding: 1rem; 96 | display: flex; 97 | flex-direction: column; 98 | box-shadow: none; 99 | transition: transform var(--transition-speed), 100 | background-color var(--transition-speed); 101 | } 102 | body.terminal .group-container:hover { 103 | transform: translateY(-2px); 104 | } 105 | body.terminal .group-header h2 { 106 | margin: 0; 107 | color: var(--text-terminal); 108 | } 109 | 110 | body.terminal .card { 111 | background-color: var(--bg-terminal); 112 | border: 1px solid var(--border-terminal); 113 | border-radius: 4px; 114 | padding: 0.6rem 0.8rem; 115 | margin-bottom: 0.75rem; 116 | box-shadow: none; 117 | transition: transform var(--transition-speed); 118 | position: relative; 119 | } 120 | body.terminal .card:hover { 121 | transform: translateY(-2px); 122 | } 123 | body.terminal .card h3 { 124 | margin: 0 0 0.25rem; 125 | color: var(--text-terminal); 126 | } 127 | body.terminal .card p { 128 | margin: 0; 129 | color: var(--text-muted-terminal); 130 | } 131 | 132 | body.terminal .status-dot.green { 133 | background-color: var(--accent-terminal); 134 | } 135 | body.terminal .status-dot.yellow { 136 | background-color: #cccc00; 137 | } 138 | body.terminal .status-dot.red { 139 | background-color: var(--danger-terminal); 140 | } 141 | 142 | body.terminal .drop-zone { 143 | border: 2px dashed var(--border-terminal); 144 | color: var(--text-muted-terminal); 145 | background-color: transparent; 146 | transition: background-color var(--transition-speed), 147 | border-color var(--transition-speed); 148 | } 149 | body.terminal .drop-zone.highlight { 150 | background-color: var(--drop-zone-highlight-terminal); 151 | border-color: var(--accent-terminal); 152 | color: var(--accent-terminal); 153 | } 154 | 155 | body.terminal #max-columns { 156 | background-color: var(--bg-terminal-alt); 157 | color: var(--text-terminal); 158 | border: 1px solid var(--border-terminal); 159 | transition: background-color var(--transition-speed), 160 | border-color var(--transition-speed); 161 | } 162 | body.terminal #max-columns:hover { 163 | background-color: var(--hover-terminal); 164 | } 165 | body.terminal #max-columns:focus { 166 | outline: none; 167 | border-color: var(--accent-terminal); 168 | background-color: var(--drop-zone-highlight-terminal); 169 | } 170 | 171 | body.terminal .delete-group-button { 172 | background: transparent; 173 | color: #999999; 174 | border: none; 175 | cursor: pointer; 176 | transition: color var(--transition-speed), transform var(--transition-speed); 177 | margin-left: 0.5rem; 178 | padding: 0.25rem; 179 | border-radius: 4px; 180 | } 181 | body.terminal .delete-group-button:hover { 182 | color: var(--danger-terminal); 183 | transform: scale(1.1); 184 | } 185 | 186 | body.terminal .group-name-input { 187 | color: var(--text-terminal); 188 | background: transparent; 189 | border: none; 190 | border-bottom: 1px dashed transparent; 191 | padding: 0.2rem 0; 192 | transition: border-color var(--transition-speed), 193 | color var(--transition-speed); 194 | } 195 | body.terminal .group-name-input:focus { 196 | outline: none; 197 | border-bottom: 1px dashed var(--accent-terminal); 198 | } 199 | 200 | body.terminal #sort-selector { 201 | background-color: var(--bg-terminal-alt); 202 | color: var(--text-terminal); 203 | border: 1px solid var(--border-terminal); 204 | transition: background-color var(--transition-speed), 205 | border-color var(--transition-speed); 206 | } 207 | body.terminal #sort-selector:hover { 208 | background-color: var(--hover-terminal); 209 | } 210 | body.terminal #sort-selector:focus { 211 | outline: none; 212 | border-color: var(--accent-terminal); 213 | background-color: var(--drop-zone-highlight-terminal); 214 | } 215 | 216 | body.terminal .settings-grid label { 217 | color: var(--text-terminal); 218 | } 219 | body.terminal .settings-grid input[type="number"], 220 | body.terminal .settings-grid select { 221 | background-color: var(--bg-terminal-alt); 222 | color: var(--text-terminal); 223 | border: 1px solid var(--border-terminal); 224 | transition: background-color var(--transition-speed), 225 | border-color var(--transition-speed); 226 | } 227 | body.terminal .settings-grid input[type="number"]:hover, 228 | body.terminal .settings-grid select:hover { 229 | background-color: var(--hover-terminal); 230 | } 231 | body.terminal .settings-grid input[type="number"]:focus, 232 | body.terminal .settings-grid select:focus { 233 | outline: none; 234 | border-color: var(--accent-terminal); 235 | background-color: var(--drop-zone-highlight-terminal); 236 | } 237 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 24 | 25 | 26 | 31 | 32 | 33 | 37 | 41 | Dashly 42 | 43 | 44 | 45 | 46 | 54 | 55 | 56 |
57 |
58 | Dashly Logo 63 |

Dashly

64 |
65 | 66 |
67 |
68 | 73 | 101 |
102 |
103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 121 | 122 | -------------------------------------------------------------------------------- /static/js/component/card.js: -------------------------------------------------------------------------------- 1 | function createCard(domain, editMode) { 2 | const card = document.createElement("div"); 3 | card.className = "card"; 4 | card.draggable = editMode; 5 | card.dataset.id = domain.id; 6 | 7 | const hostPort = `${domain.forward_host}:${domain.forward_port}`; 8 | 9 | let statusClass = "red"; 10 | if (domain.nginx_online && domain.enabled) { 11 | statusClass = "green"; 12 | } else if (!domain.nginx_online && domain.enabled) { 13 | statusClass = "yellow"; 14 | } 15 | 16 | card.innerHTML = ` 17 |
18 |

${domain.domain_names.join(", ")}

19 |

${hostPort}

20 | `; 21 | 22 | if (!editMode) { 23 | const linkOverlay = document.createElement("a"); 24 | linkOverlay.href = `http://${domain.domain_names[0]}`; 25 | linkOverlay.target = "_blank"; 26 | linkOverlay.rel = "noopener noreferrer"; 27 | linkOverlay.className = "card-link"; 28 | linkOverlay.style.position = "absolute"; 29 | linkOverlay.style.top = 0; 30 | linkOverlay.style.left = 0; 31 | linkOverlay.style.width = "100%"; 32 | linkOverlay.style.height = "100%"; 33 | linkOverlay.style.zIndex = 1; 34 | linkOverlay.style.textDecoration = "none"; 35 | linkOverlay.style.color = "inherit"; 36 | 37 | card.appendChild(linkOverlay); 38 | } 39 | 40 | return card; 41 | } -------------------------------------------------------------------------------- /static/js/component/dragdrop.js: -------------------------------------------------------------------------------- 1 | function setupDragAndDrop() { 2 | const cards = document.querySelectorAll(".card"); 3 | const droppables = document.querySelectorAll(".drop-zone"); 4 | 5 | cards.forEach((card) => { 6 | card.addEventListener("dragstart", (event) => { 7 | event.dataTransfer.setData("text/plain", card.dataset.id); 8 | }); 9 | }); 10 | 11 | droppables.forEach((dropZone) => { 12 | dropZone.addEventListener("dragover", (event) => { 13 | event.preventDefault(); 14 | dropZone.classList.add("highlight"); 15 | }); 16 | 17 | dropZone.addEventListener("dragleave", () => { 18 | dropZone.classList.remove("highlight"); 19 | }); 20 | 21 | dropZone.addEventListener("drop", async (event) => { 22 | event.preventDefault(); 23 | dropZone.classList.remove("highlight"); 24 | const domainId = parseInt(event.dataTransfer.getData("text/plain"), 10); 25 | const newGroup = dropZone.dataset.group; 26 | 27 | Object.keys(groups).forEach((group) => { 28 | const index = groups[group].indexOf(domainId); 29 | if (index > -1) groups[group].splice(index, 1); 30 | }); 31 | 32 | if (!groups[newGroup].includes(domainId)) { 33 | groups[newGroup].push(domainId); 34 | await saveGroupsToJSON(groups); 35 | } 36 | 37 | renderDashboard(); 38 | setupDragAndDrop(); 39 | }); 40 | }); 41 | } 42 | 43 | async function saveGroupsToJSON(updatedGroups) { 44 | try { 45 | await fetch("/save-groups", { 46 | method: "POST", 47 | headers: { 48 | "Content-Type": "application/json", 49 | }, 50 | body: JSON.stringify({ groups: updatedGroups }), 51 | }); 52 | } catch (error) { 53 | console.error("Error saving groups to JSON:", error); 54 | } 55 | } -------------------------------------------------------------------------------- /static/js/component/groups.js: -------------------------------------------------------------------------------- 1 | function setupGroupNameEditing() { 2 | const groupNameInputs = document.querySelectorAll(".group-name-input"); 3 | 4 | groupNameInputs.forEach((input) => { 5 | async function handleGroupRename(event) { 6 | const oldGroupName = event.target.dataset.group; 7 | const newGroupName = event.target.value.trim(); 8 | 9 | if (newGroupName === oldGroupName) { 10 | return; 11 | } 12 | 13 | if (newGroupName && !groups[newGroupName]) { 14 | groups[newGroupName] = groups[oldGroupName]; 15 | delete groups[oldGroupName]; 16 | 17 | if ( 18 | oldGroupName === renamedGroupNames?.allServices || 19 | oldGroupName === "All Services" 20 | ) { 21 | renamedGroupNames.allServices = newGroupName; 22 | await saveGroupsAndRenamedNamesToJSON(groups, renamedGroupNames); 23 | } else { 24 | await saveGroupsToJSON(groups); 25 | } 26 | 27 | renderDashboard(); 28 | setupDragAndDrop(); 29 | 30 | } else { 31 | alert("Invalid group name or name already exists."); 32 | event.target.value = oldGroupName; 33 | } 34 | } 35 | 36 | input.addEventListener("blur", handleGroupRename); 37 | 38 | input.addEventListener("keypress", async (event) => { 39 | if (event.key === "Enter") { 40 | event.preventDefault(); 41 | await handleGroupRename(event); 42 | input.blur(); 43 | } 44 | }); 45 | }); 46 | } 47 | 48 | function setupDeleteGroupButtons() { 49 | const deleteButtons = document.querySelectorAll(".delete-group-button"); 50 | 51 | deleteButtons.forEach((button) => { 52 | button.addEventListener("click", async (event) => { 53 | const groupName = event.target.dataset.group; 54 | 55 | if (groups[groupName] && groups[groupName].length > 0) { 56 | alert("You can't delete a group that still contains services."); 57 | return; 58 | } 59 | 60 | if (groups[groupName]) { 61 | delete groups[groupName]; 62 | await saveGroupsToJSON(groups); 63 | renderDashboard(); 64 | setupDragAndDrop(); 65 | } 66 | }); 67 | }); 68 | } 69 | 70 | async function saveGroupsAndRenamedNamesToJSON(updatedGroups, updatedRenamedNames) { 71 | try { 72 | await fetch("/save-groups", { 73 | method: "POST", 74 | headers: { 75 | "Content-Type": "application/json", 76 | }, 77 | body: JSON.stringify({ 78 | groups: updatedGroups, 79 | renamedGroupNames: updatedRenamedNames, 80 | }), 81 | }); 82 | } catch (error) { 83 | console.error("Error saving groups and renamed group names:", error); 84 | } 85 | } 86 | 87 | async function saveGroupsToJSON(updatedGroups) { 88 | try { 89 | await fetch("/save-groups", { 90 | method: "POST", 91 | headers: { 92 | "Content-Type": "application/json", 93 | }, 94 | body: JSON.stringify({ groups: updatedGroups }), 95 | }); 96 | } catch (error) { 97 | console.error("Error saving groups:", error); 98 | } 99 | } -------------------------------------------------------------------------------- /static/js/component/inactive.js: -------------------------------------------------------------------------------- 1 | document.getElementById("toggle-inactive").addEventListener("click", async () => { 2 | showInactive = !showInactive; 3 | renderDashboard(); 4 | 5 | document.getElementById("toggle-inactive").textContent = showInactive 6 | ? "Hide Inactive Domains" 7 | : "Show Inactive Domains"; 8 | 9 | try { 10 | await saveInactiveStateToJSON(!showInactive); 11 | } catch (error) { 12 | console.error("Error saving inactive state:", error); 13 | } 14 | }); 15 | 16 | async function saveInactiveStateToJSON(hideInactiveState) { 17 | try { 18 | await fetch("/save-settings", { 19 | method: "POST", 20 | headers: { 21 | "Content-Type": "application/json", 22 | }, 23 | body: JSON.stringify({ hideInactive: hideInactiveState }), 24 | }); 25 | } catch (error) { 26 | throw new Error("Failed to save inactive state to JSON."); 27 | } 28 | } -------------------------------------------------------------------------------- /static/js/component/layout.js: -------------------------------------------------------------------------------- 1 | let currentLayout = "list"; 2 | 3 | const layoutToggleButton = document.getElementById("toggle-layout"); 4 | const dashboard = document.getElementById("dashboard"); 5 | 6 | function applyLayout(layout) { 7 | if (layout === "grid") { 8 | dashboard.classList.add("grid-view"); 9 | dashboard.classList.remove("list-view"); 10 | layoutToggleButton.textContent = "Switch to List View"; 11 | } else { 12 | dashboard.classList.add("list-view"); 13 | dashboard.classList.remove("grid-view"); 14 | layoutToggleButton.textContent = "Switch to Grid View"; 15 | } 16 | } 17 | 18 | document.addEventListener("DOMContentLoaded", async () => { 19 | try { 20 | const settings = await fetchSettings(); 21 | currentLayout = settings.layoutView || currentLayout; 22 | applyLayout(currentLayout); 23 | } catch (error) { 24 | console.error("Error fetching layout settings:", error); 25 | } 26 | }); 27 | 28 | layoutToggleButton.addEventListener("click", async () => { 29 | currentLayout = currentLayout === "list" ? "grid" : "list"; 30 | applyLayout(currentLayout); 31 | 32 | try { 33 | await saveLayoutToJSON(currentLayout); 34 | } catch (error) { 35 | console.error("Error saving layout setting:", error); 36 | } 37 | }); 38 | 39 | async function saveLayoutToJSON(layout) { 40 | try { 41 | await fetch("/save-settings", { 42 | method: "POST", 43 | headers: { 44 | "Content-Type": "application/json", 45 | }, 46 | body: JSON.stringify({ layoutView: layout }), 47 | }); 48 | } catch (error) { 49 | throw new Error("Failed to save layout setting to JSON."); 50 | } 51 | } 52 | 53 | async function toggleMaxColumns() { 54 | maxColumns = maxColumns === 3 ? 1 : maxColumns + 1; 55 | 56 | const maxColumnsButton = document.getElementById("max-columns-toggle"); 57 | maxColumnsButton.textContent = `Columns: ${maxColumns}`; 58 | 59 | try { 60 | await fetch("/save-settings", { 61 | method: "POST", 62 | headers: { "Content-Type": "application/json" }, 63 | body: JSON.stringify({ maxColumns }), 64 | }); 65 | renderDashboard(); 66 | } catch (error) { 67 | console.error("Error updating max columns:", error); 68 | } 69 | } 70 | 71 | document.getElementById("max-columns-toggle")?.addEventListener("click", toggleMaxColumns); -------------------------------------------------------------------------------- /static/js/component/refreshDomains.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", async () => { 2 | try { 3 | await fetchDomainsOnRefresh(); 4 | } catch (error) { 5 | console.error("Error during domain refresh:", error); 6 | } 7 | }); 8 | 9 | async function fetchDomainsOnRefresh() { 10 | try { 11 | const response = await fetch("/domains"); 12 | if (!response.ok) { 13 | throw new Error("Failed to fetch domains."); 14 | } 15 | 16 | const data = await response.json(); 17 | 18 | const settingsResponse = await fetch("/settings"); 19 | if (!settingsResponse.ok) { 20 | throw new Error("Failed to fetch current settings."); 21 | } 22 | 23 | const settings = await settingsResponse.json(); 24 | groups = settings.groups || { "New Services": [] }; 25 | allDomains = settings.allDomains || []; 26 | 27 | const defaultGroup = Object.keys(groups)[0] || "New Services"; 28 | if (!groups[defaultGroup]) { 29 | groups[defaultGroup] = []; 30 | } 31 | 32 | data.allDomains.forEach((domain) => { 33 | const isExisting = allDomains.some((d) => d.id === domain.id); 34 | 35 | if (!isExisting) { 36 | allDomains.push(domain); 37 | groups[defaultGroup].push(domain.id); 38 | } 39 | }); 40 | 41 | const sortBy = settings.sortBy || "domain"; 42 | sortDomains(sortBy); 43 | 44 | await saveSettingsToJSON({ groups, allDomains }); 45 | 46 | renderDashboard(); 47 | } catch (error) { 48 | console.error("Error fetching domains on refresh:", error); 49 | } 50 | } 51 | 52 | async function saveSettingsToJSON(updatedSettings) { 53 | try { 54 | await fetch("/save-settings", { 55 | method: "POST", 56 | headers: { 57 | "Content-Type": "application/json", 58 | }, 59 | body: JSON.stringify(updatedSettings), 60 | }); 61 | } catch (error) { 62 | console.error("Failed to save settings to JSON:", error); 63 | } 64 | } 65 | 66 | function determineGroupForDomain(domain) { 67 | return domain.enabled ? "Active Domains" : "Inactive Domains"; 68 | } -------------------------------------------------------------------------------- /static/js/component/search.js: -------------------------------------------------------------------------------- 1 | let searchBarVisible = true; 2 | 3 | const searchBar = document.getElementById("search"); 4 | const toggleSearchButton = document.getElementById("toggle-search"); 5 | 6 | let lastVisibleServiceLink = null; 7 | async function toggleSearchVisibility() { 8 | searchBarVisible = !searchBarVisible; 9 | searchBar.classList.toggle("hidden", !searchBarVisible); 10 | toggleSearchButton.textContent = searchBarVisible 11 | ? "Hide Search Bar" 12 | : "Show Search Bar"; 13 | 14 | try { 15 | const settingsResponse = await fetch("/settings"); 16 | if (!settingsResponse.ok) 17 | throw new Error("Failed to fetch current settings"); 18 | 19 | const settings = await settingsResponse.json(); 20 | const updatedSettings = { ...settings, hideSearch: !searchBarVisible }; 21 | 22 | await fetch("/save-settings", { 23 | method: "POST", 24 | headers: { "Content-Type": "application/json" }, 25 | body: JSON.stringify(updatedSettings), 26 | }); 27 | } catch (error) { 28 | console.error("Error updating search visibility settings:", error); 29 | } 30 | } 31 | 32 | document.addEventListener("DOMContentLoaded", async () => { 33 | try { 34 | const settingsResponse = await fetch("/settings"); 35 | if (!settingsResponse.ok) throw new Error("Failed to fetch settings"); 36 | 37 | const settings = await settingsResponse.json(); 38 | searchBarVisible = 39 | settings.hideSearch !== undefined ? !settings.hideSearch : true; 40 | 41 | searchBar.classList.toggle("hidden", !searchBarVisible); 42 | toggleSearchButton.textContent = searchBarVisible 43 | ? "Hide Search Bar" 44 | : "Show Search Bar"; 45 | } catch (error) { 46 | console.error("Error loading search visibility settings:", error); 47 | } 48 | }); 49 | 50 | toggleSearchButton.addEventListener("click", toggleSearchVisibility); 51 | 52 | searchBar.addEventListener("input", (event) => { 53 | performSearch(event.target.value); 54 | }); 55 | 56 | document.addEventListener("keydown", (event) => { 57 | if ( 58 | document.activeElement.tagName === "INPUT" || 59 | document.activeElement.tagName === "TEXTAREA" 60 | ) { 61 | return; 62 | } 63 | 64 | if (searchBarVisible) { 65 | const currentValue = searchBar.value; 66 | 67 | if (event.key === "Backspace") { 68 | searchBar.value = currentValue.slice(0, -1); 69 | } else if (event.key.length === 1) { 70 | searchBar.value = currentValue + event.key; 71 | } 72 | 73 | performSearch(searchBar.value); 74 | } 75 | 76 | if (event.key === "Enter" && lastVisibleServiceLink) { 77 | event.preventDefault(); 78 | window.open(`http://${lastVisibleServiceLink}`, "_blank"); 79 | } 80 | }); 81 | 82 | function performSearch(query) { 83 | const lowerCaseQuery = query.toLowerCase(); 84 | 85 | const previousGroups = { ...groups }; 86 | 87 | const filteredGroups = {}; 88 | let hasResults = false; 89 | 90 | let matchedServices = []; // Collect all matched services across groups 91 | 92 | Object.keys(groups).forEach((groupName) => { 93 | const groupMatches = groupName.toLowerCase().includes(lowerCaseQuery); 94 | 95 | const filteredDomains = groups[groupName] 96 | .map((domainId) => allDomains.find((domain) => domain.id === domainId)) 97 | .filter((domain) => { 98 | if (!domain) return false; 99 | 100 | const matchesDomainName = domain.domain_names.some((name) => 101 | name.toLowerCase().includes(lowerCaseQuery) 102 | ); 103 | 104 | const hostPort = `${domain.forward_host}:${domain.forward_port}`; 105 | const matchesIP = hostPort.toLowerCase().includes(lowerCaseQuery); 106 | 107 | const status = 108 | domain.nginx_online && domain.enabled ? "online" : "offline"; 109 | const matchesStatus = status.includes(lowerCaseQuery); 110 | 111 | return matchesDomainName || matchesIP || matchesStatus; 112 | }); 113 | 114 | if (groupMatches || filteredDomains.length > 0) { 115 | filteredGroups[groupName] = groupMatches 116 | ? groups[groupName] // Include all services if group matches 117 | : filteredDomains.map((domain) => domain.id); 118 | 119 | hasResults = true; 120 | 121 | // Add matched services to the list 122 | matchedServices = matchedServices.concat(filteredDomains); 123 | } 124 | }); 125 | 126 | if (!hasResults) { 127 | document.getElementById("dashboard").innerHTML = "

No domains found.

"; 128 | } else { 129 | groups = filteredGroups; 130 | renderDashboard(); 131 | } 132 | 133 | groups = previousGroups; 134 | 135 | // Store matched services globally for keydown handling 136 | window.matchedServices = matchedServices; 137 | } 138 | 139 | // Global keydown listener to handle "Enter" for opening a link 140 | document.addEventListener("keydown", (event) => { 141 | if (event.key === "Enter" && window.matchedServices?.length === 1) { 142 | const matchedService = window.matchedServices[0]; 143 | const link = `http://${matchedService.domain_names[0]}`; 144 | window.open(link, "_blank"); // Open the link in a new tab 145 | 146 | // Clear the search bar after opening the link 147 | searchBar.value = ""; 148 | performSearch(""); // Reset the search results 149 | } 150 | }); 151 | -------------------------------------------------------------------------------- /static/js/component/sorting.js: -------------------------------------------------------------------------------- 1 | function toggleSortCriteria() { 2 | const sortOptions = ["domain", "status", "ip"]; 3 | const currentIndex = sortOptions.indexOf(currentSettings.sortBy || "domain"); 4 | const newSortCriteria = sortOptions[(currentIndex + 1) % sortOptions.length]; 5 | 6 | updateSortButton(newSortCriteria); 7 | sortDomains(newSortCriteria); 8 | renderDashboard(); 9 | saveSettingsToJson({ sortBy: newSortCriteria }); 10 | } 11 | 12 | function updateSortButton(criteria) { 13 | const sortButton = document.getElementById("sort-toggle"); 14 | sortButton.textContent = `Sort: ${formatSortOption(criteria)}`; 15 | } 16 | 17 | function formatSortOption(option) { 18 | switch (option) { 19 | case "domain": 20 | return "Domain Name"; 21 | case "status": 22 | return "Status"; 23 | case "ip": 24 | return "IP Address"; 25 | default: 26 | return option; 27 | } 28 | } 29 | 30 | async function saveSettingsToJson() { 31 | try { 32 | await fetch("/save-settings", { 33 | method: "POST", 34 | headers: { "Content-Type": "application/json" }, 35 | body: JSON.stringify(currentSettings), 36 | }); 37 | } catch (error) { 38 | console.error("Failed to save settings:", error); 39 | } 40 | } 41 | 42 | document.addEventListener("DOMContentLoaded", async () => { 43 | try { 44 | const settingsResponse = await fetch("/settings"); 45 | if (!settingsResponse.ok) throw new Error("Failed to fetch settings"); 46 | 47 | currentSettings = await settingsResponse.json(); 48 | currentSortCriteria = currentSettings.sortBy || "domain"; 49 | 50 | const sortButton = document.getElementById("sort-toggle"); 51 | sortButton.textContent = `Sort: ${formatSortOption(currentSortCriteria)}`; 52 | 53 | sortDomains(currentSortCriteria); 54 | renderDashboard(); 55 | } catch (error) { 56 | console.error("Error loading sort settings:", error); 57 | } 58 | }); 59 | 60 | document.getElementById("sort-toggle").addEventListener("click", toggleSortCriteria); 61 | 62 | async function updateSortSetting(criteria) { 63 | try { 64 | const updatedSettings = { sortBy: criteria }; 65 | 66 | await fetch("/save-settings", { 67 | method: "POST", 68 | headers: { "Content-Type": "application/json" }, 69 | body: JSON.stringify(updatedSettings), 70 | }); 71 | } catch (error) { 72 | console.error("Error updating sort settings:", error); 73 | } 74 | } 75 | 76 | function sortDomains(criteria) { 77 | if (!groups || typeof groups !== "object") { 78 | console.warn("Groups is not defined or not an object."); 79 | return; 80 | } 81 | Object.keys(groups).forEach((groupName) => { 82 | groups[groupName].sort((a, b) => { 83 | const domA = allDomains.find((d) => d.id === a); 84 | const domB = allDomains.find((d) => d.id === b); 85 | 86 | if (!domA || !domB) return 0; 87 | 88 | switch (criteria) { 89 | case "domain": 90 | return domA.domain_names[0].localeCompare(domB.domain_names[0]); 91 | case "status": 92 | return getStatusRank(domA) - getStatusRank(domB); 93 | case "ip": 94 | return domA.forward_host.localeCompare(domB.forward_host); 95 | default: 96 | return 0; 97 | } 98 | }); 99 | }); 100 | } 101 | 102 | function getStatusRank(domain) { 103 | if (domain.nginx_online && domain.enabled) return 0; 104 | if (!domain.nginx_online && domain.enabled) return 1; 105 | return 2; 106 | } -------------------------------------------------------------------------------- /static/js/component/theme.js: -------------------------------------------------------------------------------- 1 | window.themes = ["light", "dark", "midnight", "terminal"]; 2 | const themeButton = document.getElementById("theme-toggle"); 3 | 4 | function applyTheme(theme) { 5 | document.body.className = ""; 6 | if (theme !== "light") { 7 | document.body.classList.add(theme); 8 | } 9 | themeButton.textContent = theme[0].toUpperCase() + theme.slice(1); 10 | localStorage.setItem("theme", theme); 11 | } 12 | 13 | async function toggleTheme() { 14 | try { 15 | const currentThemeIndex = window.themes.indexOf(currentSettings.theme || "light"); 16 | const newTheme = window.themes[(currentThemeIndex + 1) % window.themes.length]; 17 | 18 | applyTheme(newTheme); 19 | 20 | await saveSettingsToJson({ theme: newTheme }); 21 | } catch (error) { 22 | console.error("Error toggling theme:", error); 23 | } 24 | } 25 | 26 | document.addEventListener("DOMContentLoaded", async () => { 27 | const storedTheme = localStorage.getItem("theme"); 28 | if (storedTheme) { 29 | applyTheme(storedTheme); 30 | } 31 | 32 | try { 33 | currentSettings = await fetchSettings(); 34 | const currentTheme = currentSettings.theme || "light"; 35 | applyTheme(currentTheme); 36 | } catch (error) { 37 | console.error("Error loading theme settings:", error); 38 | if (!storedTheme) { 39 | applyTheme("light"); 40 | } 41 | } 42 | }); 43 | 44 | themeButton.addEventListener("click", toggleTheme); 45 | 46 | async function fetchSettings() { 47 | try { 48 | const response = await fetch("/settings"); 49 | if (!response.ok) throw new Error("Failed to fetch settings."); 50 | return await response.json(); 51 | } catch (error) { 52 | console.error("Error fetching settings:", error); 53 | return {}; 54 | } 55 | } 56 | 57 | async function saveSettingsToJson(updates = {}) { 58 | try { 59 | Object.assign(currentSettings, updates); 60 | 61 | const response = await fetch("/save-settings", { 62 | method: "POST", 63 | headers: { "Content-Type": "application/json" }, 64 | body: JSON.stringify(currentSettings), 65 | }); 66 | 67 | if (!response.ok) { 68 | console.error("Failed to save settings:", await response.text()); 69 | } 70 | } catch (error) { 71 | console.error("Error saving settings:", error); 72 | } 73 | } -------------------------------------------------------------------------------- /static/js/core/main.js: -------------------------------------------------------------------------------- 1 | let showInactive, showSearch, allDomains, editMode, groups, allServicesGroupName, maxColumns, currentSortCriteria; 2 | 3 | const DEFAULT_SETTINGS = { 4 | groups: { "New Services": [] }, 5 | domains: [], 6 | maxColumns: 3, 7 | hideInactive: false, 8 | sortBy: "domain", 9 | renamedGroupNames: { allServices: "New Services" }, 10 | }; 11 | 12 | document.addEventListener("DOMContentLoaded", async () => { 13 | try { 14 | const localVersionResponse = await fetch("/version"); 15 | if (!localVersionResponse.ok) throw new Error("Failed to fetch local version"); 16 | 17 | const { version: localVersion } = await localVersionResponse.json(); 18 | 19 | const latestVersionResponse = await fetch("https://api.github.com/repos/lklynet/dashly/tags"); 20 | if (!latestVersionResponse.ok) throw new Error("Failed to fetch latest version"); 21 | 22 | const tags = await latestVersionResponse.json(); 23 | const latestVersion = tags[0]?.name; // Assume the first tag is the latest 24 | 25 | const versionElement = document.getElementById("version-info"); 26 | 27 | // Check and update the display based on the version comparison 28 | if (localVersion === latestVersion) { 29 | versionElement.textContent = `Version: ${localVersion} (Up to date)`; 30 | } else { 31 | versionElement.textContent = `Version: ${localVersion}`; 32 | const updateNotification = document.createElement("span"); 33 | updateNotification.style.color = "red"; 34 | updateNotification.style.marginLeft = "10px"; 35 | updateNotification.textContent = ` (Update available: ${latestVersion})`; 36 | versionElement.appendChild(updateNotification); 37 | } 38 | } catch (error) { 39 | console.error("Error checking version:", error); 40 | const versionElement = document.getElementById("version-info"); 41 | versionElement.textContent = "Version: Unknown"; 42 | } 43 | }); 44 | 45 | async function fetchAndRender() { 46 | try { 47 | const settings = await fetchSettings(); 48 | 49 | groups = settings.groups || DEFAULT_SETTINGS.groups; 50 | allDomains = settings.domains || DEFAULT_SETTINGS.domains; 51 | maxColumns = settings.maxColumns || DEFAULT_SETTINGS.maxColumns; 52 | showInactive = !settings.hideInactive; 53 | allServicesGroupName = 54 | settings.renamedGroupNames?.allServices || DEFAULT_SETTINGS.renamedGroupNames.allServices; 55 | 56 | if (!groups[allServicesGroupName]) { 57 | groups[allServicesGroupName] = allDomains.map((domain) => domain.id); 58 | } 59 | 60 | document.getElementById("max-columns-toggle").textContent = `Columns: ${maxColumns}`; 61 | document.getElementById("toggle-inactive").textContent = showInactive 62 | ? "Hide Inactive Domains" 63 | : "Show Inactive Domains"; 64 | 65 | const sortButton = document.getElementById("sort-toggle"); 66 | sortButton.textContent = `Sort: ${formatSortOption(currentSettings.sortBy || "domain")}`; 67 | 68 | renderDashboard(); 69 | setupEventListeners(); 70 | setupDragAndDrop(); 71 | } catch (error) { 72 | console.error("Error fetching and rendering settings:", error); 73 | } 74 | } 75 | 76 | function toggleMaxColumns() { 77 | maxColumns = maxColumns === 3 ? 1 : maxColumns + 1; 78 | 79 | document.getElementById("max-columns-toggle").textContent = `Columns: ${maxColumns}`; 80 | saveSettingsToJson(); 81 | renderDashboard(); 82 | } 83 | 84 | function updateGridTemplate(groupCount) { 85 | const dashboard = document.getElementById("dashboard"); 86 | dashboard.style.display = "grid"; 87 | dashboard.style.gridGap = "1rem"; 88 | 89 | if (groupCount <= maxColumns) { 90 | dashboard.style.gridTemplateColumns = `repeat(${groupCount}, 1fr)`; 91 | } else { 92 | dashboard.style.gridTemplateColumns = `repeat(${maxColumns}, 1fr)`; 93 | } 94 | dashboard.style.gridAutoRows = "auto"; 95 | } 96 | 97 | function renderDashboard() { 98 | const dashboard = document.getElementById("dashboard"); 99 | dashboard.innerHTML = ""; 100 | 101 | const groupCount = Object.keys(groups).length; 102 | updateGridTemplate(groupCount); 103 | 104 | Object.keys(groups).forEach((groupName) => { 105 | const groupContainer = document.createElement("div"); 106 | groupContainer.className = "group-container"; 107 | groupContainer.dataset.group = groupName; 108 | 109 | const groupHeader = document.createElement("div"); 110 | groupHeader.className = "group-header"; 111 | 112 | if (editMode) { 113 | groupHeader.innerHTML = ` 114 | 119 | 122 | `; 123 | } else { 124 | groupHeader.innerHTML = `

${groupName}

`; 125 | } 126 | 127 | groupContainer.appendChild(groupHeader); 128 | 129 | const groupServices = document.createElement("div"); 130 | groupServices.className = "group-services droppable"; 131 | groupServices.dataset.group = groupName; 132 | 133 | if (editMode) { 134 | const dropZone = document.createElement("div"); 135 | dropZone.className = "drop-zone"; 136 | dropZone.dataset.group = groupName; 137 | dropZone.textContent = "Drop here to add to group"; 138 | groupServices.appendChild(dropZone); 139 | } 140 | 141 | const domainIds = groups[groupName]; 142 | domainIds.forEach((domainId) => { 143 | const domain = allDomains.find((d) => d.id === domainId); 144 | if (domain && (showInactive || domain.enabled)) { 145 | const card = createCard(domain); 146 | groupServices.appendChild(card); 147 | } 148 | }); 149 | 150 | groupContainer.appendChild(groupServices); 151 | dashboard.appendChild(groupContainer); 152 | }); 153 | 154 | const ungroupedDomains = allDomains.filter((domain) => { 155 | return !Object.values(groups).some((group) => group.includes(domain.id)); 156 | }); 157 | 158 | if (ungroupedDomains.length > 0) { 159 | const defaultGroup = "New Services"; 160 | if (!groups[defaultGroup]) { 161 | groups[defaultGroup] = []; 162 | } 163 | 164 | ungroupedDomains.forEach((domain) => { 165 | groups[defaultGroup].push(domain.id); 166 | }); 167 | 168 | saveSettingsToJson(); 169 | } 170 | 171 | if (editMode) { 172 | setupGroupNameEditing(); 173 | setupDeleteGroupButtons(); 174 | } 175 | 176 | setupDragAndDrop(); 177 | } 178 | 179 | async function saveSettingsToJson() { 180 | const settings = { 181 | groups, 182 | domains: allDomains, 183 | maxColumns, 184 | hideInactive: !showInactive, 185 | sortBy: currentSortCriteria, 186 | renamedGroupNames: { allServices: allServicesGroupName }, 187 | }; 188 | 189 | try { 190 | const response = await fetch("/save-settings", { 191 | method: "POST", 192 | headers: { "Content-Type": "application/json" }, 193 | body: JSON.stringify(settings), 194 | }); 195 | 196 | if (!response.ok) { 197 | console.error("Failed to save settings:", await response.text()); 198 | } 199 | } catch (error) { 200 | console.error("Error saving settings:", error); 201 | } 202 | } 203 | 204 | fetchAndRender(); -------------------------------------------------------------------------------- /static/js/core/render.js: -------------------------------------------------------------------------------- 1 | async function setupEventListeners() { 2 | const addGroupButton = document.getElementById("add-group"); 3 | if (addGroupButton) { 4 | addGroupButton.addEventListener("click", async () => { 5 | const defaultGroupName = "New Group"; 6 | let groupName = defaultGroupName; 7 | let counter = 1; 8 | 9 | while (groups[groupName]) { 10 | groupName = `${defaultGroupName} ${counter}`; 11 | counter++; 12 | } 13 | 14 | groups[groupName] = []; 15 | 16 | try { 17 | const settingsResponse = await fetch("/settings"); 18 | if (!settingsResponse.ok) throw new Error("Failed to fetch settings"); 19 | 20 | const settings = await settingsResponse.json(); 21 | const updatedSettings = { ...settings, groups }; 22 | 23 | await fetch("/save-settings", { 24 | method: "POST", 25 | headers: { "Content-Type": "application/json" }, 26 | body: JSON.stringify(updatedSettings), 27 | }); 28 | renderDashboard(); 29 | setupDragAndDrop(); 30 | } catch (error) { 31 | console.error("Error adding group:", error); 32 | } 33 | }); 34 | } 35 | 36 | const editButton = document.getElementById("edit-button"); 37 | if (editButton) { 38 | editButton.addEventListener("click", () => { 39 | editMode = !editMode; 40 | 41 | const editControls = document.querySelector(".edit-controls"); 42 | 43 | if (!editControls) { 44 | console.error("Missing edit-controls element."); 45 | return; 46 | } 47 | 48 | if (editMode) { 49 | editControls.classList.remove("hidden"); 50 | editButton.textContent = "Done"; 51 | } else { 52 | editControls.classList.add("hidden"); 53 | editButton.textContent = "Edit"; 54 | } 55 | 56 | renderDashboard(); 57 | setupDragAndDrop(); 58 | }); 59 | } else { 60 | console.error("Edit button not found in DOM."); 61 | } 62 | } -------------------------------------------------------------------------------- /static/js/core/settings.js: -------------------------------------------------------------------------------- 1 | let currentSettings = {}; 2 | 3 | async function fetchSettings() { 4 | try { 5 | const response = await fetch("/settings"); 6 | if (!response.ok) throw new Error("Failed to fetch settings."); 7 | currentSettings = await response.json(); 8 | return currentSettings; 9 | } catch (error) { 10 | console.error("Error fetching settings:", error); 11 | return {}; 12 | } 13 | } 14 | 15 | function applySettings(updatedKeys = Object.keys(currentSettings)) { 16 | updatedKeys.forEach((key) => { 17 | const value = currentSettings[key]; 18 | 19 | switch (key) { 20 | case "theme": 21 | document.body.className = value !== "light" ? value : ""; 22 | document.getElementById("theme-toggle").textContent = 23 | value[0].toUpperCase() + value.slice(1); 24 | break; 25 | 26 | case "layoutView": 27 | const dashboard = document.getElementById("dashboard"); 28 | dashboard.classList.toggle("grid-view", value === "grid"); 29 | dashboard.classList.toggle("list-view", value === "list"); 30 | break; 31 | 32 | case "groups": 33 | groups = value || { "New Services": [] }; 34 | renderDashboard(); 35 | break; 36 | 37 | case "maxColumns": 38 | maxColumns = value || 3; 39 | updateGridTemplate(Object.keys(groups).length); 40 | break; 41 | 42 | case "hideSearch": 43 | const searchBar = document.getElementById("search"); 44 | searchBar.classList.toggle("hidden", value); 45 | document.getElementById("toggle-search").textContent = value 46 | ? "Show Search Bar" 47 | : "Hide Search Bar"; 48 | break; 49 | 50 | case "hideInactive": 51 | showInactive = !value; 52 | document.getElementById("toggle-inactive").textContent = showInactive 53 | ? "Hide Inactive Domains" 54 | : "Show Inactive Domains"; 55 | break; 56 | 57 | case "sortBy": 58 | currentSettings.sortBy = value || "domain"; 59 | const sortButton = document.getElementById("sort-toggle"); 60 | if (sortButton) { 61 | sortButton.textContent = `Sort: ${formatSortOption(currentSettings.sortBy)}`; 62 | } 63 | sortDomains(currentSettings.sortBy); 64 | break; 65 | 66 | case "allDomains": 67 | allDomains = value || []; 68 | break; 69 | 70 | case "renamedGroupNames": 71 | renamedGroupNames = value || {}; 72 | break; 73 | 74 | default: 75 | console.warn(`Unhandled setting key: "${key}"`); 76 | } 77 | }); 78 | } 79 | 80 | async function saveSettingsToJson(updates = {}) { 81 | try { 82 | Object.assign(currentSettings, updates); 83 | 84 | const response = await fetch("/save-settings", { 85 | method: "POST", 86 | headers: { "Content-Type": "application/json" }, 87 | body: JSON.stringify(currentSettings), 88 | }); 89 | 90 | if (!response.ok) { 91 | console.error("Failed to save settings:", await response.text()); 92 | } 93 | } catch (error) { 94 | console.error("Error saving settings:", error); 95 | } 96 | } 97 | 98 | document.addEventListener("DOMContentLoaded", async () => { 99 | try { 100 | currentSettings = await fetchSettings(); 101 | applySettings(); 102 | } catch (error) { 103 | console.error("Initialization failed:", error); 104 | } 105 | }); --------------------------------------------------------------------------------