{{ group }}
{{ router['display_name'] }}
-{{ router['description'] }}
+ {% if router['description'] %} +{{ router['description'] }}
+ {% endif %}diff --git a/app.py b/app.py index a56dc71..620c125 100644 --- a/app.py +++ b/app.py @@ -2,128 +2,146 @@ from flask import Flask, render_template import re import docker import requests +import yaml +import os +from waitress import serve app = Flask(__name__) -TRAEFIK_API_URL = "https://norman.lan/api/http/routers" -REGEX_PATTERNS = [r".*\.gederico\.dynu\.net"] -DEFAULT_GROUP_PRIORITY = 100 -DEFAULT_GROUP = 'Applications' -DEFAULT_GROUP_ICON = 'fas fa-box' -DEFAULT_TITLE = 'Traefik Routers' -DEFAULT_ICON = 'fas fa-bars' - +TRAEFIK_API_URL = os.getenv('TRAEFIK_API_URL') +# Check if TRAEFIK_API_URL is set +if not TRAEFIK_API_URL: + raise ValueError("TRAEFIK_API_URL environment variable is required and not set.") +USER_COLORS_FILE = os.getenv('USER_COLORS_FILE', False) +USER_CONFIG_FILE = os.getenv('USER_CONFIG_FILE', False) +router_configs, group_configs, global_configs = {}, {}, {} client = docker.from_env() -def get_routers(containers): +def get_defaults(global_configs): + return { + 'REGEX_PATTERNS': global_configs.get('url_regex', [r".*"]), + 'DEFAULT_GROUP_PRIORITY': global_configs.get('group_priority', 100), + 'DEFAULT_GROUP': global_configs.get('default_group', 'Applications'), + 'DEFAULT_GROUP_ICON': global_configs.get('group_icon', 'fas fa-box'), + 'DEFAULT_GROUP_COLLAPSED': global_configs.get('group_collapsed', 'false'), + 'DEFAULT_TITLE': global_configs.get('title', 'Traefik Routers'), + 'DEFAULT_ROUTER_ICON': global_configs.get('router_icon', 'fas fa-bars'), + 'DEFAULT_PROTOCOL': global_configs.get('entrypoint', 'http'), + 'DEFAULT_TARGET': global_configs.get('target', '_blank'), + } + + +def load_yaml(file_path): + with open(file_path, 'r') as file: + return yaml.safe_load(file) + +def merge_colors(default_colors, user_colors): + for mode in default_colors: + if mode in user_colors: + default_colors[mode].update(user_colors[mode]) + return default_colors + +def categorize_configs(config): + for key, value in config.items(): + if key == "routers": + router_configs.update(value) + elif key == "groups": + group_configs.update(value) + elif key == "global": + global_configs.update(value) + +def fetch_containers(): + containers = client.containers.list(all=True) + for container in containers: + labels = container.attrs.get('Config', {}).get('Labels', {}) + for label, value in labels.items(): + if label.startswith('traefik-frontend.http.routers.'): + router_name = label.split('.')[3] + if router_name not in router_configs: + router_configs[router_name] = {} + key = label.split('.')[4] + router_configs[router_name][key] = value + elif label.startswith('traefik-frontend.groups.'): + group_name = label.split('.')[2] + if group_name not in group_configs: + group_configs[group_name] = {} + key = label.split('.')[3] + group_configs[group_name][key] = value + elif label.startswith('traefik-frontend.'): + key = label.split('.')[1] + global_configs[key] = value + +def get_routers(defaults): response = requests.get(TRAEFIK_API_URL, verify=False) # Ignore SSL certificate verification if response.status_code == 200: routers = response.json() - filtered_routers = filter_routers(routers, containers) + filtered_routers = filter_routers(routers, defaults) return filtered_routers return [] -def filter_routers(routers, containers): +def filter_routers(routers, defaults): filtered_routers = [] for router in routers: - for pattern in REGEX_PATTERNS: + for pattern in defaults["REGEX_PATTERNS"]: if re.match(pattern, router['rule'].split('`')[1]): - if not is_router_hidden(router['name'], containers): - router['description'] = get_router_description(router['name'], containers) - router['display_name'] = get_router_display_name(router['name'], containers) - router['icon'] = get_router_icon(router['name'], containers) - router['group'] = get_router_group(router['name'], containers) + if not is_router_hidden(router['name']): + router_name = router['name'].split('@')[0] + router['description'] = router_configs.get(router_name, {}).get('description', False) + router['display_name'] = router_configs.get(router_name, {}).get('router_name', router_name).upper() + router['icon'] = router_configs.get(router_name, {}).get('icon', defaults['DEFAULT_ROUTER_ICON']) + router['group'] = router_configs.get(router_name, {}).get('group', defaults['DEFAULT_GROUP']) + router['protocol'] = router_configs.get(router_name, {}).get('entrypoint', defaults['DEFAULT_PROTOCOL']) + router['target'] = router_configs.get(router_name, {}).get('target', defaults['DEFAULT_TARGET']) filtered_routers.append(router) break return filtered_routers -def is_router_hidden(router_name, containers): +def is_router_hidden(router_name): service_name = router_name.split('@')[0] - for container in containers: - labels = container.attrs.get('Config', {}).get('Labels', {}) - hidden_label = f'traefik-frontend.http.routers.{service_name}.hidden' - if labels.get(hidden_label) == 'true': - return True - return False - -def get_router_description(router_name, containers): - service_name = router_name.split('@')[0] - for container in containers: - labels = container.attrs.get('Config', {}).get('Labels', {}) - description_label = f'traefik-frontend.http.routers.{service_name}.description' - if description_label in labels: - return labels[description_label] - return 'No description available' - -def get_router_display_name(router_name, containers): - service_name = router_name.split('@')[0] - for container in containers: - labels = container.attrs.get('Config', {}).get('Labels', {}) - display_name_label = f'traefik-frontend.http.routers.{service_name}.router_name' - if display_name_label in labels: - return labels[display_name_label].upper() - return service_name.upper() - -def get_router_group(router_name, containers): - service_name = router_name.split('@')[0] - for container in containers: - labels = container.attrs.get('Config', {}).get('Labels', {}) - group_label = f'traefik-frontend.http.routers.{service_name}.group' - if group_label in labels: - return labels[group_label] - return DEFAULT_GROUP - -def get_router_icon(router_name, containers): - service_name = router_name.split('@')[0] - for container in containers: - labels = container.attrs.get('Config', {}).get('Labels', {}) - icon_label = f'traefik-frontend.http.routers.{service_name}.icon' - if icon_label in labels: - return labels[icon_label] - return DEFAULT_ICON - -def get_groups(containers): - groups = {DEFAULT_GROUP: {'priority': DEFAULT_GROUP_PRIORITY, 'collapsed': False, 'routers': [], 'icon': DEFAULT_GROUP_ICON}} - for container in containers: - labels = container.attrs.get('Config', {}).get('Labels', {}) - for label, value in labels.items(): - if label.startswith('traefik-frontend.groups.'): - group_name = label.split('.')[2] - priority_label = f'traefik-frontend.groups.{group_name}.priority' - collapsed_label = f'traefik-frontend.groups.{group_name}.collapsed' - icon_label = f'traefik-frontend.groups.{group_name}.icon' - - if group_name not in groups: - groups[group_name] = { - 'priority': DEFAULT_GROUP_PRIORITY, - 'collapsed': False, - 'routers': [], - 'icon': DEFAULT_GROUP_ICON - } - - if priority_label in labels: - groups[group_name]['priority'] = int(labels[priority_label]) - if collapsed_label in labels: - groups[group_name]['collapsed'] = labels[collapsed_label].lower() == 'true' - if icon_label in labels: - groups[group_name]['icon'] = labels[icon_label] + return router_configs.get(service_name, {}).get('hidden') == 'true' +def get_groups(defaults): + groups = { + defaults['DEFAULT_GROUP']: { + 'priority': int(defaults['DEFAULT_GROUP_PRIORITY']), + 'collapsed': defaults['DEFAULT_GROUP_COLLAPSED'].lower() == 'true' if isinstance(defaults['DEFAULT_GROUP_COLLAPSED'], str) else defaults['DEFAULT_GROUP_COLLAPSED'], + 'routers': [], + 'icon': defaults['DEFAULT_GROUP_ICON'] + } + } + + for group_name, config in group_configs.items(): + groups[group_name] = { + 'priority': int(config.get('priority', defaults['DEFAULT_GROUP_PRIORITY'])), + 'collapsed': config.get('collapsed', defaults["DEFAULT_GROUP_COLLAPSED"]).lower() == 'true' if isinstance(config.get('collapsed', defaults["DEFAULT_GROUP_COLLAPSED"]), str) else config.get('collapsed', defaults["DEFAULT_GROUP_COLLAPSED"]), + 'routers': [], + 'icon': config.get('icon', defaults['DEFAULT_GROUP_ICON']) + } + return groups -def get_title(containers): - for container in containers: - labels = container.attrs.get('Config', {}).get('Labels', {}) - title_label = 'traefik-frontend.title' - if title_label in labels: - return labels[title_label] - return DEFAULT_TITLE +@app.route('/static/css/colors.css') +def colors_css(): + DEFAULT_COLORS_FILE = '/app/colors.yml' + default_colors = load_yaml(DEFAULT_COLORS_FILE) + if os.path.exists(USER_COLORS_FILE) and USER_COLORS_FILE: + user_colors = load_yaml(USER_COLORS_FILE) + merged_colors = merge_colors(default_colors["colors"], user_colors["colors"]) + else: + merged_colors = default_colors["colors"] + return render_template('colors.css', colors=merged_colors), {'Content-Type': 'text/css'} + @app.route('/') def index(): - containers = client.containers.list(all=True) # Get all containers once - title = get_title(containers) - routers = get_routers(containers) - groups = get_groups(containers) + if os.path.exists(USER_CONFIG_FILE) and USER_CONFIG_FILE: + user_config = load_yaml(USER_CONFIG_FILE) + categorize_configs(user_config.get("config", {})) + fetch_containers() + defaults = get_defaults(global_configs) + title = defaults["DEFAULT_TITLE"] + routers = get_routers(defaults) + groups = get_groups(defaults) for router in routers: group_name = router['group'] if group_name not in groups: @@ -133,5 +151,4 @@ def index(): return render_template('index.html', title=title, groups=sorted_groups) if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0') - + serve(app, host='0.0.0.0', port=5000) diff --git a/colors.yml b/colors.yml new file mode 100644 index 0000000..dca8f40 --- /dev/null +++ b/colors.yml @@ -0,0 +1,26 @@ +colors: + light: + background: "#f0f0f0" + title-text: "#e0e0e0" + icons: "#333" + search-bar-bg: "#f0f0f0" + icon-bg: "#f0f0f033" + icon-hover-bg: "#f0f0f066" + router-bg: "#ffffff" + router-hover-shadow: "#00000033" + router-text: "#666666" + accent: "#3f51b5" + + dark: + background: "#121212" + title-text: "#e0e0e0" + icons: "#e0e0e0" + header-bg: "#1e1e1e" + search-bar-bg: "#121212" + icon-bg: "#e0e0e033" + icon-hover-bg: "#e0e0e066" + router-bg: "#1e1e1e" + router-hover-shadow: "#ffffff33" + router-text: "#e0e0e0" + footer-bg: "#1e1e1e" + accent: "#bb86fc" diff --git a/docker-compose.yml b/docker-compose.yml index 24d7713..901758e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,10 +7,14 @@ services: dockerfile: Dockerfile volumes: - /var/run/docker.sock:/var/run/docker.sock:ro + - ./data:/data environment: - FLASK_ENV=development - PUID=1000 - PGID=1000 + - TRAEFIK_API_URL=http://norman.lan/api/http/routers + - USER_CONFIG_FILE=/data/config.yml + #- USER_COLORS_FILE=/data/accent.yml networks: - web labels: @@ -43,6 +47,7 @@ services: - "traefik-frontend.groups.Security.priority=6" - "traefik-frontend.groups.Security.icon=fas fa-lock" - "traefik-frontend.title=GEDERICO'S HOME" + - "traefik-frontend.entrypoint=https" networks: web: diff --git a/requirements.txt b/requirements.txt index a63d0c5..da81675 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ docker==7.1.0 Flask==2.3.2 +PyYAML==6.0.1 Requests==2.32.3 +waitress==2.1.2 diff --git a/static/css/colors.css b/static/css/colors.css deleted file mode 100644 index 9890501..0000000 --- a/static/css/colors.css +++ /dev/null @@ -1,122 +0,0 @@ -:root { - --light-background: #f0f0f0; - --dark-background: #121212; - --light-title-text: #e0e0e0; - --light-icons: #333; - --dark-title-text: #e0e0e0; - --dark-icons: #e0e0e0; - --light-header: #3f51b5; - --light-group-text: #3f51b5; - --light-router-title: #3f51b5; - --dark-header: #1e1e1e; - --light-border: #3f51b5; - --dark-border: #bb86fc; - --dark-group-text: #bb86fc; - --dark-router-title: #bb86fc; - --light-icon-bg: rgba(240, 240, 240, 0.2); - --dark-icon-bg: rgba(224, 224, 224, 0.2); - --light-icon-hover-bg: rgba(240, 240, 240, 0.4); - --dark-icon-hover-bg: rgba(224, 224, 224, 0.4); - --light-router-bg: white; - --dark-router-bg: #1e1e1e; - --light-router-hover-shadow: rgba(0, 0, 0, 0.2); - --light-router-text: #666; - --dark-router-text: #e0e0e0; - --dark-router-hover-shadow: rgba(255, 255, 255, 0.2); -} - -body { - background-color: var(--light-background); - color: var(--light-title-text); -} - -.dark-mode { - background-color: var(--dark-background); - color: var(--dark-title-text); -} - -.header { - background-color: var(--light-header); -} - -.dark-mode .header { - background-color: var(--dark-header); -} - -.icon-button { - background-color: var(--light-icon-bg); - border: 1px solid var(--light-border); - color: var(--light-icons); -} - -.icon-button:hover { - background-color: var(--light-icon-hover-bg); -} - -.dark-mode .icon-button { - background-color: var(--dark-icon-bg); - border: 1px solid var(--dark-border); - color: var(--dark-icons); -} - -.dark-mode .icon-button:hover { - background-color: var(--dark-icon-hover-bg); -} - -.group h2 { - color: var(--light-group-text); -} - -.dark-mode .group h2 { - color: var(--dark-group-text); -} - -.router { - background-color: var(--light-router-bg); - color: var(--light-router-text); -} - -.router:hover { - box-shadow: 0 4px 8px var(--light-router-hover-shadow); -} - -.router h2 { - color: var(--light-router-title); -} - -.dark-mode .router h2 { - color: var(--dark-router-title); -} - -.dark-mode .router { - background-color: var(--dark-router-bg); - color: var(--dark-router-text); -} - -.dark-mode .router:hover { - box-shadow: 0 4px 8px var(--dark-router-hover-shadow); -} - -.group-title .fa { - color: var(--light-group-text); -} - -.dark-mode .group-title .fa { - color: var(--dark-group-text); -} - -.settings-menu .fa { - color: var(--light-icons); -} - -.dark-mode .settings-menu .fa { - color: var(--dark-icons); -} -.router svg { - font-size: 26px; - color: var(--light-router-title); -} - -.dark-mode .router svg { - color: var(--dark-router-title); -} diff --git a/static/css/components.css b/static/css/components.css index 19cdb1a..6e05ba3 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -9,6 +9,7 @@ margin: 0; flex-grow: 1; text-align: center; + font-size: 2em; } .settings { @@ -41,13 +42,14 @@ } .group { - margin-bottom: 20px; /* Reduced margin between groups */ + margin-bottom: 15px; /* Reduced margin between groups */ } .group-title { display: inline-block; cursor: pointer; margin-top: 10px; /* Reduced margin above group title */ + margin-left: 15px; } .group-icon { @@ -89,34 +91,139 @@ margin: 0; } +.router div { + overflow: hidden; +} + .router p { font-size: 14px; margin: 5px 0 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .content { padding: 20px; /* Added padding to the left and right of the webpage */ + padding-bottom: 60px +} + +/* List mode styles */ +.list-mode .content { + display: flex; + flex-wrap: wrap; +} + +.list-mode .group { + display: flex; + flex-direction: column; + flex: 1 1 calc(33.333% - 40px); /* Adjusted for new margin */ + max-width: calc(33.333% - 40px); /* Adjusted for new margin */ + margin: 0 15px 15px 15px; +} + +.list-mode .group-title { + margin-left: 0; +} + +.list-mode .router-container { + flex-direction: column; + flex-wrap: nowrap; +} + +.list-mode .router { + max-width: 100%; + margin: 1px 0; /* Adjusted margin between routers */ +} + +.router:first-child { + margin-top: 15px; /* Adjusted margin between routers */ +} + +.footer { + position: fixed; + bottom: 0; + width: 100%; + padding: 10px 0; + box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.search-bar { + display: flex; + align-items: center; + width: 100%; + max-width: 600px; + border-radius: 20px; + padding: 5px 10px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin: 0 20px; +} + + +.search-bar i { + margin-right: 10px; +} + +#clear-search, #start-search { + cursor: pointer; +} + +.search-bar input { + width: 100%; + border: none; + background: none; + outline: none; + text-align: center; + font-weight: bold; } /* Responsive Design */ @media (max-width: 1024px) { - .router { + body:not(.list-mode) .router { + flex: 1 1 calc(50% - 40px); /* Adjusted for new margin */ + max-width: calc(50% - 40px); /* Adjusted for new margin */ + } + + .list-mode .group { flex: 1 1 calc(50% - 40px); /* Adjusted for new margin */ max-width: calc(50% - 40px); /* Adjusted for new margin */ } } @media (max-width: 768px) { - .router { + body:not(.list-mode) .router { flex: 1 1 calc(50% - 40px); /* Adjusted for new margin */ max-width: calc(50% - 40px); /* Adjusted for new margin */ } + + .list-mode .group { + flex: 1 1 calc(50% - 40px); /* Adjusted for new margin */ + max-width: calc(50% - 40px); /* Adjusted for new margin */ + } + .header h1 { + font-size: 1.8em; + } } @media (max-width: 480px) { - .router { + body:not(.list-mode) .router { flex: 1 1 100%; max-width: 100%; } + + .list-mode .group { + flex: 1 1 100%; + max-width: 100%; + } + .router:first-child { + margin-top: 1px; /* Adjusted margin between routers */ + } + .header h1 { + font-size: 1.5em; + } } diff --git a/static/css/styles.css b/static/css/styles.css index b383143..eac0e42 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,5 +1,5 @@ body { - font-family: Arial, sans-serif; + font-family: 'Helvetica', 'Arial', sans-serif; margin: 0; transition: background-color 0.3s, color 0.3s; } @@ -34,4 +34,10 @@ body { transition: box-shadow 0.3s ease; box-sizing: border-box; } - +/* Custom scrollbar for webkit browsers */ +body::-webkit-scrollbar { + width: 8px; + height: 8px; + overflow: visible; + display: none; +} diff --git a/static/js/scripts.js b/static/js/scripts.js index a5ed9bd..88dbce1 100644 --- a/static/js/scripts.js +++ b/static/js/scripts.js @@ -1,4 +1,8 @@ +let initialWindowHeight; + document.addEventListener("DOMContentLoaded", function() { + initialWindowHeight = window.innerHeight; + if (localStorage.getItem("dark-mode") === "true") { document.body.classList.add("dark-mode"); document.getElementById('theme-icon').classList.remove('fa-moon'); @@ -8,6 +12,15 @@ document.addEventListener("DOMContentLoaded", function() { document.getElementById('theme-icon').classList.add('fa-moon'); } + if (localStorage.getItem("view-mode") === "list") { + document.body.classList.add("list-mode"); + document.getElementById('view-mode-icon').classList.remove('fa-list'); + document.getElementById('view-mode-icon').classList.add('fa-table'); + } else { + document.getElementById('view-mode-icon').classList.remove('fa-table'); + document.getElementById('view-mode-icon').classList.add('fa-list'); + } + document.querySelectorAll('.group').forEach(group => { const groupName = group.id; const collapsed = group.dataset.collapsed === 'true'; @@ -24,8 +37,30 @@ document.addEventListener("DOMContentLoaded", function() { settingsMenu.classList.remove('visible'); } }); + + const searchInput = document.getElementById('search-input'); + searchInput.addEventListener('input', handleSearch); + searchInput.addEventListener('keydown', handleKeyDown); + + document.addEventListener('keydown', function(event) { + if (event.key === '/') { + event.preventDefault(); + searchInput.focus(); + } + if (event.key === 'Escape' || event.key === 'Esc') { + event.preventDefault(); + clearSearch(); + searchInput.blur(); + } + }); + + window.visualViewport.addEventListener('resize', adjustFooterPosition); }); +function searchFocus() { + document.getElementById('search-input').focus() +} + function toggleDarkMode() { document.body.classList.toggle("dark-mode"); const themeIcon = document.getElementById('theme-icon'); @@ -56,3 +91,171 @@ function refreshData() { location.reload(); } +function toggleViewMode() { + document.body.classList.toggle("list-mode"); + const viewModeIcon = document.getElementById('view-mode-icon'); + if (document.body.classList.contains("list-mode")) { + localStorage.setItem("view-mode", "list"); + viewModeIcon.classList.remove('fa-list'); + viewModeIcon.classList.add('fa-table'); + } else { + localStorage.setItem("view-mode", "box"); + viewModeIcon.classList.remove('fa-table'); + viewModeIcon.classList.add('fa-list'); + } +} + +let selectedRouterIndex = -1; + +function handleSearch(event) { + const query = event.target.value.toLowerCase(); + const groups = document.querySelectorAll('.group'); + const content = document.getElementById('content'); + + // Remove the search group if it exists + let searchGroup = document.getElementById('search-group'); + if (searchGroup) { + searchGroup.remove(); + } + + if (query === '') { + // Show all original groups + groups.forEach(group => group.style.display = ''); + selectedRouterIndex = -1; + } else { + // Hide all original groups + groups.forEach(group => group.style.display = 'none'); + + // Create and show the search group + searchGroup = document.createElement('div'); + searchGroup.id = 'search-group'; + searchGroup.className = 'group'; + content.appendChild(searchGroup); + + searchGroup.innerHTML = ` +
{{ router['description'] }}
+ {% if router['description'] %} +{{ router['description'] }}
+ {% endif %}