From 875a60f90f162c99d364c1993d1583d8ea9986f2 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 21 May 2026 20:37:54 +0100 Subject: [PATCH] =?UTF-8?q?LibrePortal=20v0.1.0=20=E2=80=94=20initial=20re?= =?UTF-8?q?lease?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys, Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun VPN routing, and a web dashboard to manage it all. Free & open forever to self-host; optional paid hosted services fund it. See PROMISE.md. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- .claude/settings.json | 5 + .gitignore | 12 + CONTRIBUTING.md | 40 + LICENSE | 661 ++ PROMISE.md | 46 + README.md | 49 + configs/backup/.category | 5 + configs/backup/backup_advanced | 8 + configs/backup/backup_general | 5 + configs/backup/backup_retention | 13 + configs/backup/locations/1/location.config | 27 + configs/features/.category | 5 + configs/features/features_core | 18 + configs/features/features_security | 7 + configs/features/features_terminal | 11 + configs/general/.category | 5 + configs/general/general_basic | 5 + configs/general/general_docker_install | 7 + configs/general/general_install | 9 + configs/general/general_libreportal | 4 + configs/general/general_mail | 10 + configs/general/general_terminal | 9 + configs/network/.category | 5 + configs/network/network_dns | 8 + configs/network/network_docker | 7 + configs/network/network_domains | 12 + configs/network/network_headscale | 5 + configs/network/network_ports | 5 + configs/network/network_whitelist | 4 + configs/security/.category | 5 + configs/security/security_logins | 5 + configs/webui/.category | 5 + configs/webui/webui_logins | 5 + configs/webui/webui_logs | 6 + containers/adguard/adguard.config | 79 + containers/adguard/adguard.sh | 276 + containers/adguard/adguard.svg | 1 + containers/adguard/docker-compose.yml | 61 + .../grafana-dashboards/adguard.json | 74 + .../monitoring/prometheus-scrape.yml | 4 + containers/authelia/authelia.config | 74 + containers/authelia/authelia.sh | 208 + containers/authelia/authelia.svg | 1 + containers/authelia/docker-compose.yml | 43 + .../authelia/resources/configuration.yml | 76 + .../grafana-dashboards/authelia.json | 1211 +++ .../monitoring/prometheus-scrape.yml | 9 + .../authelia/resources/users_database.yml | 11 + containers/bookstack/bookstack.config | 76 + containers/bookstack/bookstack.sh | 180 + containers/bookstack/bookstack.svg | 1 + containers/bookstack/docker-compose.yml | 68 + containers/crowdsec/crowdsec.config | 68 + containers/crowdsec/crowdsec.sh | 100 + containers/crowdsec/crowdsec.svg | 32 + .../details-per-machine.json | 1520 +++ .../grafana-dashboards/insight.json | 811 ++ .../grafana-dashboards/lapi-metrics.json | 515 + .../grafana-dashboards/overview.json | 1321 +++ .../monitoring/prometheus-scrape.yml | 9 + containers/dashy/dashy.config | 85 + containers/dashy/dashy.sh | 114 + containers/dashy/dashy.svg | 161 + containers/dashy/docker-compose.yml | 45 + containers/dashy/resources/conf.yml | 232 + containers/focalboard/docker-compose.yml | 44 + containers/focalboard/focalboard.config | 68 + containers/focalboard/focalboard.sh | 110 + containers/focalboard/focalboard.svg | 1 + containers/gitea/docker-compose.yml | 92 + containers/gitea/gitea.config | 73 + containers/gitea/gitea.sh | 139 + containers/gitea/gitea.svg | 4 + .../monitoring/grafana-dashboards/gitea.json | 1096 +++ .../monitoring/prometheus-scrape.yml | 13 + containers/gluetun/docker-compose.yml | 55 + containers/gluetun/gluetun.config | 96 + containers/gluetun/gluetun.sh | 136 + containers/gluetun/gluetun.svg | 13 + .../grafana-dashboards/gluetun.json | 67 + .../monitoring/prometheus-scrape.yml | 4 + containers/grafana/docker-compose.yml | 43 + containers/grafana/grafana.config | 67 + containers/grafana/grafana.sh | 133 + containers/grafana/grafana.svg | 70 + .../grafana-dashboards/grafana.json | 233 + .../monitoring/prometheus-scrape.yml | 4 + containers/headscale/docker-compose.yml | 70 + containers/headscale/headscale.config | 63 + containers/headscale/headscale.sh | 127 + containers/headscale/headscale.svg | 1 + containers/headscale/resources/config.yaml | 327 + .../grafana-dashboards/headscale.json | 200 + .../monitoring/prometheus-scrape.yml | 4 + containers/invidious/docker-compose.yml | 76 + containers/invidious/invidious.config | 71 + containers/invidious/invidious.sh | 114 + containers/invidious/invidious.svg | 2 + containers/ipinfo/docker-compose.yml | 38 + containers/ipinfo/ipinfo.config | 62 + containers/ipinfo/ipinfo.sh | 110 + containers/ipinfo/ipinfo.svg | 1 + containers/jellyfin/docker-compose.yml | 41 + containers/jellyfin/jellyfin.config | 62 + containers/jellyfin/jellyfin.sh | 103 + containers/jellyfin/jellyfin.svg | 1 + containers/jitsimeet/docker-compose.yml | 125 + containers/jitsimeet/jitsimeet.config | 64 + containers/jitsimeet/jitsimeet.sh | 202 + containers/jitsimeet/jitsimeet.svg | 650 ++ containers/libreportal/.dockerignore | 10 + containers/libreportal/Dockerfile | 14 + .../libreportal/backend/package-lock.json | 951 ++ containers/libreportal/backend/package.json | 12 + .../libreportal/backend/routes/auth-routes.js | 92 + .../libreportal/backend/routes/routes.js | 224 + .../backend/routes/service-routes.js | 565 ++ .../backend/routes/setup-routes.js | 253 + .../libreportal/backend/routes/task-routes.js | 430 + .../libreportal/backend/routes/theme.js | 62 + .../libreportal/backend/routes/themes.js | 71 + containers/libreportal/backend/server.js | 15 + containers/libreportal/backend/utils/auth.js | 46 + .../libreportal/backend/utils/config.js | 61 + containers/libreportal/backend/utils/fifo.js | 22 + containers/libreportal/backend/utils/mail.js | 142 + .../libreportal/backend/utils/middleware.js | 42 + containers/libreportal/docker-compose.yml | 50 + .../libreportal/frontend/css/apps-layout.css | 93 + containers/libreportal/frontend/css/apps.css | 409 + .../frontend/css/aurora-background.css | 253 + .../libreportal/frontend/css/backup.css | 1063 +++ .../libreportal/frontend/css/config.css | 601 ++ .../libreportal/frontend/css/dashboard.css | 62 + containers/libreportal/frontend/css/forms.css | 1042 +++ .../libreportal/frontend/css/ip-whitelist.css | 248 + .../frontend/css/loading-screen.css | 541 ++ containers/libreportal/frontend/css/login.css | 199 + containers/libreportal/frontend/css/modal.css | 602 ++ .../libreportal/frontend/css/port-manager.css | 590 ++ .../libreportal/frontend/css/routing.css | 134 + .../frontend/css/service-buttons.css | 198 + .../libreportal/frontend/css/services.css | 199 + .../libreportal/frontend/css/setup-wizard.css | 1054 +++ .../libreportal/frontend/css/sidebar.css | 155 + containers/libreportal/frontend/css/style.css | 3816 ++++++++ containers/libreportal/frontend/css/tasks.css | 885 ++ .../libreportal/frontend/css/themes.css | 331 + containers/libreportal/frontend/css/tools.css | 632 ++ .../libreportal/frontend/css/topbar.css | 257 + .../frontend/html/app-content.html | 96 + .../frontend/html/apps-content.html | 71 + .../frontend/html/apps-unified-layout.html | 240 + .../frontend/html/backup-content.html | 173 + .../frontend/html/config-content.html | 21 + .../frontend/html/dashboard-content.html | 53 + .../frontend/html/tasks-content.html | 198 + .../libreportal/frontend/html/topbar.html | 71 + .../frontend/icons/apps/adguard.svg | 1 + .../frontend/icons/apps/authelia.svg | 1 + .../frontend/icons/apps/bookstack.svg | 1 + .../frontend/icons/apps/crowdsec.svg | 32 + .../libreportal/frontend/icons/apps/dashy.svg | 161 + .../frontend/icons/apps/default.svg | 1 + .../frontend/icons/apps/focalboard.svg | 1 + .../libreportal/frontend/icons/apps/gitea.svg | 4 + .../frontend/icons/apps/gluetun.svg | 13 + .../frontend/icons/apps/grafana.svg | 70 + .../frontend/icons/apps/headscale.svg | 1 + .../frontend/icons/apps/invidious.svg | 2 + .../frontend/icons/apps/ipinfo.svg | 1 + .../frontend/icons/apps/jellyfin.svg | 1 + .../frontend/icons/apps/jitsimeet.svg | 650 ++ .../frontend/icons/apps/libreportal.svg | 605 ++ .../frontend/icons/apps/linkding.svg | 1 + .../frontend/icons/apps/mastodon.svg | 1 + .../frontend/icons/apps/nextcloud.svg | 1 + .../frontend/icons/apps/ollama.svg | 1 + .../frontend/icons/apps/onlyoffice.svg | 1 + .../frontend/icons/apps/owncloud.svg | 1 + .../frontend/icons/apps/pihole.svg | 1 + .../frontend/icons/apps/portainer.svg | 1 + .../frontend/icons/apps/prometheus.svg | 1 + .../frontend/icons/apps/searxng.svg | 1 + .../frontend/icons/apps/speedtest.svg | 1 + .../frontend/icons/apps/traefik.svg | 1 + .../frontend/icons/apps/trilium.svg | 1 + .../frontend/icons/apps/unbound.svg | 1 + .../frontend/icons/apps/vaultwarden.svg | 1 + .../frontend/icons/apps/wireguard.svg | 1 + .../frontend/icons/categories/all.svg | 6 + .../icons/categories/communication.svg | 3 + .../frontend/icons/categories/development.svg | 4 + .../frontend/icons/categories/installed.svg | 3 + .../frontend/icons/categories/knowledge.svg | 7 + .../frontend/icons/categories/media.svg | 3 + .../frontend/icons/categories/misc.svg | 6 + .../frontend/icons/categories/monitoring.svg | 5 + .../frontend/icons/categories/networking.svg | 5 + .../icons/categories/productivity.svg | 5 + .../frontend/icons/categories/recommended.svg | 3 + .../frontend/icons/categories/security.svg | 4 + .../frontend/icons/categories/storage.svg | 4 + .../frontend/icons/categories/system.svg | 4 + .../frontend/icons/categories/utils.svg | 4 + .../frontend/icons/config/backup.svg | 5 + .../frontend/icons/config/features.svg | 5 + .../frontend/icons/config/general.svg | 3 + .../frontend/icons/config/network.svg | 5 + .../frontend/icons/config/security.svg | 4 + .../frontend/icons/config/webui.svg | 7 + .../libreportal/frontend/icons/favicon.ico | Bin 0 -> 87483 bytes .../frontend/icons/libreportal.svg | 605 ++ .../libreportal/frontend/icons/vpn/airvpn.svg | 1 + .../frontend/icons/vpn/cyberghost.png | Bin 0 -> 1147 bytes .../frontend/icons/vpn/expressvpn.svg | 1 + .../frontend/icons/vpn/fastestvpn.png | Bin 0 -> 112317 bytes .../frontend/icons/vpn/giganews.png | Bin 0 -> 2301 bytes .../frontend/icons/vpn/hidemyass.png | Bin 0 -> 260 bytes .../frontend/icons/vpn/ipvanish.svg | 1 + .../libreportal/frontend/icons/vpn/ivpn.png | Bin 0 -> 1375 bytes .../frontend/icons/vpn/mullvad.svg | 1 + .../frontend/icons/vpn/nordvpn.svg | 1 + .../frontend/icons/vpn/privado.png | Bin 0 -> 15086 bytes .../icons/vpn/private-internet-access.svg | 1 + .../frontend/icons/vpn/privatevpn.png | Bin 0 -> 15086 bytes .../frontend/icons/vpn/protonvpn.svg | 1 + .../frontend/icons/vpn/purevpn.png | Bin 0 -> 1505 bytes .../frontend/icons/vpn/slickvpn.png | Bin 0 -> 5430 bytes .../frontend/icons/vpn/surfshark.svg | 1 + .../frontend/icons/vpn/torguard.png | Bin 0 -> 15406 bytes .../frontend/icons/vpn/vpn-unlimited.png | Bin 0 -> 1397 bytes .../frontend/icons/vpn/vpnsecure.png | Bin 0 -> 370070 bytes .../frontend/icons/vpn/vyprvpn.png | Bin 0 -> 1378 bytes .../frontend/icons/vpn/windscribe.png | Bin 0 -> 15406 bytes containers/libreportal/frontend/index.html | 100 + .../frontend/js/components/app/app-manager.js | 363 + .../js/components/app/app-tabbed-manager.js | 1138 +++ .../js/components/app/apps-manager.js | 4073 ++++++++ .../js/components/app/port-manager.js | 821 ++ .../js/components/app/routing-manager.js | 244 + .../js/components/app/services-manager.js | 507 + .../js/components/app/tools-manager.js | 872 ++ .../js/components/backup/backup-app-card.js | 173 + .../js/components/backup/backup-page.js | 1533 +++ .../js/components/config/config-core.js | 80 + .../js/components/config/config-form.js | 124 + .../components/config/config-manager-old.js | 1645 ++++ .../js/components/config/config-manager.js | 307 + .../js/components/config/config-options.js | 434 + .../js/components/config/config-renderer.js | 227 + .../js/components/config/config-router.js | 291 + .../js/components/config/config-shared.js | 1542 +++ .../js/components/config/config-sidebar.js | 96 + .../js/components/config/config-utils.js | 111 + .../js/components/config/config-validator.js | 248 + .../js/components/config/domain-manager.js | 646 ++ .../components/config/ip-whitelist-manager.js | 952 ++ .../js/components/config/toggle-manager.js | 319 + .../js/components/confirmation-dialog.js | 167 + .../frontend/js/components/dashboard.js | 128 + .../frontend/js/components/eo-modal.js | 222 + .../frontend/js/components/mobile-menu.js | 70 + .../frontend/js/components/notifications.js | 448 + .../js/components/task/task-actions.js | 407 + .../js/components/task/task-commands.js | 360 + .../js/components/task/task-event-bus.js | 139 + .../components/task/task-global-functions.js | 28 + .../js/components/task/task-manager.js | 178 + .../js/components/task/task-router.js | 227 + .../js/components/tasks/tasks-manager.js | 2457 +++++ .../frontend/js/components/topbar.js | 399 + containers/libreportal/frontend/js/spa.js | 550 ++ .../frontend/js/system/auth-manager.js | 177 + .../frontend/js/system/custom-number.js | 212 + .../frontend/js/system/custom-select.js | 318 + .../frontend/js/system/loading-ui.js | 404 + .../js/system/setup-completion-watcher.js | 234 + .../frontend/js/system/setup-detector.js | 440 + .../frontend/js/system/setup-wizard.js | 758 ++ .../frontend/js/system/system-loader.js | 1342 +++ .../frontend/js/system/system-orchestrator.js | 385 + .../frontend/js/system/theme-registry.js | 82 + .../frontend/js/task-parameter-preserve.js | 18 + .../frontend/js/utils/data-loader.js | 555 ++ .../frontend/js/utils/dom-helpers.js | 65 + .../frontend/js/utils/html-cache.js | 0 .../libreportal/frontend/js/utils/router.js | 287 + .../frontend/js/utils/ui-helpers.js | 99 + .../libreportal/frontend/themes/README.md | 55 + .../frontend/themes/dark-blue/meta.json | 5 + .../frontend/themes/dark-blue/theme.css | 75 + .../frontend/themes/example/meta.json | 4 + .../frontend/themes/example/theme.css | 103 + .../frontend/themes/light/meta.json | 5 + .../frontend/themes/light/theme.css | 75 + .../frontend/themes/nebula/meta.json | 5 + .../frontend/themes/nebula/theme.css | 151 + containers/libreportal/libreportal.config | 62 + containers/libreportal/libreportal.sh | 132 + containers/libreportal/libreportal.svg | 605 ++ containers/linkding/docker-compose.yml | 33 + containers/linkding/linkding.config | 62 + containers/linkding/linkding.sh | 118 + containers/linkding/linkding.svg | 1 + containers/linkding/resources/.env | 49 + containers/mastodon/docker-compose.yml | 75 + containers/mastodon/mastodon.config | 63 + containers/mastodon/mastodon.sh | 106 + containers/mastodon/mastodon.svg | 1 + containers/moneyapp/.dockerignore | 36 + containers/moneyapp/.gitignore | 41 + containers/moneyapp/Dockerfile | 81 + containers/moneyapp/README.md | 94 + containers/moneyapp/app/components.json | 21 + containers/moneyapp/app/drizzle.config.ts | 13 + .../app/drizzle/0000_fresh_silverclaw.sql | 186 + .../app/drizzle/meta/0000_snapshot.json | 1367 +++ .../moneyapp/app/drizzle/meta/_journal.json | 13 + containers/moneyapp/app/eslint.config.mjs | 25 + containers/moneyapp/app/next.config.ts | 8 + containers/moneyapp/app/package-lock.json | 8258 +++++++++++++++++ containers/moneyapp/app/package.json | 53 + containers/moneyapp/app/postcss.config.mjs | 8 + containers/moneyapp/app/public/file.svg | 1 + containers/moneyapp/app/public/globe.svg | 1 + containers/moneyapp/app/public/next.svg | 1 + containers/moneyapp/app/public/vercel.svg | 1 + containers/moneyapp/app/public/window.svg | 1 + .../moneyapp/app/src/app/(app)/actions.ts | 7 + .../app/src/app/(app)/budgets/actions.ts | 40 + .../app/src/app/(app)/budgets/page.tsx | 119 + .../src/app/(app)/forecast/forecast-chart.tsx | 98 + .../app/src/app/(app)/forecast/page.tsx | 70 + .../app/src/app/(app)/goals/actions.ts | 46 + .../app/src/app/(app)/goals/calculator.tsx | 111 + .../moneyapp/app/src/app/(app)/goals/page.tsx | 116 + .../app/src/app/(app)/import/actions.ts | 95 + .../app/src/app/(app)/import/page.tsx | 53 + .../moneyapp/app/src/app/(app)/layout.tsx | 30 + .../app/src/app/(app)/ledger/actions.ts | 86 + .../app/src/app/(app)/ledger/ledger-table.tsx | 317 + .../app/src/app/(app)/ledger/page.tsx | 61 + .../moneyapp/app/src/app/(app)/page.tsx | 216 + .../app/src/app/(app)/recurring/actions.ts | 68 + .../app/src/app/(app)/recurring/page.tsx | 139 + .../app/src/app/(app)/settings/actions.ts | 39 + .../app/src/app/(app)/settings/page.tsx | 86 + .../app/src/app/(auth)/login/actions.ts | 18 + .../app/src/app/(auth)/login/page.tsx | 45 + .../app/src/app/(auth)/signup/actions.ts | 51 + .../app/src/app/(auth)/signup/page.tsx | 40 + .../src/app/api/auth/[...nextauth]/route.ts | 2 + containers/moneyapp/app/src/app/favicon.ico | Bin 0 -> 25931 bytes containers/moneyapp/app/src/app/globals.css | 93 + containers/moneyapp/app/src/app/layout.tsx | 34 + .../app/src/app/onboarding/actions.ts | 38 + .../moneyapp/app/src/app/onboarding/page.tsx | 85 + containers/moneyapp/app/src/auth.config.ts | 19 + containers/moneyapp/app/src/auth.ts | 74 + .../app/src/components/animated-number.tsx | 28 + .../moneyapp/app/src/components/sidebar.tsx | 61 + .../moneyapp/app/src/components/topbar.tsx | 19 + .../moneyapp/app/src/components/ui/badge.tsx | 30 + .../moneyapp/app/src/components/ui/button.tsx | 49 + .../moneyapp/app/src/components/ui/card.tsx | 50 + .../moneyapp/app/src/components/ui/input.tsx | 25 + .../moneyapp/app/src/components/ui/label.tsx | 19 + .../app/src/components/ui/progress.tsx | 21 + .../app/src/components/ui/separator.tsx | 20 + containers/moneyapp/app/src/db/index.ts | 14 + containers/moneyapp/app/src/db/migrate.ts | 18 + containers/moneyapp/app/src/db/schema.ts | 373 + containers/moneyapp/app/src/lib/finance.ts | 47 + .../moneyapp/app/src/lib/ledger-service.ts | 100 + containers/moneyapp/app/src/lib/recurring.ts | 158 + containers/moneyapp/app/src/lib/seed.ts | 30 + containers/moneyapp/app/src/lib/session.ts | 21 + containers/moneyapp/app/src/lib/utils.ts | 24 + containers/moneyapp/app/src/middleware.ts | 8 + .../moneyapp/app/src/types/next-auth.d.ts | 23 + containers/moneyapp/app/tailwind.config.ts | 98 + containers/moneyapp/app/tsconfig.json | 27 + containers/moneyapp/docker-compose.yml | 53 + containers/moneyapp/moneyapp.config | 58 + containers/moneyapp/moneyapp.sh | 148 + containers/nextcloud/docker-compose.yml | 92 + containers/nextcloud/nextcloud.config | 75 + containers/nextcloud/nextcloud.sh | 119 + containers/nextcloud/nextcloud.svg | 1 + .../grafana-dashboards/nextcloud.json | 73 + .../monitoring/prometheus-scrape.yml | 4 + containers/ollama/docker-compose.yml | 71 + containers/ollama/ollama.config | 66 + containers/ollama/ollama.sh | 119 + containers/ollama/ollama.svg | 1 + .../monitoring/grafana-dashboards/ollama.json | 61 + .../monitoring/prometheus-scrape.yml | 4 + containers/onlyoffice/docker-compose.yml | 43 + containers/onlyoffice/onlyoffice.config | 62 + containers/onlyoffice/onlyoffice.sh | 117 + containers/onlyoffice/onlyoffice.svg | 1 + containers/owncloud/docker-compose.yml | 103 + containers/owncloud/owncloud.config | 72 + containers/owncloud/owncloud.sh | 152 + containers/owncloud/owncloud.svg | 1 + containers/pihole/docker-compose.yml | 69 + containers/pihole/pihole.config | 78 + containers/pihole/pihole.sh | 121 + containers/pihole/pihole.svg | 1 + .../monitoring/grafana-dashboards/pihole.json | 151 + .../monitoring/prometheus-scrape.yml | 4 + containers/prometheus/docker-compose.yml | 73 + containers/prometheus/prometheus.config | 62 + containers/prometheus/prometheus.sh | 142 + containers/prometheus/prometheus.svg | 1 + .../prometheus/resources/prometheus.yml | 23 + containers/searxng/docker-compose.yml | 48 + containers/searxng/searxng.config | 69 + containers/searxng/searxng.sh | 135 + containers/searxng/searxng.svg | 1 + containers/speedtest/docker-compose.yml | 45 + containers/speedtest/speedtest.config | 70 + containers/speedtest/speedtest.sh | 110 + containers/speedtest/speedtest.svg | 1 + containers/traefik/docker-compose.yml | 37 + containers/traefik/resources/config.yml | 48 + containers/traefik/resources/crowdsec.yml | 21 + .../grafana-dashboards/traefik.json | 1528 +++ .../monitoring/prometheus-scrape.yml | 9 + .../traefik/resources/protectionauth.yml | 8 + containers/traefik/resources/tls.yml | 44 + containers/traefik/resources/traefik.yml | 61 + containers/traefik/resources/whitelist.yml | 6 + containers/traefik/traefik.config | 90 + containers/traefik/traefik.sh | 222 + containers/traefik/traefik.svg | 1 + containers/trilium/docker-compose.yml | 36 + containers/trilium/trilium.config | 62 + containers/trilium/trilium.sh | 136 + containers/trilium/trilium.svg | 1 + containers/unbound/docker-compose.yml | 45 + .../grafana-dashboards/unbound.json | 64 + .../monitoring/prometheus-scrape.yml | 4 + containers/unbound/resources/unbound.conf | 22 + containers/unbound/unbound.config | 66 + containers/unbound/unbound.sh | 126 + containers/unbound/unbound.svg | 1 + containers/vaultwarden/docker-compose.yml | 59 + .../grafana-dashboards/vaultwarden.json | 74 + .../monitoring/prometheus-scrape.yml | 4 + containers/vaultwarden/vaultwarden.config | 74 + containers/vaultwarden/vaultwarden.sh | 119 + containers/vaultwarden/vaultwarden.svg | 1 + containers/wireguard/docker-compose.yml | 56 + .../grafana-dashboards/wireguard.json | 64 + .../monitoring/prometheus-scrape.yml | 4 + containers/wireguard/wireguard.config | 78 + containers/wireguard/wireguard.sh | 200 + containers/wireguard/wireguard.svg | 1 + init.sh | 1219 +++ logs/backup.log | 0 logs/libreportal.log | 0 scripts/app/app_generate.sh | 164 + scripts/app/app_get_key_data.sh | 20 + scripts/app/app_scan_available.sh | 29 + scripts/app/app_status.sh | 41 + scripts/app/app_update_specifics.sh | 34 + scripts/app/auth_adapter.sh | 52 + .../adguard/adguard_apply_dns_updater.sh | 12 + .../app/containers/adguard/adguard_auth.sh | 44 + .../adguard/adguard_reset_password.sh | 8 + .../containers/bookstack/bookstack_auth.sh | 115 + .../bookstack/bookstack_create_account.sh | 10 + .../bookstack/bookstack_delete_user.sh | 6 + .../bookstack/bookstack_list_users.sh | 5 + .../bookstack/bookstack_reset_password.sh | 8 + .../bookstack/bookstack_set_admin.sh | 6 + .../crowdsec/crowdsec_alerts_list.sh | 5 + .../crowdsec/crowdsec_console_disable.sh | 7 + .../crowdsec/crowdsec_console_enroll.sh | 14 + .../crowdsec/crowdsec_decisions_list.sh | 5 + .../crowdsec/crowdsec_fix_priority.sh | 39 + .../crowdsec/crowdsec_hub_update.sh | 10 + .../containers/crowdsec/crowdsec_metrics.sh | 5 + .../containers/crowdsec/crowdsec_status.sh | 16 + .../app/containers/crowdsec/crowdsec_unban.sh | 12 + .../containers/crowdsec/crowdsec_update.sh | 40 + .../crowdsec/crowdsec_verify_firewall.sh | 33 + .../dashy/dashy_manage_shortcuts.sh | 25 + .../app/containers/dashy/dashy_update_conf.sh | 288 + .../containers/focalboard/focalboard_auth.sh | 64 + .../focalboard/focalboard_create_account.sh | 6 + .../focalboard/focalboard_delete_user.sh | 6 + .../focalboard/focalboard_list_users.sh | 5 + .../focalboard/focalboard_reset_password.sh | 8 + .../focalboard/focalboard_set_admin.sh | 6 + scripts/app/containers/gitea/gitea_auth.sh | 62 + .../containers/gitea/gitea_create_account.sh | 6 + .../app/containers/gitea/gitea_delete_user.sh | 6 + .../app/containers/gitea/gitea_list_users.sh | 5 + .../containers/gitea/gitea_reset_password.sh | 8 + .../app/containers/gitea/gitea_set_admin.sh | 6 + .../gluetun/gluetun_recreate_routed.sh | 66 + .../gluetun/gluetun_refresh_providers.sh | 16 + .../containers/invidious/invidious_auth.sh | 62 + .../invidious/invidious_create_account.sh | 6 + .../invidious/invidious_delete_user.sh | 6 + .../invidious/invidious_list_users.sh | 5 + .../invidious/invidious_reset_password.sh | 8 + .../invidious/invidious_set_admin.sh | 6 + .../containers/mattermost/mattermost_auth.sh | 60 + .../mattermost/mattermost_create_account.sh | 6 + .../mattermost/mattermost_delete_user.sh | 6 + .../mattermost/mattermost_list_users.sh | 5 + .../mattermost/mattermost_reset_password.sh | 8 + .../mattermost/mattermost_set_admin.sh | 6 + .../nextcloud/nextcloud_add_trusted_domain.sh | 24 + .../containers/nextcloud/nextcloud_auth.sh | 113 + .../nextcloud/nextcloud_create_account.sh | 10 + .../nextcloud/nextcloud_delete_user.sh | 6 + .../nextcloud/nextcloud_list_users.sh | 5 + .../nextcloud/nextcloud_rescan_files.sh | 21 + .../nextcloud/nextcloud_reset_password.sh | 8 + .../nextcloud/nextcloud_set_admin.sh | 6 + .../nextcloud/nextcloud_system_status.sh | 9 + .../nextcloud/nextcloud_tail_logs.sh | 13 + .../nextcloud/nextcloud_toggle_maintenance.sh | 21 + .../owncloud/owncloud_setup_config.sh | 133 + .../pihole/pihole_apply_dns_updater.sh | 12 + .../app/containers/traefik/traefik_auth.sh | 17 + .../traefik/traefik_reset_password.sh | 8 + scripts/backup/app/backup_app_all.sh | 27 + scripts/backup/app/backup_app_delete.sh | 53 + scripts/backup/app/backup_app_hooks.sh | 20 + scripts/backup/app/backup_app_schedule.sh | 21 + scripts/backup/app/backup_app_start.sh | 130 + scripts/backup/engine/backup_ssh.sh | 55 + scripts/backup/engine/borg_backup.sh | 43 + scripts/backup/engine/borg_check.sh | 50 + scripts/backup/engine/borg_env.sh | 79 + scripts/backup/engine/borg_forget.sh | 40 + scripts/backup/engine/borg_init.sh | 50 + scripts/backup/engine/borg_install.sh | 24 + scripts/backup/engine/borg_restore.sh | 49 + scripts/backup/engine/borg_snapshots.sh | 81 + scripts/backup/engine/engine_dispatch.sh | 118 + scripts/backup/engine/kopia_backup.sh | 42 + scripts/backup/engine/kopia_check.sh | 46 + scripts/backup/engine/kopia_env.sh | 59 + scripts/backup/engine/kopia_forget.sh | 43 + scripts/backup/engine/kopia_init.sh | 93 + scripts/backup/engine/kopia_install.sh | 50 + scripts/backup/engine/kopia_restore.sh | 50 + scripts/backup/engine/kopia_snapshots.sh | 69 + scripts/backup/engine/restic_backup.sh | 79 + scripts/backup/engine/restic_check.sh | 49 + scripts/backup/engine/restic_dump.sh | 20 + scripts/backup/engine/restic_env.sh | 167 + scripts/backup/engine/restic_forget.sh | 63 + scripts/backup/engine/restic_init.sh | 72 + scripts/backup/engine/restic_install.sh | 49 + scripts/backup/engine/restic_restore.sh | 46 + scripts/backup/engine/restic_snapshots.sh | 47 + scripts/backup/engines/borg.json | 24 + scripts/backup/engines/kopia.json | 24 + scripts/backup/engines/restic.json | 24 + scripts/backup/locations/location_add.sh | 73 + scripts/backup/locations/location_loader.sh | 18 + scripts/backup/locations/location_migrate.sh | 106 + scripts/backup/locations/location_paths.sh | 74 + scripts/backup/locations/location_remove.sh | 36 + scripts/backup/locations/location_ssh.sh | 102 + scripts/backup/manifest/manifest_collect.sh | 66 + scripts/backup/manifest/manifest_read.sh | 23 + scripts/backup/manifest/manifest_write.sh | 30 + scripts/backup/verify/backup_verify.sh | 38 + scripts/checks/check_requirements.sh | 65 + scripts/checks/first_install.sh | 194 + scripts/checks/generate_install_name.sh | 11 + .../checks/requirements/check_app_install.sh | 119 + scripts/checks/requirements/check_command.sh | 19 + scripts/checks/requirements/check_config.sh | 8 + scripts/checks/requirements/check_crontab.sh | 16 + scripts/checks/requirements/check_database.sh | 14 + scripts/checks/requirements/check_docker.sh | 23 + .../requirements/check_docker_compose.sh | 14 + .../requirements/check_docker_network.sh | 36 + .../requirements/check_docker_rootless.sh | 14 + .../requirements/check_docker_switcher.sh | 14 + .../checks/requirements/check_install_type.sh | 25 + scripts/checks/requirements/check_manager.sh | 14 + .../checks/requirements/check_passwords.sh | 22 + scripts/checks/requirements/check_root.sh | 14 + .../checks/requirements/check_sshdownload.sh | 25 + scripts/checks/requirements/check_sshkeys.sh | 35 + .../checks/requirements/check_sshpassword.sh | 14 + .../checks/requirements/check_sshremote.sh | 27 + scripts/checks/requirements/check_sslcerts.sh | 74 + .../requirements/check_suggest_installs.sh | 15 + scripts/checks/requirements/check_swapfile.sh | 14 + scripts/checks/requirements/check_traefik.sh | 9 + scripts/checks/requirements/check_ufw.sh | 14 + scripts/checks/requirements/check_ufwd.sh | 16 + .../checks/requirements/check_webui_app.sh | 14 + .../checks/requirements/check_webui_image.sh | 13 + .../requirements/check_webui_systemd.sh | 14 + .../checks/requirements/check_wireguard.sh | 14 + scripts/cli/cli_initialize.sh | 40 + scripts/cli/cli_update.sh | 40 + scripts/cli/commands/app/cli_app_commands.sh | 138 + scripts/cli/commands/app/cli_app_header.sh | 40 + scripts/cli/commands/app/cli_app_restore.sh | 22 + scripts/cli/commands/app/cli_app_tool_list.sh | 69 + .../commands/backup/cli_backup_commands.sh | 96 + .../cli/commands/backup/cli_backup_header.sh | 45 + .../commands/config/cli_config_commands.sh | 26 + .../cli/commands/config/cli_config_header.sh | 14 + .../dockertype/cli_dockertype_commands.sh | 30 + .../dockertype/cli_dockertype_header.sh | 15 + .../firewall/cli_firewall_commands.sh | 37 + .../commands/firewall/cli_firewall_header.sh | 7 + .../cli/commands/help/cli_help_commands.sh | 41 + scripts/cli/commands/help/cli_help_header.sh | 18 + .../commands/install/cli_install_commands.sh | 20 + .../commands/install/cli_install_header.sh | 17 + scripts/cli/commands/ip/cli_ip_commands.sh | 115 + scripts/cli/commands/ip/cli_ip_header.sh | 23 + .../cli/commands/reset/cli_reset_commands.sh | 16 + .../cli/commands/reset/cli_reset_header.sh | 23 + .../commands/restore/cli_restore_commands.sh | 80 + .../commands/restore/cli_restore_header.sh | 26 + .../cli/commands/setup/cli_setup_commands.sh | 54 + .../cli/commands/setup/cli_setup_header.sh | 16 + .../commands/system/cli_system_commands.sh | 28 + .../cli/commands/system/cli_system_header.sh | 15 + .../commands/update/cli_update_commands.sh | 16 + .../cli/commands/update/cli_update_header.sh | 16 + .../validation/cli_validation_commands.sh | 38 + .../validation/cli_validation_header.sh | 16 + .../cli/commands/webui/cli_webui_commands.sh | 106 + .../cli/commands/webui/cli_webui_header.sh | 35 + .../application/application_edit_config.sh | 62 + .../application/application_menu_apps.sh | 68 + .../application/application_menu_category.sh | 109 + .../application_missing_variables.sh | 221 + scripts/config/config_update.sh | 55 + scripts/config/core/config_check_missing.sh | 52 + scripts/config/core/config_file_setup_data.sh | 42 + scripts/config/core/config_find_file.sh | 27 + scripts/config/core/config_get_config_data.sh | 19 + scripts/config/core/config_main_menu.sh | 27 + scripts/config/core/config_manage_menu.sh | 125 + scripts/config/core/config_update_option.sh | 48 + .../variables/config_missing_variables.sh | 92 + .../core/variables/config_scan_variables.sh | 12 + scripts/config/docker/docker_compose_menu.sh | 126 + .../config/docker/docker_config_setup_data.sh | 153 + .../docker/docker_config_to_container.sh | 245 + .../docker/docker_list_compose_files.sh | 16 + .../password/bcrypt/password_export_bcrypt.sh | 37 + .../bcrypt/password_process_bcrypt.sh | 47 + .../bcrypt/password_replace_bcrypt.sh | 19 + .../bcrypt/password_retreive_bcrypt.sh | 14 + scripts/config/password/password_generate.sh | 11 + scripts/config/password/password_hash.sh | 35 + .../config/password/password_replace hex.sh | 21 + .../config/password/password_replace vapid.sh | 21 + scripts/config/password/password_replace.sh | 19 + .../password/password_replace_appkey.sh | 23 + scripts/config/password/password_scan_file.sh | 15 + .../config/password/password_update_all.sh | 21 + .../password/password_user_generator.sh | 16 + .../config/password/password_user_replace.sh | 19 + .../tags/manager/tags_manager_content.sh | 28 + .../config/tags/manager/tags_manager_state.sh | 18 + .../tags/manager/tags_manager_update.sh | 53 + .../tags_processor_app_config_values.sh | 35 + .../tags/processors/tags_processor_app_url.sh | 33 + .../tags_processor_docker_installation.sh | 25 + .../processors/tags_processor_healthcheck.sh | 18 + .../processors/tags_processor_network_mode.sh | 119 + .../tags_processor_password_generation.sh | 26 + .../processors/tags_processor_random_user.sh | 14 + .../tags_processor_socket_configuration.sh | 40 + .../tags_processor_speedtest_pass.sh | 22 + .../tags_processor_standard_replacements.sh | 26 + .../tags_processor_traefik_control.sh | 16 + .../tags_processor_trusted_domains.sh | 29 + scripts/config/utils/update_whitelist.sh | 10 + .../crontab/app/crontab_backup_all_apps.sh | 54 + .../crontab/app/crontab_check_backup_app.sh | 21 + .../crontab/app/crontab_remove_backup_app.sh | 9 + scripts/crontab/app/crontab_remove_folder.sh | 23 + scripts/crontab/app/install/crontab_setup.sh | 33 + scripts/crontab/app/install/crontab_timing.sh | 65 + scripts/crontab/crontab_clean.sh | 13 + scripts/crontab/crontab_clear.sh | 8 + scripts/crontab/crontab_install.sh | 25 + scripts/crontab/crontab_refresh.sh | 19 + scripts/crontab/crontab_setup.sh | 15 + .../crontab_setup_system_info_updater.sh | 15 + .../crontab/task/crontab_check_processor.sh | 742 ++ .../task/crontab_setup_check_processor.sh | 26 + .../task/crontab_setup_task_processor.sh | 26 + .../crontab/task/crontab_task_processor.sh | 451 + scripts/database/app/db_app_scan.sh | 205 + scripts/database/app/db_cycle_apps.sh | 24 + scripts/database/app/db_install_app.sh | 34 + scripts/database/app/db_list_all_apps.sh | 35 + scripts/database/app/db_list_installed_app.sh | 182 + .../database/app/db_list_installed_apps.sh | 27 + scripts/database/app/db_uninstall_app.sh | 35 + scripts/database/check_os_update.sh | 27 + scripts/database/delete_db_file.sh | 9 + scripts/database/insert/db_insert_backups.sh | 9 + .../database/insert/db_insert_cron_jobs.sh | 19 + scripts/database/insert/db_insert_option.sh | 17 + .../database/insert/db_insert_port_open.sh | 19 + .../database/insert/db_insert_port_used.sh | 17 + scripts/database/insert/db_insert_restore.sh | 9 + scripts/database/insert/db_insert_ssh.sh | 9 + scripts/database/insert/db_insert_ssh_keys.sh | 17 + scripts/database/install_sqlite.sh | 35 + scripts/database/tables/db_create_tables.sh | 110 + scripts/database/tables/db_display_tables.sh | 60 + scripts/database/tables/db_empty_table.sh | 46 + scripts/docker/app/checks/allowed_install.sh | 39 + scripts/docker/app/checks/app_installed.sh | 39 + scripts/docker/app/checks/container_health.sh | 13 + .../app/checks/container_health_loop.sh | 27 + scripts/docker/app/compose/down_all.sh | 12 + scripts/docker/app/compose/down_app.sh | 58 + scripts/docker/app/compose/up_all.sh | 12 + scripts/docker/app/compose/up_app.sh | 139 + scripts/docker/app/compose/up_down_app.sh | 9 + scripts/docker/app/docker/remove_app.sh | 19 + scripts/docker/app/docker/restart_app.sh | 25 + scripts/docker/app/docker/start_all.sh | 8 + scripts/docker/app/docker/start_app.sh | 16 + scripts/docker/app/docker/stop_all.sh | 10 + scripts/docker/app/docker/stop_app.sh | 25 + .../docker/app/functions/function_app_tool.sh | 75 + .../app/functions/function_install_app.sh | 49 + .../app/functions/function_restart_app.sh | 18 + scripts/docker/app/uninstall/delete_data.sh | 14 + .../docker/app/uninstall/down_remove_app.sh | 30 + scripts/docker/app/uninstall/remove_images.sh | 39 + scripts/docker/app/uninstall/uninstall_app.sh | 122 + scripts/docker/checks/app_health_details.sh | 9 + scripts/docker/checks/app_health_status.sh | 9 + scripts/docker/checks/running_for_user.sh | 26 + scripts/docker/command/docker_run.sh | 17 + scripts/docker/command/docker_run_install.sh | 43 + scripts/docker/compose/copy_build_context.sh | 57 + .../docker/compose/restart_after_update.sh | 32 + scripts/docker/compose/setup_compose_yml.sh | 63 + scripts/docker/compose/update_and_start.sh | 17 + scripts/docker/compose/update_compose_yml.sh | 55 + .../docker/install/rooted/rooted_docker.sh | 37 + .../install/rooted/rooted_docker_check.sh | 31 + .../install/rooted/rooted_docker_compose.sh | 49 + .../install/rootless/rootless_docker.sh | 180 + .../install/rootless/rootless_start_setup.sh | 10 + .../install/rootless/rootless_uninstall.sh | 9 + .../docker/install/rootless/rootless_user.sh | 15 + .../migrate/migrate_apps_to_new_network.sh | 68 + ...migrate_check_app_network_compatibility.sh | 42 + .../migrate/migrate_get_installed_apps.sh | 14 + .../migrate_update_compose_file_network.sh | 27 + .../migrate_update_docker_network_config.sh | 23 + scripts/docker/network/network_prune.sh | 24 + .../network/network_randomize_subnet.sh | 33 + scripts/docker/network/network_setup.sh | 34 + scripts/docker/service/start_docker.sh | 20 + scripts/docker/service/stop_docker.sh | 26 + scripts/docker/setup_env.sh | 7 + .../type_switcher/scan_container_socket.sh | 33 + .../type_switcher/set_socket_permissions.sh | 61 + .../docker/type_switcher/swap_docker_type.sh | 94 + .../type_switcher/switch_containers_type.sh | 28 + scripts/docker/update_docker_sudo_pass.sh | 7 + scripts/docker/update_docker_user_pass.sh | 7 + scripts/docker/whitelist_port_updater.sh | 16 + .../function/checks/check_first_install.sh | 0 scripts/function/checks/check_success.sh | 53 + scripts/function/checks/detect_os.sh | 51 + scripts/function/checks/user_exists.sh | 10 + .../function/file/container/backup_files.sh | 29 + .../function/file/container/restore_files.sh | 16 + scripts/function/file/copy_file.sh | 31 + scripts/function/file/copy_files.sh | 35 + scripts/function/file/copy_resource.sh | 33 + scripts/function/file/create_touch.sh | 16 + .../function/file/empty_line/check_empty.sh | 6 + .../function/file/empty_line/remove_line.sh | 12 + scripts/function/file/move_file.sh | 22 + scripts/function/file/zip_file.sh | 15 + scripts/function/folder/copy_folder.sh | 16 + scripts/function/folder/copy_folders.sh | 26 + scripts/function/folder/create_folder.sh | 28 + scripts/function/permission/app_folder.sh | 81 + scripts/function/permission/before_start.sh | 31 + scripts/function/permission/config.sh | 15 + .../permission/libreportal_folders.sh | 28 + scripts/function/permission/ownership/file.sh | 13 + .../permission/ownership/folder_group.sh | 43 + .../permission/ownership/root_file.sh | 20 + .../ownership/root_files_folders.sh | 37 + .../run/create_successful_run_file.sh | 6 + scripts/function/run/reinstall_libreportal.sh | 86 + scripts/function/validation/element.sh | 14 + scripts/function/validation/email.sh | 16 + scripts/function/validation/password.sh | 33 + scripts/gluetun/gluetun_route_apps.sh | 97 + scripts/headscale/headscale_commands.sh | 143 + scripts/headscale/headscale_edit_config.sh | 27 + scripts/headscale/headscale_setup.sh | 60 + scripts/headscale/headscale_user.sh | 32 + .../local/headscale_generate_auth.sh | 14 + .../headscale/local/headscale_get_hostname.sh | 19 + .../headscale/local/headscale_setup_local.sh | 23 + .../local/headscale_setup_localhost.sh | 39 + .../remote/headscale_check_remote.sh | 12 + .../remote/headscale_setup_remote.sh | 11 + scripts/headscale/tailscale/tailscale.sh | 45 + .../headscale/tailscale/tailscale_install.sh | 21 + scripts/install/host_app.sh | 29 + scripts/install/install_certificate.sh | 70 + scripts/install/install_crowdsec.sh | 324 + scripts/install/install_restic.sh | 42 + scripts/install/install_swapfile.sh | 27 + scripts/install/install_ufw.sh | 68 + scripts/install/install_ufwd.sh | 39 + .../install/manager/install_user_manager.sh | 72 + .../install/manager/uninstall_user_manager.sh | 21 + scripts/logs/app_log_menu.sh | 58 + scripts/logs/installed_apps.sh | 45 + scripts/menu/menu_app_install.sh | 129 + scripts/menu/menu_app_uninstall.sh | 49 + scripts/menu/menu_main.sh | 198 + scripts/menu/menu_reset_to_menu.sh | 52 + scripts/menu/message/complete.sh | 9 + scripts/menu/message/continue.sh | 25 + scripts/menu/message/final.sh | 14 + scripts/menu/message/instructions.sh | 14 + scripts/menu/message/login.sh | 41 + scripts/menu/message/markers.sh | 63 + scripts/menu/tools/manage_crontab.sh | 35 + scripts/menu/tools/manage_dashy.sh | 29 + scripts/menu/tools/manage_docker.sh | 59 + scripts/menu/tools/manage_invidious.sh | 29 + scripts/menu/tools/manage_linkding.sh | 29 + scripts/menu/tools/manage_main.sh | 65 + scripts/menu/tools/manage_mattermost.sh | 29 + scripts/menu/tools/manage_ssh.sh | 38 + scripts/menu/tools/manage_tools.sh | 38 + scripts/menu/tools/manage_wireguard.sh | 42 + scripts/migrate/migrate_app.sh | 18 + scripts/migrate/migrate_discover.sh | 19 + scripts/migrate/migrate_system.sh | 40 + .../display/show_all_network_services.sh | 31 + .../show_all_network_services_detailed.sh | 29 + .../network/display/show_ip_allocations.sh | 49 + .../network/display/show_network_conflicts.sh | 46 + .../display/show_network_health_detailed.sh | 52 + .../network/display/show_network_service.sh | 27 + .../display/show_network_services_by_app.sh | 39 + .../show_network_services_by_category.sh | 39 + .../display/show_network_statistics.sh | 48 + .../network/display/show_traefik_services.sh | 34 + scripts/network/dns/setup_dns.sh | 146 + scripts/network/dns/setup_dns_ip.sh | 21 + .../firewall/firewall_initial_setup.sh | 87 + .../firewall/rules/firewall_clear_rules.sh | 60 + .../rules/firewall_rebuild_from_db.sh | 129 + .../firewall/rules/firewall_refresh_all.sh | 15 + scripts/network/ip/ip_allocation.sh | 53 + scripts/network/ip/ip_find_available.sh | 46 + scripts/network/ip/ip_is_available.sh | 23 + scripts/network/ip/ip_remove_from_db.sh | 18 + scripts/network/ip/ip_replace_tags.sh | 95 + scripts/network/monitoring/HOWTO_SIDECAR.md | 114 + scripts/network/monitoring/monitoring.sh | 224 + .../network/ports/allocation/port_allocate.sh | 104 + .../ports/allocation/port_store_mapping.sh | 32 + .../allocation/port_update_compose_tags.sh | 124 + .../ports/core/port_find_next_available.sh | 76 + .../ports/core/port_get_public_ports.sh | 30 + .../ports/core/port_get_service_ports.sh | 14 + .../ports/core/port_get_service_ports_only.sh | 25 + .../network/ports/core/port_remove_from_db.sh | 20 + scripts/network/ssh/ssh.sh | 13 + .../traefik/traefik_login_credentials.sh | 24 + .../network/traefik/traefik_middlewares.sh | 35 + .../traefik/traefik_port_middlewares.sh | 81 + scripts/network/traefik/traefik_whitelist.sh | 28 + scripts/network/variables/basic_scan.sh | 27 + .../network/variables/headscale_variables.sh | 25 + .../network/variables/variables_init_app.sh | 158 + scripts/os/install/arch.sh | 40 + scripts/os/install/ubuntu.sh | 28 + scripts/restore/restore_app_hooks.sh | 20 + scripts/restore/restore_app_pick.sh | 30 + scripts/restore/restore_app_start.sh | 128 + scripts/restore/restore_first_run.sh | 38 + scripts/setup/setup_apply.sh | 149 + scripts/setup/setup_lock.sh | 20 + scripts/source/files/app_files.sh | 36 + scripts/source/files/arrays/files_api.sh | 9 + scripts/source/files/arrays/files_app.sh | 77 + scripts/source/files/arrays/files_backup.sh | 50 + scripts/source/files/arrays/files_checks.sh | 39 + scripts/source/files/arrays/files_cli.sh | 42 + scripts/source/files/arrays/files_config.sh | 56 + scripts/source/files/arrays/files_crontab.sh | 24 + scripts/source/files/arrays/files_database.sh | 29 + scripts/source/files/arrays/files_docker.sh | 65 + scripts/source/files/arrays/files_function.sh | 38 + scripts/source/files/arrays/files_gluetun.sh | 9 + .../source/files/arrays/files_headscale.sh | 19 + scripts/source/files/arrays/files_install.sh | 17 + scripts/source/files/arrays/files_logs.sh | 10 + scripts/source/files/arrays/files_menu.sh | 28 + scripts/source/files/arrays/files_migrate.sh | 11 + scripts/source/files/arrays/files_network.sh | 46 + scripts/source/files/arrays/files_os.sh | 10 + scripts/source/files/arrays/files_restore.sh | 12 + scripts/source/files/arrays/files_setup.sh | 10 + scripts/source/files/arrays/files_source.sh | 34 + scripts/source/files/arrays/files_ssh.sh | 16 + scripts/source/files/arrays/files_start.sh | 16 + scripts/source/files/arrays/files_update.sh | 17 + scripts/source/files/arrays/files_webui.sh | 50 + .../source/files/arrays/files_wireguard.sh | 13 + scripts/source/files/cli_files.sh | 36 + scripts/source/files/generate_arrays.sh | 128 + scripts/source/load_sources.sh | 49 + scripts/source/loading/check_files.sh | 56 + scripts/source/loading/initilize_files.sh | 54 + scripts/source/loading/scan_files.sh | 67 + .../ssh/disable_passwords/disable_ssh_auth.sh | 49 + .../ssh/disable_passwords/update_ssh_html.sh | 39 + scripts/ssh/keys/check_key_pair.sh | 21 + scripts/ssh/keys/generate_key_pair.sh | 89 + scripts/ssh/keys/install_key_pair.sh | 30 + scripts/ssh/keys/regenerate_key_pair.sh | 24 + scripts/ssh/keys/setup_auth_key.sh | 40 + scripts/ssh/keys/setup_key_pair.sh | 35 + scripts/start/start_end.sh | 14 + scripts/start/start_exit.sh | 12 + scripts/start/start_install.sh | 25 + scripts/start/start_load.sh | 11 + scripts/start/start_other.sh | 72 + scripts/start/start_preinstall.sh | 71 + scripts/start/start_recommended.sh | 216 + scripts/start/start_scan.sh | 27 + .../OLD_CONTAINERS/anysync/anysync.config | 31 + .../unused/OLD_CONTAINERS/anysync/anysync.sh | 134 + .../unused/OLD_CONTAINERS/anysync/anysync.svg | 1 + .../OLD_CONTAINERS/anysync/docker-compose.yml | 75 + .../OLD_CONTAINERS/baikal/baikal.config | 30 + .../unused/OLD_CONTAINERS/baikal/baikal.sh | 122 + .../unused/OLD_CONTAINERS/baikal/baikal.svg | 112 + .../OLD_CONTAINERS/baikal/docker-compose.yml | 26 + .../dolibarr/docker-compose.yml | 49 + .../OLD_CONTAINERS/dolibarr/dolibarr.config | 31 + .../OLD_CONTAINERS/dolibarr/dolibarr.sh | 114 + .../OLD_CONTAINERS/dolibarr/dolibarr.svg | 1 + .../duplicati/docker-compose.yml | 33 + .../OLD_CONTAINERS/duplicati/duplicati.config | 30 + .../OLD_CONTAINERS/duplicati/duplicati.sh | 114 + .../OLD_CONTAINERS/duplicati/duplicati.svg | 1 + .../fail2ban/docker-compose.yml | 40 + .../OLD_CONTAINERS/fail2ban/fail2ban.config | 32 + .../OLD_CONTAINERS/fail2ban/fail2ban.sh | 158 + .../OLD_CONTAINERS/fail2ban/fail2ban.svg | 122 + .../fail2ban/resources/jail.local | 12 + .../OLD_CONTAINERS/firefly/docker-compose.yml | 52 + .../OLD_CONTAINERS/firefly/firefly.config | 30 + .../unused/OLD_CONTAINERS/firefly/firefly.sh | 141 + .../unused/OLD_CONTAINERS/firefly/firefly.svg | 1 + .../OLD_CONTAINERS/firefly/resources/.db.env | 4 + .../OLD_CONTAINERS/firefly/resources/.env | 341 + .../killbill/docker-compose.yml | 54 + .../OLD_CONTAINERS/killbill/killbill.config | 30 + .../OLD_CONTAINERS/killbill/killbill.sh | 114 + .../OLD_CONTAINERS/killbill/killbill.svg | 65 + .../OLD_CONTAINERS/kimai/docker-compose.yml | 104 + .../unused/OLD_CONTAINERS/kimai/kimai.config | 30 + scripts/unused/OLD_CONTAINERS/kimai/kimai.sh | 129 + scripts/unused/OLD_CONTAINERS/kimai/kimai.svg | 1 + .../OLD_CONTAINERS/mailcow/docker-compose.yml | 34 + .../OLD_CONTAINERS/mailcow/mailcow.config | 32 + .../unused/OLD_CONTAINERS/mailcow/mailcow.sh | 214 + .../unused/OLD_CONTAINERS/mailcow/mailcow.svg | 1 + .../mailcow/resources/caddy-to-mailcow-ssl.sh | 15 + .../mattermost/mattermost.config | 32 + .../OLD_CONTAINERS/mattermost/mattermost.sh | 255 + .../OLD_CONTAINERS/mattermost/mattermost.svg | 1 + .../nextcloud_hpb/docker-compose.yml | 38 + .../nextcloud_hpb/nextcloud_hpb.config | 30 + .../nextcloud_hpb/nextcloud_hpb.sh | 157 + .../nextcloud_hpb/nextcloud_hpb.svg | 1 + .../nextcloud_record/docker-compose.yml | 30 + .../nextcloud_record/nextcloud_record.config | 31 + .../nextcloud_record/nextcloud_record.sh | 148 + .../nextcloud_record/nextcloud_record.svg | 1 + .../OLD_CONTAINERS/nginx/docker-compose.yml | 37 + .../unused/OLD_CONTAINERS/nginx/nginx.config | 64 + scripts/unused/OLD_CONTAINERS/nginx/nginx.sh | 112 + scripts/unused/OLD_CONTAINERS/nginx/nginx.svg | 1 + .../OLD_CONTAINERS/nginx/resources/index.html | 13 + .../olivetin/docker-compose.yml | 28 + .../OLD_CONTAINERS/olivetin/olivetin.config | 32 + .../OLD_CONTAINERS/olivetin/olivetin.sh | 149 + .../OLD_CONTAINERS/olivetin/olivetin.svg | 1 + .../olivetin/resources/config.yaml | 64 + .../OLD_CONTAINERS/piped/docker-compose.yml | 85 + .../unused/OLD_CONTAINERS/piped/piped.config | 31 + scripts/unused/OLD_CONTAINERS/piped/piped.sh | 118 + scripts/unused/OLD_CONTAINERS/piped/piped.svg | 1 + .../portainer/docker-compose.yml | 38 + .../OLD_CONTAINERS/portainer/portainer.config | 65 + .../OLD_CONTAINERS/portainer/portainer.sh | 111 + .../OLD_CONTAINERS/portainer/portainer.svg | 1 + .../OLD_CONTAINERS/seafile/docker-compose.yml | 61 + .../OLD_CONTAINERS/seafile/resources/.env | 341 + .../OLD_CONTAINERS/seafile/seafile.config | 31 + .../unused/OLD_CONTAINERS/seafile/seafile.sh | 146 + .../unused/OLD_CONTAINERS/seafile/seafile.svg | 1 + .../sshdownload/docker-compose.yml | 25 + .../sshdownload/resources/index.html | 25 + .../sshdownload/sshdownload.config | 31 + .../OLD_CONTAINERS/sshdownload/sshdownload.sh | 178 + .../sshdownload/sshdownload.svg | 290 + .../template/docker-compose.yml | 248 + .../OLD_CONTAINERS/template/template.config | 28 + .../OLD_CONTAINERS/template/template.sh | 107 + .../tiledesk/docker-compose.yml | 90 + .../OLD_CONTAINERS/tiledesk/tiledesk.config | 31 + .../OLD_CONTAINERS/tiledesk/tiledesk.sh | 146 + .../OLD_CONTAINERS/tiledesk/tiledesk.svg | 5 + scripts/unused/ssh_manager.sh | 278 + scripts/update/backup/install_git_backup.sh | 9 + scripts/update/backup/reset_git_backup.sh | 39 + scripts/update/backup/use_git_backup.sh | 30 + scripts/update/check_update.sh | 126 + scripts/update/git/check_git_details.sh | 82 + scripts/update/git/checks/config_git_check.sh | 108 + scripts/update/git/checks/update_git_check.sh | 51 + scripts/update/git/reset_git.sh | 57 + scripts/update/git/untrack_files.sh | 11 + .../data/generators/apps/webui_app_config.sh | 29 + .../data/generators/apps/webui_app_status.sh | 53 + .../data/generators/apps/webui_config.sh | 240 + .../generators/apps/webui_config_patch.sh | 66 + .../apps/webui_gluetun_providers.sh | 81 + .../data/generators/apps/webui_services.sh | 320 + .../webui/data/generators/apps/webui_tools.sh | 428 + .../backup/webui_backup_app_status.sh | 57 + .../backup/webui_backup_dashboard.sh | 86 + .../generators/backup/webui_backup_engines.sh | 39 + .../backup/webui_backup_locations.sh | 93 + .../backup/webui_backup_passwords.sh | 42 + .../backup/webui_backup_snapshots.sh | 41 + .../generators/backup/webui_task_create.sh | 80 + .../categories/webui_create_all_categories.sh | 13 + .../categories/webui_create_app_categories.sh | 104 + .../webui_create_app_config_categories.sh | 86 + .../webui_create_app_field_mappings.sh | 811 ++ .../generators/config/webui_cli_config_set.sh | 64 + .../config/webui_generate_configs.sh | 301 + .../generators/config/webui_update_config.sh | 131 + .../generators/system/webui_system_disk.sh | 64 + .../generators/system/webui_system_info.sh | 61 + .../generators/system/webui_system_memory.sh | 58 + .../generators/system/webui_system_update.sh | 11 + .../data/lock/webui_check_update_lock.sh | 17 + .../data/lock/webui_create_update_lock.sh | 17 + .../data/lock/webui_remove_setup_lock.sh | 12 + .../data/lock/webui_remove_update_lock.sh | 12 + scripts/webui/data/logs/webui_app_logs.sh | 34 + scripts/webui/data/logs/webui_logs_folders.sh | 13 + scripts/webui/data/tasks/webui_task_files.sh | 36 + scripts/webui/data/utils/webui_app_icons.sh | 58 + scripts/webui/data/utils/webui_app_log.sh | 31 + .../webui/data/utils/webui_atomic_write.sh | 29 + .../webui/data/utils/webui_container_setup.sh | 31 + scripts/webui/webui_display_logins.sh | 183 + scripts/webui/webui_install_app.sh | 11 + scripts/webui/webui_install_image.sh | 18 + scripts/webui/webui_install_systemd.sh | 74 + scripts/webui/webui_update_ssh.sh | 48 + scripts/webui/webui_updater.sh | 111 + scripts/wireguard/client/check_clients.sh | 12 + scripts/wireguard/client/list_clients.sh | 10 + scripts/wireguard/client/revoke_client.sh | 31 + scripts/wireguard/config_wireguard | 7 + scripts/wireguard/install_standalone.sh | 315 + scripts/wireguard/uninstall_standalone.sh | 52 + start.sh | 66 + variables.sh | 56 + 1103 files changed, 116990 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100755 LICENSE create mode 100644 PROMISE.md create mode 100755 README.md create mode 100644 configs/backup/.category create mode 100644 configs/backup/backup_advanced create mode 100755 configs/backup/backup_general create mode 100644 configs/backup/backup_retention create mode 100644 configs/backup/locations/1/location.config create mode 100755 configs/features/.category create mode 100755 configs/features/features_core create mode 100755 configs/features/features_security create mode 100755 configs/features/features_terminal create mode 100755 configs/general/.category create mode 100755 configs/general/general_basic create mode 100755 configs/general/general_docker_install create mode 100755 configs/general/general_install create mode 100755 configs/general/general_libreportal create mode 100755 configs/general/general_mail create mode 100755 configs/general/general_terminal create mode 100755 configs/network/.category create mode 100755 configs/network/network_dns create mode 100755 configs/network/network_docker create mode 100755 configs/network/network_domains create mode 100755 configs/network/network_headscale create mode 100755 configs/network/network_ports create mode 100755 configs/network/network_whitelist create mode 100644 configs/security/.category create mode 100755 configs/security/security_logins create mode 100755 configs/webui/.category create mode 100755 configs/webui/webui_logins create mode 100644 configs/webui/webui_logs create mode 100755 containers/adguard/adguard.config create mode 100644 containers/adguard/adguard.sh create mode 100755 containers/adguard/adguard.svg create mode 100644 containers/adguard/docker-compose.yml create mode 100644 containers/adguard/resources/monitoring/grafana-dashboards/adguard.json create mode 100644 containers/adguard/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/authelia/authelia.config create mode 100755 containers/authelia/authelia.sh create mode 100755 containers/authelia/authelia.svg create mode 100755 containers/authelia/docker-compose.yml create mode 100644 containers/authelia/resources/configuration.yml create mode 100644 containers/authelia/resources/monitoring/grafana-dashboards/authelia.json create mode 100644 containers/authelia/resources/monitoring/prometheus-scrape.yml create mode 100644 containers/authelia/resources/users_database.yml create mode 100644 containers/bookstack/bookstack.config create mode 100755 containers/bookstack/bookstack.sh create mode 100755 containers/bookstack/bookstack.svg create mode 100755 containers/bookstack/docker-compose.yml create mode 100644 containers/crowdsec/crowdsec.config create mode 100644 containers/crowdsec/crowdsec.sh create mode 100644 containers/crowdsec/crowdsec.svg create mode 100644 containers/crowdsec/resources/monitoring/grafana-dashboards/details-per-machine.json create mode 100644 containers/crowdsec/resources/monitoring/grafana-dashboards/insight.json create mode 100644 containers/crowdsec/resources/monitoring/grafana-dashboards/lapi-metrics.json create mode 100644 containers/crowdsec/resources/monitoring/grafana-dashboards/overview.json create mode 100644 containers/crowdsec/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/dashy/dashy.config create mode 100755 containers/dashy/dashy.sh create mode 100755 containers/dashy/dashy.svg create mode 100755 containers/dashy/docker-compose.yml create mode 100755 containers/dashy/resources/conf.yml create mode 100755 containers/focalboard/docker-compose.yml create mode 100755 containers/focalboard/focalboard.config create mode 100755 containers/focalboard/focalboard.sh create mode 100755 containers/focalboard/focalboard.svg create mode 100755 containers/gitea/docker-compose.yml create mode 100755 containers/gitea/gitea.config create mode 100755 containers/gitea/gitea.sh create mode 100755 containers/gitea/gitea.svg create mode 100644 containers/gitea/resources/monitoring/grafana-dashboards/gitea.json create mode 100644 containers/gitea/resources/monitoring/prometheus-scrape.yml create mode 100644 containers/gluetun/docker-compose.yml create mode 100644 containers/gluetun/gluetun.config create mode 100644 containers/gluetun/gluetun.sh create mode 100644 containers/gluetun/gluetun.svg create mode 100644 containers/gluetun/resources/monitoring/grafana-dashboards/gluetun.json create mode 100644 containers/gluetun/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/grafana/docker-compose.yml create mode 100755 containers/grafana/grafana.config create mode 100755 containers/grafana/grafana.sh create mode 100755 containers/grafana/grafana.svg create mode 100644 containers/grafana/resources/monitoring/grafana-dashboards/grafana.json create mode 100644 containers/grafana/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/headscale/docker-compose.yml create mode 100755 containers/headscale/headscale.config create mode 100755 containers/headscale/headscale.sh create mode 100755 containers/headscale/headscale.svg create mode 100755 containers/headscale/resources/config.yaml create mode 100644 containers/headscale/resources/monitoring/grafana-dashboards/headscale.json create mode 100644 containers/headscale/resources/monitoring/prometheus-scrape.yml create mode 100644 containers/invidious/docker-compose.yml create mode 100755 containers/invidious/invidious.config create mode 100755 containers/invidious/invidious.sh create mode 100755 containers/invidious/invidious.svg create mode 100755 containers/ipinfo/docker-compose.yml create mode 100755 containers/ipinfo/ipinfo.config create mode 100755 containers/ipinfo/ipinfo.sh create mode 100755 containers/ipinfo/ipinfo.svg create mode 100755 containers/jellyfin/docker-compose.yml create mode 100755 containers/jellyfin/jellyfin.config create mode 100755 containers/jellyfin/jellyfin.sh create mode 100755 containers/jellyfin/jellyfin.svg create mode 100644 containers/jitsimeet/docker-compose.yml create mode 100755 containers/jitsimeet/jitsimeet.config create mode 100755 containers/jitsimeet/jitsimeet.sh create mode 100755 containers/jitsimeet/jitsimeet.svg create mode 100644 containers/libreportal/.dockerignore create mode 100755 containers/libreportal/Dockerfile create mode 100755 containers/libreportal/backend/package-lock.json create mode 100755 containers/libreportal/backend/package.json create mode 100755 containers/libreportal/backend/routes/auth-routes.js create mode 100755 containers/libreportal/backend/routes/routes.js create mode 100644 containers/libreportal/backend/routes/service-routes.js create mode 100644 containers/libreportal/backend/routes/setup-routes.js create mode 100755 containers/libreportal/backend/routes/task-routes.js create mode 100755 containers/libreportal/backend/routes/theme.js create mode 100644 containers/libreportal/backend/routes/themes.js create mode 100755 containers/libreportal/backend/server.js create mode 100755 containers/libreportal/backend/utils/auth.js create mode 100755 containers/libreportal/backend/utils/config.js create mode 100644 containers/libreportal/backend/utils/fifo.js create mode 100755 containers/libreportal/backend/utils/mail.js create mode 100755 containers/libreportal/backend/utils/middleware.js create mode 100644 containers/libreportal/docker-compose.yml create mode 100644 containers/libreportal/frontend/css/apps-layout.css create mode 100644 containers/libreportal/frontend/css/apps.css create mode 100644 containers/libreportal/frontend/css/aurora-background.css create mode 100755 containers/libreportal/frontend/css/backup.css create mode 100644 containers/libreportal/frontend/css/config.css create mode 100644 containers/libreportal/frontend/css/dashboard.css create mode 100644 containers/libreportal/frontend/css/forms.css create mode 100755 containers/libreportal/frontend/css/ip-whitelist.css create mode 100755 containers/libreportal/frontend/css/loading-screen.css create mode 100755 containers/libreportal/frontend/css/login.css create mode 100755 containers/libreportal/frontend/css/modal.css create mode 100755 containers/libreportal/frontend/css/port-manager.css create mode 100644 containers/libreportal/frontend/css/routing.css create mode 100644 containers/libreportal/frontend/css/service-buttons.css create mode 100644 containers/libreportal/frontend/css/services.css create mode 100755 containers/libreportal/frontend/css/setup-wizard.css create mode 100644 containers/libreportal/frontend/css/sidebar.css create mode 100755 containers/libreportal/frontend/css/style.css create mode 100644 containers/libreportal/frontend/css/tasks.css create mode 100644 containers/libreportal/frontend/css/themes.css create mode 100644 containers/libreportal/frontend/css/tools.css create mode 100644 containers/libreportal/frontend/css/topbar.css create mode 100755 containers/libreportal/frontend/html/app-content.html create mode 100755 containers/libreportal/frontend/html/apps-content.html create mode 100755 containers/libreportal/frontend/html/apps-unified-layout.html create mode 100644 containers/libreportal/frontend/html/backup-content.html create mode 100755 containers/libreportal/frontend/html/config-content.html create mode 100755 containers/libreportal/frontend/html/dashboard-content.html create mode 100755 containers/libreportal/frontend/html/tasks-content.html create mode 100755 containers/libreportal/frontend/html/topbar.html create mode 100644 containers/libreportal/frontend/icons/apps/adguard.svg create mode 100644 containers/libreportal/frontend/icons/apps/authelia.svg create mode 100644 containers/libreportal/frontend/icons/apps/bookstack.svg create mode 100644 containers/libreportal/frontend/icons/apps/crowdsec.svg create mode 100644 containers/libreportal/frontend/icons/apps/dashy.svg create mode 100644 containers/libreportal/frontend/icons/apps/default.svg create mode 100644 containers/libreportal/frontend/icons/apps/focalboard.svg create mode 100644 containers/libreportal/frontend/icons/apps/gitea.svg create mode 100644 containers/libreportal/frontend/icons/apps/gluetun.svg create mode 100644 containers/libreportal/frontend/icons/apps/grafana.svg create mode 100644 containers/libreportal/frontend/icons/apps/headscale.svg create mode 100644 containers/libreportal/frontend/icons/apps/invidious.svg create mode 100644 containers/libreportal/frontend/icons/apps/ipinfo.svg create mode 100644 containers/libreportal/frontend/icons/apps/jellyfin.svg create mode 100644 containers/libreportal/frontend/icons/apps/jitsimeet.svg create mode 100644 containers/libreportal/frontend/icons/apps/libreportal.svg create mode 100644 containers/libreportal/frontend/icons/apps/linkding.svg create mode 100644 containers/libreportal/frontend/icons/apps/mastodon.svg create mode 100644 containers/libreportal/frontend/icons/apps/nextcloud.svg create mode 100644 containers/libreportal/frontend/icons/apps/ollama.svg create mode 100644 containers/libreportal/frontend/icons/apps/onlyoffice.svg create mode 100644 containers/libreportal/frontend/icons/apps/owncloud.svg create mode 100644 containers/libreportal/frontend/icons/apps/pihole.svg create mode 100644 containers/libreportal/frontend/icons/apps/portainer.svg create mode 100644 containers/libreportal/frontend/icons/apps/prometheus.svg create mode 100644 containers/libreportal/frontend/icons/apps/searxng.svg create mode 100644 containers/libreportal/frontend/icons/apps/speedtest.svg create mode 100644 containers/libreportal/frontend/icons/apps/traefik.svg create mode 100644 containers/libreportal/frontend/icons/apps/trilium.svg create mode 100644 containers/libreportal/frontend/icons/apps/unbound.svg create mode 100644 containers/libreportal/frontend/icons/apps/vaultwarden.svg create mode 100644 containers/libreportal/frontend/icons/apps/wireguard.svg create mode 100755 containers/libreportal/frontend/icons/categories/all.svg create mode 100755 containers/libreportal/frontend/icons/categories/communication.svg create mode 100755 containers/libreportal/frontend/icons/categories/development.svg create mode 100755 containers/libreportal/frontend/icons/categories/installed.svg create mode 100755 containers/libreportal/frontend/icons/categories/knowledge.svg create mode 100755 containers/libreportal/frontend/icons/categories/media.svg create mode 100755 containers/libreportal/frontend/icons/categories/misc.svg create mode 100644 containers/libreportal/frontend/icons/categories/monitoring.svg create mode 100755 containers/libreportal/frontend/icons/categories/networking.svg create mode 100755 containers/libreportal/frontend/icons/categories/productivity.svg create mode 100644 containers/libreportal/frontend/icons/categories/recommended.svg create mode 100755 containers/libreportal/frontend/icons/categories/security.svg create mode 100755 containers/libreportal/frontend/icons/categories/storage.svg create mode 100755 containers/libreportal/frontend/icons/categories/system.svg create mode 100755 containers/libreportal/frontend/icons/categories/utils.svg create mode 100755 containers/libreportal/frontend/icons/config/backup.svg create mode 100755 containers/libreportal/frontend/icons/config/features.svg create mode 100755 containers/libreportal/frontend/icons/config/general.svg create mode 100755 containers/libreportal/frontend/icons/config/network.svg create mode 100644 containers/libreportal/frontend/icons/config/security.svg create mode 100755 containers/libreportal/frontend/icons/config/webui.svg create mode 100644 containers/libreportal/frontend/icons/favicon.ico create mode 100755 containers/libreportal/frontend/icons/libreportal.svg create mode 100644 containers/libreportal/frontend/icons/vpn/airvpn.svg create mode 100644 containers/libreportal/frontend/icons/vpn/cyberghost.png create mode 100644 containers/libreportal/frontend/icons/vpn/expressvpn.svg create mode 100644 containers/libreportal/frontend/icons/vpn/fastestvpn.png create mode 100644 containers/libreportal/frontend/icons/vpn/giganews.png create mode 100644 containers/libreportal/frontend/icons/vpn/hidemyass.png create mode 100644 containers/libreportal/frontend/icons/vpn/ipvanish.svg create mode 100644 containers/libreportal/frontend/icons/vpn/ivpn.png create mode 100644 containers/libreportal/frontend/icons/vpn/mullvad.svg create mode 100644 containers/libreportal/frontend/icons/vpn/nordvpn.svg create mode 100644 containers/libreportal/frontend/icons/vpn/privado.png create mode 100644 containers/libreportal/frontend/icons/vpn/private-internet-access.svg create mode 100644 containers/libreportal/frontend/icons/vpn/privatevpn.png create mode 100644 containers/libreportal/frontend/icons/vpn/protonvpn.svg create mode 100644 containers/libreportal/frontend/icons/vpn/purevpn.png create mode 100644 containers/libreportal/frontend/icons/vpn/slickvpn.png create mode 100644 containers/libreportal/frontend/icons/vpn/surfshark.svg create mode 100644 containers/libreportal/frontend/icons/vpn/torguard.png create mode 100644 containers/libreportal/frontend/icons/vpn/vpn-unlimited.png create mode 100644 containers/libreportal/frontend/icons/vpn/vpnsecure.png create mode 100644 containers/libreportal/frontend/icons/vpn/vyprvpn.png create mode 100644 containers/libreportal/frontend/icons/vpn/windscribe.png create mode 100755 containers/libreportal/frontend/index.html create mode 100755 containers/libreportal/frontend/js/components/app/app-manager.js create mode 100755 containers/libreportal/frontend/js/components/app/app-tabbed-manager.js create mode 100755 containers/libreportal/frontend/js/components/app/apps-manager.js create mode 100755 containers/libreportal/frontend/js/components/app/port-manager.js create mode 100644 containers/libreportal/frontend/js/components/app/routing-manager.js create mode 100644 containers/libreportal/frontend/js/components/app/services-manager.js create mode 100644 containers/libreportal/frontend/js/components/app/tools-manager.js create mode 100644 containers/libreportal/frontend/js/components/backup/backup-app-card.js create mode 100644 containers/libreportal/frontend/js/components/backup/backup-page.js create mode 100755 containers/libreportal/frontend/js/components/config/config-core.js create mode 100755 containers/libreportal/frontend/js/components/config/config-form.js create mode 100755 containers/libreportal/frontend/js/components/config/config-manager-old.js create mode 100755 containers/libreportal/frontend/js/components/config/config-manager.js create mode 100755 containers/libreportal/frontend/js/components/config/config-options.js create mode 100755 containers/libreportal/frontend/js/components/config/config-renderer.js create mode 100755 containers/libreportal/frontend/js/components/config/config-router.js create mode 100755 containers/libreportal/frontend/js/components/config/config-shared.js create mode 100755 containers/libreportal/frontend/js/components/config/config-sidebar.js create mode 100755 containers/libreportal/frontend/js/components/config/config-utils.js create mode 100755 containers/libreportal/frontend/js/components/config/config-validator.js create mode 100755 containers/libreportal/frontend/js/components/config/domain-manager.js create mode 100755 containers/libreportal/frontend/js/components/config/ip-whitelist-manager.js create mode 100755 containers/libreportal/frontend/js/components/config/toggle-manager.js create mode 100755 containers/libreportal/frontend/js/components/confirmation-dialog.js create mode 100755 containers/libreportal/frontend/js/components/dashboard.js create mode 100644 containers/libreportal/frontend/js/components/eo-modal.js create mode 100755 containers/libreportal/frontend/js/components/mobile-menu.js create mode 100755 containers/libreportal/frontend/js/components/notifications.js create mode 100755 containers/libreportal/frontend/js/components/task/task-actions.js create mode 100755 containers/libreportal/frontend/js/components/task/task-commands.js create mode 100755 containers/libreportal/frontend/js/components/task/task-event-bus.js create mode 100755 containers/libreportal/frontend/js/components/task/task-global-functions.js create mode 100755 containers/libreportal/frontend/js/components/task/task-manager.js create mode 100755 containers/libreportal/frontend/js/components/task/task-router.js create mode 100755 containers/libreportal/frontend/js/components/tasks/tasks-manager.js create mode 100755 containers/libreportal/frontend/js/components/topbar.js create mode 100755 containers/libreportal/frontend/js/spa.js create mode 100755 containers/libreportal/frontend/js/system/auth-manager.js create mode 100644 containers/libreportal/frontend/js/system/custom-number.js create mode 100644 containers/libreportal/frontend/js/system/custom-select.js create mode 100755 containers/libreportal/frontend/js/system/loading-ui.js create mode 100644 containers/libreportal/frontend/js/system/setup-completion-watcher.js create mode 100755 containers/libreportal/frontend/js/system/setup-detector.js create mode 100755 containers/libreportal/frontend/js/system/setup-wizard.js create mode 100755 containers/libreportal/frontend/js/system/system-loader.js create mode 100755 containers/libreportal/frontend/js/system/system-orchestrator.js create mode 100644 containers/libreportal/frontend/js/system/theme-registry.js create mode 100755 containers/libreportal/frontend/js/task-parameter-preserve.js create mode 100755 containers/libreportal/frontend/js/utils/data-loader.js create mode 100755 containers/libreportal/frontend/js/utils/dom-helpers.js create mode 100755 containers/libreportal/frontend/js/utils/html-cache.js create mode 100755 containers/libreportal/frontend/js/utils/router.js create mode 100755 containers/libreportal/frontend/js/utils/ui-helpers.js create mode 100644 containers/libreportal/frontend/themes/README.md create mode 100644 containers/libreportal/frontend/themes/dark-blue/meta.json create mode 100644 containers/libreportal/frontend/themes/dark-blue/theme.css create mode 100644 containers/libreportal/frontend/themes/example/meta.json create mode 100644 containers/libreportal/frontend/themes/example/theme.css create mode 100644 containers/libreportal/frontend/themes/light/meta.json create mode 100644 containers/libreportal/frontend/themes/light/theme.css create mode 100644 containers/libreportal/frontend/themes/nebula/meta.json create mode 100644 containers/libreportal/frontend/themes/nebula/theme.css create mode 100755 containers/libreportal/libreportal.config create mode 100644 containers/libreportal/libreportal.sh create mode 100755 containers/libreportal/libreportal.svg create mode 100755 containers/linkding/docker-compose.yml create mode 100755 containers/linkding/linkding.config create mode 100755 containers/linkding/linkding.sh create mode 100755 containers/linkding/linkding.svg create mode 100755 containers/linkding/resources/.env create mode 100755 containers/mastodon/docker-compose.yml create mode 100755 containers/mastodon/mastodon.config create mode 100755 containers/mastodon/mastodon.sh create mode 100755 containers/mastodon/mastodon.svg create mode 100644 containers/moneyapp/.dockerignore create mode 100644 containers/moneyapp/.gitignore create mode 100644 containers/moneyapp/Dockerfile create mode 100644 containers/moneyapp/README.md create mode 100644 containers/moneyapp/app/components.json create mode 100644 containers/moneyapp/app/drizzle.config.ts create mode 100644 containers/moneyapp/app/drizzle/0000_fresh_silverclaw.sql create mode 100644 containers/moneyapp/app/drizzle/meta/0000_snapshot.json create mode 100644 containers/moneyapp/app/drizzle/meta/_journal.json create mode 100644 containers/moneyapp/app/eslint.config.mjs create mode 100644 containers/moneyapp/app/next.config.ts create mode 100644 containers/moneyapp/app/package-lock.json create mode 100644 containers/moneyapp/app/package.json create mode 100644 containers/moneyapp/app/postcss.config.mjs create mode 100644 containers/moneyapp/app/public/file.svg create mode 100644 containers/moneyapp/app/public/globe.svg create mode 100644 containers/moneyapp/app/public/next.svg create mode 100644 containers/moneyapp/app/public/vercel.svg create mode 100644 containers/moneyapp/app/public/window.svg create mode 100644 containers/moneyapp/app/src/app/(app)/actions.ts create mode 100644 containers/moneyapp/app/src/app/(app)/budgets/actions.ts create mode 100644 containers/moneyapp/app/src/app/(app)/budgets/page.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/forecast/forecast-chart.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/forecast/page.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/goals/actions.ts create mode 100644 containers/moneyapp/app/src/app/(app)/goals/calculator.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/goals/page.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/import/actions.ts create mode 100644 containers/moneyapp/app/src/app/(app)/import/page.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/layout.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/ledger/actions.ts create mode 100644 containers/moneyapp/app/src/app/(app)/ledger/ledger-table.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/ledger/page.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/page.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/recurring/actions.ts create mode 100644 containers/moneyapp/app/src/app/(app)/recurring/page.tsx create mode 100644 containers/moneyapp/app/src/app/(app)/settings/actions.ts create mode 100644 containers/moneyapp/app/src/app/(app)/settings/page.tsx create mode 100644 containers/moneyapp/app/src/app/(auth)/login/actions.ts create mode 100644 containers/moneyapp/app/src/app/(auth)/login/page.tsx create mode 100644 containers/moneyapp/app/src/app/(auth)/signup/actions.ts create mode 100644 containers/moneyapp/app/src/app/(auth)/signup/page.tsx create mode 100644 containers/moneyapp/app/src/app/api/auth/[...nextauth]/route.ts create mode 100644 containers/moneyapp/app/src/app/favicon.ico create mode 100644 containers/moneyapp/app/src/app/globals.css create mode 100644 containers/moneyapp/app/src/app/layout.tsx create mode 100644 containers/moneyapp/app/src/app/onboarding/actions.ts create mode 100644 containers/moneyapp/app/src/app/onboarding/page.tsx create mode 100644 containers/moneyapp/app/src/auth.config.ts create mode 100644 containers/moneyapp/app/src/auth.ts create mode 100644 containers/moneyapp/app/src/components/animated-number.tsx create mode 100644 containers/moneyapp/app/src/components/sidebar.tsx create mode 100644 containers/moneyapp/app/src/components/topbar.tsx create mode 100644 containers/moneyapp/app/src/components/ui/badge.tsx create mode 100644 containers/moneyapp/app/src/components/ui/button.tsx create mode 100644 containers/moneyapp/app/src/components/ui/card.tsx create mode 100644 containers/moneyapp/app/src/components/ui/input.tsx create mode 100644 containers/moneyapp/app/src/components/ui/label.tsx create mode 100644 containers/moneyapp/app/src/components/ui/progress.tsx create mode 100644 containers/moneyapp/app/src/components/ui/separator.tsx create mode 100644 containers/moneyapp/app/src/db/index.ts create mode 100644 containers/moneyapp/app/src/db/migrate.ts create mode 100644 containers/moneyapp/app/src/db/schema.ts create mode 100644 containers/moneyapp/app/src/lib/finance.ts create mode 100644 containers/moneyapp/app/src/lib/ledger-service.ts create mode 100644 containers/moneyapp/app/src/lib/recurring.ts create mode 100644 containers/moneyapp/app/src/lib/seed.ts create mode 100644 containers/moneyapp/app/src/lib/session.ts create mode 100644 containers/moneyapp/app/src/lib/utils.ts create mode 100644 containers/moneyapp/app/src/middleware.ts create mode 100644 containers/moneyapp/app/src/types/next-auth.d.ts create mode 100644 containers/moneyapp/app/tailwind.config.ts create mode 100644 containers/moneyapp/app/tsconfig.json create mode 100644 containers/moneyapp/docker-compose.yml create mode 100644 containers/moneyapp/moneyapp.config create mode 100644 containers/moneyapp/moneyapp.sh create mode 100644 containers/nextcloud/docker-compose.yml create mode 100755 containers/nextcloud/nextcloud.config create mode 100755 containers/nextcloud/nextcloud.sh create mode 100755 containers/nextcloud/nextcloud.svg create mode 100644 containers/nextcloud/resources/monitoring/grafana-dashboards/nextcloud.json create mode 100644 containers/nextcloud/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/ollama/docker-compose.yml create mode 100755 containers/ollama/ollama.config create mode 100755 containers/ollama/ollama.sh create mode 100755 containers/ollama/ollama.svg create mode 100644 containers/ollama/resources/monitoring/grafana-dashboards/ollama.json create mode 100644 containers/ollama/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/onlyoffice/docker-compose.yml create mode 100755 containers/onlyoffice/onlyoffice.config create mode 100755 containers/onlyoffice/onlyoffice.sh create mode 100755 containers/onlyoffice/onlyoffice.svg create mode 100755 containers/owncloud/docker-compose.yml create mode 100755 containers/owncloud/owncloud.config create mode 100755 containers/owncloud/owncloud.sh create mode 100755 containers/owncloud/owncloud.svg create mode 100755 containers/pihole/docker-compose.yml create mode 100755 containers/pihole/pihole.config create mode 100755 containers/pihole/pihole.sh create mode 100755 containers/pihole/pihole.svg create mode 100644 containers/pihole/resources/monitoring/grafana-dashboards/pihole.json create mode 100644 containers/pihole/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/prometheus/docker-compose.yml create mode 100755 containers/prometheus/prometheus.config create mode 100755 containers/prometheus/prometheus.sh create mode 100755 containers/prometheus/prometheus.svg create mode 100755 containers/prometheus/resources/prometheus.yml create mode 100755 containers/searxng/docker-compose.yml create mode 100755 containers/searxng/searxng.config create mode 100755 containers/searxng/searxng.sh create mode 100755 containers/searxng/searxng.svg create mode 100644 containers/speedtest/docker-compose.yml create mode 100755 containers/speedtest/speedtest.config create mode 100755 containers/speedtest/speedtest.sh create mode 100755 containers/speedtest/speedtest.svg create mode 100644 containers/traefik/docker-compose.yml create mode 100755 containers/traefik/resources/config.yml create mode 100644 containers/traefik/resources/crowdsec.yml create mode 100644 containers/traefik/resources/monitoring/grafana-dashboards/traefik.json create mode 100644 containers/traefik/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/traefik/resources/protectionauth.yml create mode 100755 containers/traefik/resources/tls.yml create mode 100755 containers/traefik/resources/traefik.yml create mode 100755 containers/traefik/resources/whitelist.yml create mode 100755 containers/traefik/traefik.config create mode 100755 containers/traefik/traefik.sh create mode 100755 containers/traefik/traefik.svg create mode 100755 containers/trilium/docker-compose.yml create mode 100755 containers/trilium/trilium.config create mode 100755 containers/trilium/trilium.sh create mode 100755 containers/trilium/trilium.svg create mode 100755 containers/unbound/docker-compose.yml create mode 100644 containers/unbound/resources/monitoring/grafana-dashboards/unbound.json create mode 100644 containers/unbound/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/unbound/resources/unbound.conf create mode 100755 containers/unbound/unbound.config create mode 100755 containers/unbound/unbound.sh create mode 100755 containers/unbound/unbound.svg create mode 100755 containers/vaultwarden/docker-compose.yml create mode 100644 containers/vaultwarden/resources/monitoring/grafana-dashboards/vaultwarden.json create mode 100644 containers/vaultwarden/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/vaultwarden/vaultwarden.config create mode 100755 containers/vaultwarden/vaultwarden.sh create mode 100755 containers/vaultwarden/vaultwarden.svg create mode 100755 containers/wireguard/docker-compose.yml create mode 100644 containers/wireguard/resources/monitoring/grafana-dashboards/wireguard.json create mode 100644 containers/wireguard/resources/monitoring/prometheus-scrape.yml create mode 100755 containers/wireguard/wireguard.config create mode 100755 containers/wireguard/wireguard.sh create mode 100755 containers/wireguard/wireguard.svg create mode 100755 init.sh create mode 100755 logs/backup.log create mode 100755 logs/libreportal.log create mode 100755 scripts/app/app_generate.sh create mode 100755 scripts/app/app_get_key_data.sh create mode 100755 scripts/app/app_scan_available.sh create mode 100755 scripts/app/app_status.sh create mode 100755 scripts/app/app_update_specifics.sh create mode 100644 scripts/app/auth_adapter.sh create mode 100644 scripts/app/containers/adguard/adguard_apply_dns_updater.sh create mode 100644 scripts/app/containers/adguard/adguard_auth.sh create mode 100644 scripts/app/containers/adguard/adguard_reset_password.sh create mode 100644 scripts/app/containers/bookstack/bookstack_auth.sh create mode 100644 scripts/app/containers/bookstack/bookstack_create_account.sh create mode 100644 scripts/app/containers/bookstack/bookstack_delete_user.sh create mode 100644 scripts/app/containers/bookstack/bookstack_list_users.sh create mode 100644 scripts/app/containers/bookstack/bookstack_reset_password.sh create mode 100644 scripts/app/containers/bookstack/bookstack_set_admin.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_alerts_list.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_console_disable.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_console_enroll.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_decisions_list.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_fix_priority.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_hub_update.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_metrics.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_status.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_unban.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_update.sh create mode 100644 scripts/app/containers/crowdsec/crowdsec_verify_firewall.sh create mode 100644 scripts/app/containers/dashy/dashy_manage_shortcuts.sh create mode 100755 scripts/app/containers/dashy/dashy_update_conf.sh create mode 100644 scripts/app/containers/focalboard/focalboard_auth.sh create mode 100644 scripts/app/containers/focalboard/focalboard_create_account.sh create mode 100644 scripts/app/containers/focalboard/focalboard_delete_user.sh create mode 100644 scripts/app/containers/focalboard/focalboard_list_users.sh create mode 100644 scripts/app/containers/focalboard/focalboard_reset_password.sh create mode 100644 scripts/app/containers/focalboard/focalboard_set_admin.sh create mode 100644 scripts/app/containers/gitea/gitea_auth.sh create mode 100644 scripts/app/containers/gitea/gitea_create_account.sh create mode 100644 scripts/app/containers/gitea/gitea_delete_user.sh create mode 100644 scripts/app/containers/gitea/gitea_list_users.sh create mode 100644 scripts/app/containers/gitea/gitea_reset_password.sh create mode 100644 scripts/app/containers/gitea/gitea_set_admin.sh create mode 100644 scripts/app/containers/gluetun/gluetun_recreate_routed.sh create mode 100644 scripts/app/containers/gluetun/gluetun_refresh_providers.sh create mode 100644 scripts/app/containers/invidious/invidious_auth.sh create mode 100644 scripts/app/containers/invidious/invidious_create_account.sh create mode 100644 scripts/app/containers/invidious/invidious_delete_user.sh create mode 100644 scripts/app/containers/invidious/invidious_list_users.sh create mode 100755 scripts/app/containers/invidious/invidious_reset_password.sh create mode 100644 scripts/app/containers/invidious/invidious_set_admin.sh create mode 100644 scripts/app/containers/mattermost/mattermost_auth.sh create mode 100644 scripts/app/containers/mattermost/mattermost_create_account.sh create mode 100644 scripts/app/containers/mattermost/mattermost_delete_user.sh create mode 100644 scripts/app/containers/mattermost/mattermost_list_users.sh create mode 100755 scripts/app/containers/mattermost/mattermost_reset_password.sh create mode 100644 scripts/app/containers/mattermost/mattermost_set_admin.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_add_trusted_domain.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_auth.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_create_account.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_delete_user.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_list_users.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_rescan_files.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_reset_password.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_set_admin.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_system_status.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_tail_logs.sh create mode 100644 scripts/app/containers/nextcloud/nextcloud_toggle_maintenance.sh create mode 100755 scripts/app/containers/owncloud/owncloud_setup_config.sh create mode 100644 scripts/app/containers/pihole/pihole_apply_dns_updater.sh create mode 100644 scripts/app/containers/traefik/traefik_auth.sh create mode 100644 scripts/app/containers/traefik/traefik_reset_password.sh create mode 100644 scripts/backup/app/backup_app_all.sh create mode 100644 scripts/backup/app/backup_app_delete.sh create mode 100644 scripts/backup/app/backup_app_hooks.sh create mode 100755 scripts/backup/app/backup_app_schedule.sh create mode 100755 scripts/backup/app/backup_app_start.sh create mode 100644 scripts/backup/engine/backup_ssh.sh create mode 100644 scripts/backup/engine/borg_backup.sh create mode 100644 scripts/backup/engine/borg_check.sh create mode 100644 scripts/backup/engine/borg_env.sh create mode 100644 scripts/backup/engine/borg_forget.sh create mode 100644 scripts/backup/engine/borg_init.sh create mode 100644 scripts/backup/engine/borg_install.sh create mode 100644 scripts/backup/engine/borg_restore.sh create mode 100644 scripts/backup/engine/borg_snapshots.sh create mode 100644 scripts/backup/engine/engine_dispatch.sh create mode 100644 scripts/backup/engine/kopia_backup.sh create mode 100644 scripts/backup/engine/kopia_check.sh create mode 100644 scripts/backup/engine/kopia_env.sh create mode 100644 scripts/backup/engine/kopia_forget.sh create mode 100644 scripts/backup/engine/kopia_init.sh create mode 100644 scripts/backup/engine/kopia_install.sh create mode 100644 scripts/backup/engine/kopia_restore.sh create mode 100644 scripts/backup/engine/kopia_snapshots.sh create mode 100644 scripts/backup/engine/restic_backup.sh create mode 100644 scripts/backup/engine/restic_check.sh create mode 100644 scripts/backup/engine/restic_dump.sh create mode 100644 scripts/backup/engine/restic_env.sh create mode 100644 scripts/backup/engine/restic_forget.sh create mode 100644 scripts/backup/engine/restic_init.sh create mode 100644 scripts/backup/engine/restic_install.sh create mode 100644 scripts/backup/engine/restic_restore.sh create mode 100644 scripts/backup/engine/restic_snapshots.sh create mode 100644 scripts/backup/engines/borg.json create mode 100644 scripts/backup/engines/kopia.json create mode 100644 scripts/backup/engines/restic.json create mode 100644 scripts/backup/locations/location_add.sh create mode 100644 scripts/backup/locations/location_loader.sh create mode 100644 scripts/backup/locations/location_migrate.sh create mode 100644 scripts/backup/locations/location_paths.sh create mode 100644 scripts/backup/locations/location_remove.sh create mode 100644 scripts/backup/locations/location_ssh.sh create mode 100644 scripts/backup/manifest/manifest_collect.sh create mode 100644 scripts/backup/manifest/manifest_read.sh create mode 100644 scripts/backup/manifest/manifest_write.sh create mode 100644 scripts/backup/verify/backup_verify.sh create mode 100755 scripts/checks/check_requirements.sh create mode 100755 scripts/checks/first_install.sh create mode 100644 scripts/checks/generate_install_name.sh create mode 100644 scripts/checks/requirements/check_app_install.sh create mode 100755 scripts/checks/requirements/check_command.sh create mode 100755 scripts/checks/requirements/check_config.sh create mode 100755 scripts/checks/requirements/check_crontab.sh create mode 100755 scripts/checks/requirements/check_database.sh create mode 100755 scripts/checks/requirements/check_docker.sh create mode 100755 scripts/checks/requirements/check_docker_compose.sh create mode 100755 scripts/checks/requirements/check_docker_network.sh create mode 100755 scripts/checks/requirements/check_docker_rootless.sh create mode 100755 scripts/checks/requirements/check_docker_switcher.sh create mode 100755 scripts/checks/requirements/check_install_type.sh create mode 100755 scripts/checks/requirements/check_manager.sh create mode 100755 scripts/checks/requirements/check_passwords.sh create mode 100755 scripts/checks/requirements/check_root.sh create mode 100755 scripts/checks/requirements/check_sshdownload.sh create mode 100755 scripts/checks/requirements/check_sshkeys.sh create mode 100755 scripts/checks/requirements/check_sshpassword.sh create mode 100755 scripts/checks/requirements/check_sshremote.sh create mode 100755 scripts/checks/requirements/check_sslcerts.sh create mode 100755 scripts/checks/requirements/check_suggest_installs.sh create mode 100755 scripts/checks/requirements/check_swapfile.sh create mode 100755 scripts/checks/requirements/check_traefik.sh create mode 100755 scripts/checks/requirements/check_ufw.sh create mode 100755 scripts/checks/requirements/check_ufwd.sh create mode 100755 scripts/checks/requirements/check_webui_app.sh create mode 100755 scripts/checks/requirements/check_webui_image.sh create mode 100755 scripts/checks/requirements/check_webui_systemd.sh create mode 100755 scripts/checks/requirements/check_wireguard.sh create mode 100755 scripts/cli/cli_initialize.sh create mode 100755 scripts/cli/cli_update.sh create mode 100755 scripts/cli/commands/app/cli_app_commands.sh create mode 100755 scripts/cli/commands/app/cli_app_header.sh create mode 100755 scripts/cli/commands/app/cli_app_restore.sh create mode 100644 scripts/cli/commands/app/cli_app_tool_list.sh create mode 100755 scripts/cli/commands/backup/cli_backup_commands.sh create mode 100755 scripts/cli/commands/backup/cli_backup_header.sh create mode 100755 scripts/cli/commands/config/cli_config_commands.sh create mode 100755 scripts/cli/commands/config/cli_config_header.sh create mode 100755 scripts/cli/commands/dockertype/cli_dockertype_commands.sh create mode 100755 scripts/cli/commands/dockertype/cli_dockertype_header.sh create mode 100755 scripts/cli/commands/firewall/cli_firewall_commands.sh create mode 100755 scripts/cli/commands/firewall/cli_firewall_header.sh create mode 100755 scripts/cli/commands/help/cli_help_commands.sh create mode 100755 scripts/cli/commands/help/cli_help_header.sh create mode 100755 scripts/cli/commands/install/cli_install_commands.sh create mode 100755 scripts/cli/commands/install/cli_install_header.sh create mode 100755 scripts/cli/commands/ip/cli_ip_commands.sh create mode 100755 scripts/cli/commands/ip/cli_ip_header.sh create mode 100755 scripts/cli/commands/reset/cli_reset_commands.sh create mode 100755 scripts/cli/commands/reset/cli_reset_header.sh create mode 100755 scripts/cli/commands/restore/cli_restore_commands.sh create mode 100755 scripts/cli/commands/restore/cli_restore_header.sh create mode 100644 scripts/cli/commands/setup/cli_setup_commands.sh create mode 100644 scripts/cli/commands/setup/cli_setup_header.sh create mode 100755 scripts/cli/commands/system/cli_system_commands.sh create mode 100755 scripts/cli/commands/system/cli_system_header.sh create mode 100755 scripts/cli/commands/update/cli_update_commands.sh create mode 100755 scripts/cli/commands/update/cli_update_header.sh create mode 100755 scripts/cli/commands/validation/cli_validation_commands.sh create mode 100755 scripts/cli/commands/validation/cli_validation_header.sh create mode 100755 scripts/cli/commands/webui/cli_webui_commands.sh create mode 100755 scripts/cli/commands/webui/cli_webui_header.sh create mode 100755 scripts/config/application/application_edit_config.sh create mode 100755 scripts/config/application/application_menu_apps.sh create mode 100755 scripts/config/application/application_menu_category.sh create mode 100755 scripts/config/application/application_missing_variables.sh create mode 100755 scripts/config/config_update.sh create mode 100755 scripts/config/core/config_check_missing.sh create mode 100755 scripts/config/core/config_file_setup_data.sh create mode 100755 scripts/config/core/config_find_file.sh create mode 100755 scripts/config/core/config_get_config_data.sh create mode 100755 scripts/config/core/config_main_menu.sh create mode 100755 scripts/config/core/config_manage_menu.sh create mode 100755 scripts/config/core/config_update_option.sh create mode 100755 scripts/config/core/variables/config_missing_variables.sh create mode 100755 scripts/config/core/variables/config_scan_variables.sh create mode 100755 scripts/config/docker/docker_compose_menu.sh create mode 100755 scripts/config/docker/docker_config_setup_data.sh create mode 100755 scripts/config/docker/docker_config_to_container.sh create mode 100755 scripts/config/docker/docker_list_compose_files.sh create mode 100755 scripts/config/password/bcrypt/password_export_bcrypt.sh create mode 100755 scripts/config/password/bcrypt/password_process_bcrypt.sh create mode 100755 scripts/config/password/bcrypt/password_replace_bcrypt.sh create mode 100755 scripts/config/password/bcrypt/password_retreive_bcrypt.sh create mode 100755 scripts/config/password/password_generate.sh create mode 100755 scripts/config/password/password_hash.sh create mode 100755 scripts/config/password/password_replace hex.sh create mode 100755 scripts/config/password/password_replace vapid.sh create mode 100755 scripts/config/password/password_replace.sh create mode 100644 scripts/config/password/password_replace_appkey.sh create mode 100755 scripts/config/password/password_scan_file.sh create mode 100755 scripts/config/password/password_update_all.sh create mode 100755 scripts/config/password/password_user_generator.sh create mode 100755 scripts/config/password/password_user_replace.sh create mode 100755 scripts/config/tags/manager/tags_manager_content.sh create mode 100755 scripts/config/tags/manager/tags_manager_state.sh create mode 100755 scripts/config/tags/manager/tags_manager_update.sh create mode 100644 scripts/config/tags/processors/tags_processor_app_config_values.sh create mode 100644 scripts/config/tags/processors/tags_processor_app_url.sh create mode 100755 scripts/config/tags/processors/tags_processor_docker_installation.sh create mode 100755 scripts/config/tags/processors/tags_processor_healthcheck.sh create mode 100644 scripts/config/tags/processors/tags_processor_network_mode.sh create mode 100755 scripts/config/tags/processors/tags_processor_password_generation.sh create mode 100755 scripts/config/tags/processors/tags_processor_random_user.sh create mode 100755 scripts/config/tags/processors/tags_processor_socket_configuration.sh create mode 100755 scripts/config/tags/processors/tags_processor_speedtest_pass.sh create mode 100755 scripts/config/tags/processors/tags_processor_standard_replacements.sh create mode 100755 scripts/config/tags/processors/tags_processor_traefik_control.sh create mode 100755 scripts/config/tags/processors/tags_processor_trusted_domains.sh create mode 100755 scripts/config/utils/update_whitelist.sh create mode 100755 scripts/crontab/app/crontab_backup_all_apps.sh create mode 100755 scripts/crontab/app/crontab_check_backup_app.sh create mode 100755 scripts/crontab/app/crontab_remove_backup_app.sh create mode 100755 scripts/crontab/app/crontab_remove_folder.sh create mode 100755 scripts/crontab/app/install/crontab_setup.sh create mode 100755 scripts/crontab/app/install/crontab_timing.sh create mode 100755 scripts/crontab/crontab_clean.sh create mode 100755 scripts/crontab/crontab_clear.sh create mode 100755 scripts/crontab/crontab_install.sh create mode 100755 scripts/crontab/crontab_refresh.sh create mode 100755 scripts/crontab/crontab_setup.sh create mode 100755 scripts/crontab/system/crontab_setup_system_info_updater.sh create mode 100755 scripts/crontab/task/crontab_check_processor.sh create mode 100755 scripts/crontab/task/crontab_setup_check_processor.sh create mode 100755 scripts/crontab/task/crontab_setup_task_processor.sh create mode 100755 scripts/crontab/task/crontab_task_processor.sh create mode 100755 scripts/database/app/db_app_scan.sh create mode 100755 scripts/database/app/db_cycle_apps.sh create mode 100755 scripts/database/app/db_install_app.sh create mode 100755 scripts/database/app/db_list_all_apps.sh create mode 100755 scripts/database/app/db_list_installed_app.sh create mode 100755 scripts/database/app/db_list_installed_apps.sh create mode 100755 scripts/database/app/db_uninstall_app.sh create mode 100755 scripts/database/check_os_update.sh create mode 100755 scripts/database/delete_db_file.sh create mode 100755 scripts/database/insert/db_insert_backups.sh create mode 100755 scripts/database/insert/db_insert_cron_jobs.sh create mode 100755 scripts/database/insert/db_insert_option.sh create mode 100755 scripts/database/insert/db_insert_port_open.sh create mode 100755 scripts/database/insert/db_insert_port_used.sh create mode 100755 scripts/database/insert/db_insert_restore.sh create mode 100755 scripts/database/insert/db_insert_ssh.sh create mode 100755 scripts/database/insert/db_insert_ssh_keys.sh create mode 100755 scripts/database/install_sqlite.sh create mode 100755 scripts/database/tables/db_create_tables.sh create mode 100755 scripts/database/tables/db_display_tables.sh create mode 100755 scripts/database/tables/db_empty_table.sh create mode 100755 scripts/docker/app/checks/allowed_install.sh create mode 100755 scripts/docker/app/checks/app_installed.sh create mode 100755 scripts/docker/app/checks/container_health.sh create mode 100755 scripts/docker/app/checks/container_health_loop.sh create mode 100755 scripts/docker/app/compose/down_all.sh create mode 100755 scripts/docker/app/compose/down_app.sh create mode 100755 scripts/docker/app/compose/up_all.sh create mode 100755 scripts/docker/app/compose/up_app.sh create mode 100755 scripts/docker/app/compose/up_down_app.sh create mode 100755 scripts/docker/app/docker/remove_app.sh create mode 100755 scripts/docker/app/docker/restart_app.sh create mode 100755 scripts/docker/app/docker/start_all.sh create mode 100755 scripts/docker/app/docker/start_app.sh create mode 100755 scripts/docker/app/docker/stop_all.sh create mode 100755 scripts/docker/app/docker/stop_app.sh create mode 100644 scripts/docker/app/functions/function_app_tool.sh create mode 100755 scripts/docker/app/functions/function_install_app.sh create mode 100755 scripts/docker/app/functions/function_restart_app.sh create mode 100755 scripts/docker/app/uninstall/delete_data.sh create mode 100755 scripts/docker/app/uninstall/down_remove_app.sh create mode 100644 scripts/docker/app/uninstall/remove_images.sh create mode 100755 scripts/docker/app/uninstall/uninstall_app.sh create mode 100755 scripts/docker/checks/app_health_details.sh create mode 100755 scripts/docker/checks/app_health_status.sh create mode 100755 scripts/docker/checks/running_for_user.sh create mode 100755 scripts/docker/command/docker_run.sh create mode 100755 scripts/docker/command/docker_run_install.sh create mode 100644 scripts/docker/compose/copy_build_context.sh create mode 100755 scripts/docker/compose/restart_after_update.sh create mode 100755 scripts/docker/compose/setup_compose_yml.sh create mode 100755 scripts/docker/compose/update_and_start.sh create mode 100755 scripts/docker/compose/update_compose_yml.sh create mode 100755 scripts/docker/install/rooted/rooted_docker.sh create mode 100755 scripts/docker/install/rooted/rooted_docker_check.sh create mode 100755 scripts/docker/install/rooted/rooted_docker_compose.sh create mode 100755 scripts/docker/install/rootless/rootless_docker.sh create mode 100755 scripts/docker/install/rootless/rootless_start_setup.sh create mode 100755 scripts/docker/install/rootless/rootless_uninstall.sh create mode 100755 scripts/docker/install/rootless/rootless_user.sh create mode 100755 scripts/docker/network/migrate/migrate_apps_to_new_network.sh create mode 100755 scripts/docker/network/migrate/migrate_check_app_network_compatibility.sh create mode 100755 scripts/docker/network/migrate/migrate_get_installed_apps.sh create mode 100755 scripts/docker/network/migrate/migrate_update_compose_file_network.sh create mode 100755 scripts/docker/network/migrate/migrate_update_docker_network_config.sh create mode 100755 scripts/docker/network/network_prune.sh create mode 100755 scripts/docker/network/network_randomize_subnet.sh create mode 100755 scripts/docker/network/network_setup.sh create mode 100755 scripts/docker/service/start_docker.sh create mode 100755 scripts/docker/service/stop_docker.sh create mode 100755 scripts/docker/setup_env.sh create mode 100755 scripts/docker/type_switcher/scan_container_socket.sh create mode 100755 scripts/docker/type_switcher/set_socket_permissions.sh create mode 100755 scripts/docker/type_switcher/swap_docker_type.sh create mode 100755 scripts/docker/type_switcher/switch_containers_type.sh create mode 100755 scripts/docker/update_docker_sudo_pass.sh create mode 100755 scripts/docker/update_docker_user_pass.sh create mode 100755 scripts/docker/whitelist_port_updater.sh create mode 100755 scripts/function/checks/check_first_install.sh create mode 100755 scripts/function/checks/check_success.sh create mode 100755 scripts/function/checks/detect_os.sh create mode 100755 scripts/function/checks/user_exists.sh create mode 100755 scripts/function/file/container/backup_files.sh create mode 100755 scripts/function/file/container/restore_files.sh create mode 100755 scripts/function/file/copy_file.sh create mode 100755 scripts/function/file/copy_files.sh create mode 100755 scripts/function/file/copy_resource.sh create mode 100755 scripts/function/file/create_touch.sh create mode 100755 scripts/function/file/empty_line/check_empty.sh create mode 100755 scripts/function/file/empty_line/remove_line.sh create mode 100755 scripts/function/file/move_file.sh create mode 100755 scripts/function/file/zip_file.sh create mode 100755 scripts/function/folder/copy_folder.sh create mode 100755 scripts/function/folder/copy_folders.sh create mode 100755 scripts/function/folder/create_folder.sh create mode 100755 scripts/function/permission/app_folder.sh create mode 100755 scripts/function/permission/before_start.sh create mode 100755 scripts/function/permission/config.sh create mode 100755 scripts/function/permission/libreportal_folders.sh create mode 100755 scripts/function/permission/ownership/file.sh create mode 100755 scripts/function/permission/ownership/folder_group.sh create mode 100755 scripts/function/permission/ownership/root_file.sh create mode 100755 scripts/function/permission/ownership/root_files_folders.sh create mode 100755 scripts/function/run/create_successful_run_file.sh create mode 100755 scripts/function/run/reinstall_libreportal.sh create mode 100755 scripts/function/validation/element.sh create mode 100755 scripts/function/validation/email.sh create mode 100755 scripts/function/validation/password.sh create mode 100644 scripts/gluetun/gluetun_route_apps.sh create mode 100755 scripts/headscale/headscale_commands.sh create mode 100755 scripts/headscale/headscale_edit_config.sh create mode 100755 scripts/headscale/headscale_setup.sh create mode 100755 scripts/headscale/headscale_user.sh create mode 100755 scripts/headscale/local/headscale_generate_auth.sh create mode 100755 scripts/headscale/local/headscale_get_hostname.sh create mode 100755 scripts/headscale/local/headscale_setup_local.sh create mode 100755 scripts/headscale/local/headscale_setup_localhost.sh create mode 100755 scripts/headscale/remote/headscale_check_remote.sh create mode 100755 scripts/headscale/remote/headscale_setup_remote.sh create mode 100755 scripts/headscale/tailscale/tailscale.sh create mode 100755 scripts/headscale/tailscale/tailscale_install.sh create mode 100644 scripts/install/host_app.sh create mode 100755 scripts/install/install_certificate.sh create mode 100644 scripts/install/install_crowdsec.sh create mode 100644 scripts/install/install_restic.sh create mode 100755 scripts/install/install_swapfile.sh create mode 100755 scripts/install/install_ufw.sh create mode 100755 scripts/install/install_ufwd.sh create mode 100755 scripts/install/manager/install_user_manager.sh create mode 100755 scripts/install/manager/uninstall_user_manager.sh create mode 100755 scripts/logs/app_log_menu.sh create mode 100755 scripts/logs/installed_apps.sh create mode 100755 scripts/menu/menu_app_install.sh create mode 100755 scripts/menu/menu_app_uninstall.sh create mode 100755 scripts/menu/menu_main.sh create mode 100755 scripts/menu/menu_reset_to_menu.sh create mode 100755 scripts/menu/message/complete.sh create mode 100755 scripts/menu/message/continue.sh create mode 100755 scripts/menu/message/final.sh create mode 100755 scripts/menu/message/instructions.sh create mode 100755 scripts/menu/message/login.sh create mode 100755 scripts/menu/message/markers.sh create mode 100755 scripts/menu/tools/manage_crontab.sh create mode 100755 scripts/menu/tools/manage_dashy.sh create mode 100755 scripts/menu/tools/manage_docker.sh create mode 100755 scripts/menu/tools/manage_invidious.sh create mode 100755 scripts/menu/tools/manage_linkding.sh create mode 100755 scripts/menu/tools/manage_main.sh create mode 100755 scripts/menu/tools/manage_mattermost.sh create mode 100755 scripts/menu/tools/manage_ssh.sh create mode 100755 scripts/menu/tools/manage_tools.sh create mode 100755 scripts/menu/tools/manage_wireguard.sh create mode 100644 scripts/migrate/migrate_app.sh create mode 100644 scripts/migrate/migrate_discover.sh create mode 100644 scripts/migrate/migrate_system.sh create mode 100755 scripts/network/display/show_all_network_services.sh create mode 100755 scripts/network/display/show_all_network_services_detailed.sh create mode 100755 scripts/network/display/show_ip_allocations.sh create mode 100755 scripts/network/display/show_network_conflicts.sh create mode 100755 scripts/network/display/show_network_health_detailed.sh create mode 100755 scripts/network/display/show_network_service.sh create mode 100755 scripts/network/display/show_network_services_by_app.sh create mode 100755 scripts/network/display/show_network_services_by_category.sh create mode 100755 scripts/network/display/show_network_statistics.sh create mode 100755 scripts/network/display/show_traefik_services.sh create mode 100755 scripts/network/dns/setup_dns.sh create mode 100755 scripts/network/dns/setup_dns_ip.sh create mode 100755 scripts/network/firewall/firewall_initial_setup.sh create mode 100755 scripts/network/firewall/rules/firewall_clear_rules.sh create mode 100755 scripts/network/firewall/rules/firewall_rebuild_from_db.sh create mode 100755 scripts/network/firewall/rules/firewall_refresh_all.sh create mode 100755 scripts/network/ip/ip_allocation.sh create mode 100755 scripts/network/ip/ip_find_available.sh create mode 100755 scripts/network/ip/ip_is_available.sh create mode 100755 scripts/network/ip/ip_remove_from_db.sh create mode 100755 scripts/network/ip/ip_replace_tags.sh create mode 100644 scripts/network/monitoring/HOWTO_SIDECAR.md create mode 100644 scripts/network/monitoring/monitoring.sh create mode 100755 scripts/network/ports/allocation/port_allocate.sh create mode 100755 scripts/network/ports/allocation/port_store_mapping.sh create mode 100755 scripts/network/ports/allocation/port_update_compose_tags.sh create mode 100755 scripts/network/ports/core/port_find_next_available.sh create mode 100755 scripts/network/ports/core/port_get_public_ports.sh create mode 100755 scripts/network/ports/core/port_get_service_ports.sh create mode 100755 scripts/network/ports/core/port_get_service_ports_only.sh create mode 100755 scripts/network/ports/core/port_remove_from_db.sh create mode 100755 scripts/network/ssh/ssh.sh create mode 100755 scripts/network/traefik/traefik_login_credentials.sh create mode 100755 scripts/network/traefik/traefik_middlewares.sh create mode 100644 scripts/network/traefik/traefik_port_middlewares.sh create mode 100755 scripts/network/traefik/traefik_whitelist.sh create mode 100755 scripts/network/variables/basic_scan.sh create mode 100755 scripts/network/variables/headscale_variables.sh create mode 100755 scripts/network/variables/variables_init_app.sh create mode 100755 scripts/os/install/arch.sh create mode 100755 scripts/os/install/ubuntu.sh create mode 100644 scripts/restore/restore_app_hooks.sh create mode 100644 scripts/restore/restore_app_pick.sh create mode 100644 scripts/restore/restore_app_start.sh create mode 100644 scripts/restore/restore_first_run.sh create mode 100644 scripts/setup/setup_apply.sh create mode 100644 scripts/setup/setup_lock.sh create mode 100755 scripts/source/files/app_files.sh create mode 100755 scripts/source/files/arrays/files_api.sh create mode 100755 scripts/source/files/arrays/files_app.sh create mode 100755 scripts/source/files/arrays/files_backup.sh create mode 100755 scripts/source/files/arrays/files_checks.sh create mode 100755 scripts/source/files/arrays/files_cli.sh create mode 100755 scripts/source/files/arrays/files_config.sh create mode 100755 scripts/source/files/arrays/files_crontab.sh create mode 100755 scripts/source/files/arrays/files_database.sh create mode 100755 scripts/source/files/arrays/files_docker.sh create mode 100755 scripts/source/files/arrays/files_function.sh create mode 100644 scripts/source/files/arrays/files_gluetun.sh create mode 100755 scripts/source/files/arrays/files_headscale.sh create mode 100755 scripts/source/files/arrays/files_install.sh create mode 100755 scripts/source/files/arrays/files_logs.sh create mode 100755 scripts/source/files/arrays/files_menu.sh create mode 100755 scripts/source/files/arrays/files_migrate.sh create mode 100755 scripts/source/files/arrays/files_network.sh create mode 100755 scripts/source/files/arrays/files_os.sh create mode 100755 scripts/source/files/arrays/files_restore.sh create mode 100644 scripts/source/files/arrays/files_setup.sh create mode 100755 scripts/source/files/arrays/files_source.sh create mode 100755 scripts/source/files/arrays/files_ssh.sh create mode 100755 scripts/source/files/arrays/files_start.sh create mode 100755 scripts/source/files/arrays/files_update.sh create mode 100755 scripts/source/files/arrays/files_webui.sh create mode 100755 scripts/source/files/arrays/files_wireguard.sh create mode 100755 scripts/source/files/cli_files.sh create mode 100755 scripts/source/files/generate_arrays.sh create mode 100755 scripts/source/load_sources.sh create mode 100755 scripts/source/loading/check_files.sh create mode 100755 scripts/source/loading/initilize_files.sh create mode 100755 scripts/source/loading/scan_files.sh create mode 100755 scripts/ssh/disable_passwords/disable_ssh_auth.sh create mode 100755 scripts/ssh/disable_passwords/update_ssh_html.sh create mode 100755 scripts/ssh/keys/check_key_pair.sh create mode 100755 scripts/ssh/keys/generate_key_pair.sh create mode 100755 scripts/ssh/keys/install_key_pair.sh create mode 100755 scripts/ssh/keys/regenerate_key_pair.sh create mode 100755 scripts/ssh/keys/setup_auth_key.sh create mode 100755 scripts/ssh/keys/setup_key_pair.sh create mode 100755 scripts/start/start_end.sh create mode 100755 scripts/start/start_exit.sh create mode 100755 scripts/start/start_install.sh create mode 100755 scripts/start/start_load.sh create mode 100755 scripts/start/start_other.sh create mode 100755 scripts/start/start_preinstall.sh create mode 100755 scripts/start/start_recommended.sh create mode 100755 scripts/start/start_scan.sh create mode 100755 scripts/unused/OLD_CONTAINERS/anysync/anysync.config create mode 100755 scripts/unused/OLD_CONTAINERS/anysync/anysync.sh create mode 100755 scripts/unused/OLD_CONTAINERS/anysync/anysync.svg create mode 100755 scripts/unused/OLD_CONTAINERS/anysync/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/baikal/baikal.config create mode 100755 scripts/unused/OLD_CONTAINERS/baikal/baikal.sh create mode 100755 scripts/unused/OLD_CONTAINERS/baikal/baikal.svg create mode 100755 scripts/unused/OLD_CONTAINERS/baikal/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/dolibarr/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/dolibarr/dolibarr.config create mode 100755 scripts/unused/OLD_CONTAINERS/dolibarr/dolibarr.sh create mode 100755 scripts/unused/OLD_CONTAINERS/dolibarr/dolibarr.svg create mode 100755 scripts/unused/OLD_CONTAINERS/duplicati/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/duplicati/duplicati.config create mode 100755 scripts/unused/OLD_CONTAINERS/duplicati/duplicati.sh create mode 100755 scripts/unused/OLD_CONTAINERS/duplicati/duplicati.svg create mode 100755 scripts/unused/OLD_CONTAINERS/fail2ban/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/fail2ban/fail2ban.config create mode 100755 scripts/unused/OLD_CONTAINERS/fail2ban/fail2ban.sh create mode 100755 scripts/unused/OLD_CONTAINERS/fail2ban/fail2ban.svg create mode 100755 scripts/unused/OLD_CONTAINERS/fail2ban/resources/jail.local create mode 100755 scripts/unused/OLD_CONTAINERS/firefly/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/firefly/firefly.config create mode 100755 scripts/unused/OLD_CONTAINERS/firefly/firefly.sh create mode 100755 scripts/unused/OLD_CONTAINERS/firefly/firefly.svg create mode 100755 scripts/unused/OLD_CONTAINERS/firefly/resources/.db.env create mode 100755 scripts/unused/OLD_CONTAINERS/firefly/resources/.env create mode 100755 scripts/unused/OLD_CONTAINERS/killbill/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/killbill/killbill.config create mode 100755 scripts/unused/OLD_CONTAINERS/killbill/killbill.sh create mode 100755 scripts/unused/OLD_CONTAINERS/killbill/killbill.svg create mode 100755 scripts/unused/OLD_CONTAINERS/kimai/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/kimai/kimai.config create mode 100755 scripts/unused/OLD_CONTAINERS/kimai/kimai.sh create mode 100755 scripts/unused/OLD_CONTAINERS/kimai/kimai.svg create mode 100755 scripts/unused/OLD_CONTAINERS/mailcow/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/mailcow/mailcow.config create mode 100755 scripts/unused/OLD_CONTAINERS/mailcow/mailcow.sh create mode 100755 scripts/unused/OLD_CONTAINERS/mailcow/mailcow.svg create mode 100755 scripts/unused/OLD_CONTAINERS/mailcow/resources/caddy-to-mailcow-ssl.sh create mode 100755 scripts/unused/OLD_CONTAINERS/mattermost/mattermost.config create mode 100755 scripts/unused/OLD_CONTAINERS/mattermost/mattermost.sh create mode 100755 scripts/unused/OLD_CONTAINERS/mattermost/mattermost.svg create mode 100755 scripts/unused/OLD_CONTAINERS/nextcloud_hpb/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/nextcloud_hpb/nextcloud_hpb.config create mode 100755 scripts/unused/OLD_CONTAINERS/nextcloud_hpb/nextcloud_hpb.sh create mode 100755 scripts/unused/OLD_CONTAINERS/nextcloud_hpb/nextcloud_hpb.svg create mode 100755 scripts/unused/OLD_CONTAINERS/nextcloud_record/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/nextcloud_record/nextcloud_record.config create mode 100755 scripts/unused/OLD_CONTAINERS/nextcloud_record/nextcloud_record.sh create mode 100755 scripts/unused/OLD_CONTAINERS/nextcloud_record/nextcloud_record.svg create mode 100755 scripts/unused/OLD_CONTAINERS/nginx/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/nginx/nginx.config create mode 100755 scripts/unused/OLD_CONTAINERS/nginx/nginx.sh create mode 100755 scripts/unused/OLD_CONTAINERS/nginx/nginx.svg create mode 100755 scripts/unused/OLD_CONTAINERS/nginx/resources/index.html create mode 100755 scripts/unused/OLD_CONTAINERS/olivetin/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/olivetin/olivetin.config create mode 100755 scripts/unused/OLD_CONTAINERS/olivetin/olivetin.sh create mode 100755 scripts/unused/OLD_CONTAINERS/olivetin/olivetin.svg create mode 100755 scripts/unused/OLD_CONTAINERS/olivetin/resources/config.yaml create mode 100755 scripts/unused/OLD_CONTAINERS/piped/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/piped/piped.config create mode 100755 scripts/unused/OLD_CONTAINERS/piped/piped.sh create mode 100755 scripts/unused/OLD_CONTAINERS/piped/piped.svg create mode 100755 scripts/unused/OLD_CONTAINERS/portainer/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/portainer/portainer.config create mode 100755 scripts/unused/OLD_CONTAINERS/portainer/portainer.sh create mode 100755 scripts/unused/OLD_CONTAINERS/portainer/portainer.svg create mode 100755 scripts/unused/OLD_CONTAINERS/seafile/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/seafile/resources/.env create mode 100755 scripts/unused/OLD_CONTAINERS/seafile/seafile.config create mode 100755 scripts/unused/OLD_CONTAINERS/seafile/seafile.sh create mode 100755 scripts/unused/OLD_CONTAINERS/seafile/seafile.svg create mode 100755 scripts/unused/OLD_CONTAINERS/sshdownload/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/sshdownload/resources/index.html create mode 100755 scripts/unused/OLD_CONTAINERS/sshdownload/sshdownload.config create mode 100755 scripts/unused/OLD_CONTAINERS/sshdownload/sshdownload.sh create mode 100755 scripts/unused/OLD_CONTAINERS/sshdownload/sshdownload.svg create mode 100755 scripts/unused/OLD_CONTAINERS/template/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/template/template.config create mode 100755 scripts/unused/OLD_CONTAINERS/template/template.sh create mode 100755 scripts/unused/OLD_CONTAINERS/tiledesk/docker-compose.yml create mode 100755 scripts/unused/OLD_CONTAINERS/tiledesk/tiledesk.config create mode 100755 scripts/unused/OLD_CONTAINERS/tiledesk/tiledesk.sh create mode 100755 scripts/unused/OLD_CONTAINERS/tiledesk/tiledesk.svg create mode 100755 scripts/unused/ssh_manager.sh create mode 100755 scripts/update/backup/install_git_backup.sh create mode 100755 scripts/update/backup/reset_git_backup.sh create mode 100755 scripts/update/backup/use_git_backup.sh create mode 100755 scripts/update/check_update.sh create mode 100755 scripts/update/git/check_git_details.sh create mode 100755 scripts/update/git/checks/config_git_check.sh create mode 100755 scripts/update/git/checks/update_git_check.sh create mode 100755 scripts/update/git/reset_git.sh create mode 100755 scripts/update/git/untrack_files.sh create mode 100755 scripts/webui/data/generators/apps/webui_app_config.sh create mode 100755 scripts/webui/data/generators/apps/webui_app_status.sh create mode 100644 scripts/webui/data/generators/apps/webui_config.sh create mode 100644 scripts/webui/data/generators/apps/webui_config_patch.sh create mode 100644 scripts/webui/data/generators/apps/webui_gluetun_providers.sh create mode 100755 scripts/webui/data/generators/apps/webui_services.sh create mode 100644 scripts/webui/data/generators/apps/webui_tools.sh create mode 100644 scripts/webui/data/generators/backup/webui_backup_app_status.sh create mode 100644 scripts/webui/data/generators/backup/webui_backup_dashboard.sh create mode 100644 scripts/webui/data/generators/backup/webui_backup_engines.sh create mode 100644 scripts/webui/data/generators/backup/webui_backup_locations.sh create mode 100644 scripts/webui/data/generators/backup/webui_backup_passwords.sh create mode 100644 scripts/webui/data/generators/backup/webui_backup_snapshots.sh create mode 100755 scripts/webui/data/generators/backup/webui_task_create.sh create mode 100755 scripts/webui/data/generators/categories/webui_create_all_categories.sh create mode 100755 scripts/webui/data/generators/categories/webui_create_app_categories.sh create mode 100755 scripts/webui/data/generators/categories/webui_create_app_config_categories.sh create mode 100755 scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh create mode 100755 scripts/webui/data/generators/config/webui_cli_config_set.sh create mode 100755 scripts/webui/data/generators/config/webui_generate_configs.sh create mode 100755 scripts/webui/data/generators/config/webui_update_config.sh create mode 100755 scripts/webui/data/generators/system/webui_system_disk.sh create mode 100755 scripts/webui/data/generators/system/webui_system_info.sh create mode 100755 scripts/webui/data/generators/system/webui_system_memory.sh create mode 100755 scripts/webui/data/generators/system/webui_system_update.sh create mode 100755 scripts/webui/data/lock/webui_check_update_lock.sh create mode 100755 scripts/webui/data/lock/webui_create_update_lock.sh create mode 100755 scripts/webui/data/lock/webui_remove_setup_lock.sh create mode 100755 scripts/webui/data/lock/webui_remove_update_lock.sh create mode 100755 scripts/webui/data/logs/webui_app_logs.sh create mode 100755 scripts/webui/data/logs/webui_logs_folders.sh create mode 100755 scripts/webui/data/tasks/webui_task_files.sh create mode 100755 scripts/webui/data/utils/webui_app_icons.sh create mode 100755 scripts/webui/data/utils/webui_app_log.sh create mode 100755 scripts/webui/data/utils/webui_atomic_write.sh create mode 100755 scripts/webui/data/utils/webui_container_setup.sh create mode 100755 scripts/webui/webui_display_logins.sh create mode 100755 scripts/webui/webui_install_app.sh create mode 100755 scripts/webui/webui_install_image.sh create mode 100755 scripts/webui/webui_install_systemd.sh create mode 100755 scripts/webui/webui_update_ssh.sh create mode 100755 scripts/webui/webui_updater.sh create mode 100755 scripts/wireguard/client/check_clients.sh create mode 100755 scripts/wireguard/client/list_clients.sh create mode 100755 scripts/wireguard/client/revoke_client.sh create mode 100755 scripts/wireguard/config_wireguard create mode 100755 scripts/wireguard/install_standalone.sh create mode 100755 scripts/wireguard/uninstall_standalone.sh create mode 100755 start.sh create mode 100755 variables.sh diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9030888 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bacc21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Claude sandbox + working notes. +# .claude-work was getting mirrored back into SRC by update.sh, creating +# nested copies on every run. Excluding the directory name everywhere belts- +# and-suspenders the fix in update.sh (the missing trailing slash on rsync). +.claude-work/ + +# Living spec authored in the sandbox; persisted at SRC root but not tracked. +/APPS.md + +# Node dependencies — installed via `npm ci` at image build, never vendored. +node_modules/ +npm-debug.log* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..33ac7e9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing to LibrePortal + +Thanks for wanting to help — LibrePortal is built in the open, and +contributions are genuinely welcome. + +## Ground rules + +- LibrePortal is **AGPLv3**. By contributing, your work is offered under that + same license (see the DCO below). +- **Match the surrounding code** — keep it simple and readable, and follow + the style of the file you're editing. +- Keep pull requests focused: one change per PR where you can. + +## Developer Certificate of Origin (DCO) + +We use the [DCO](https://developercertificate.org/) instead of a CLA — no +paperwork, just a sign-off certifying you have the right to submit your code. + +Add a `Signed-off-by` line to every commit by committing with `-s`: + +```bash +git commit -s -m "your message" +``` + +That appends: + +``` +Signed-off-by: Your Name +``` + +By signing off, you agree to the DCO: that you wrote the patch (or otherwise +have the right to submit it) and that it may be included under the project's +AGPLv3 license. + +## Bugs & ideas + +Open an issue — clear steps to reproduce and your environment details help a +lot. + +Thanks for helping keep self-hosting free and open. 🕊️ diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PROMISE.md b/PROMISE.md new file mode 100644 index 0000000..9079c5d --- /dev/null +++ b/PROMISE.md @@ -0,0 +1,46 @@ +# The LibrePortal Promise + +LibrePortal is free software, and it always will be. This is our commitment +to you — in plain language, so you can hold us to it. + +## What "free" means here + +You can **run, study, modify, share, and fully use 100% of LibrePortal — +every feature — for free, forever.** The entire platform is licensed under +the GNU AGPLv3 (see [LICENSE](LICENSE)). There are **no feature paywalls in +the software, no crippled "community edition," and no telemetry** phoning +home. + +If you self-host LibrePortal, you get everything. No asterisks. + +## How we keep the lights on + +Building and maintaining this takes real work, and we want to do it +sustainably — without betraying a word of the above. So we charge only for +things that **aren't the software**: + +- **LibrePortal Cloud** — optional hosted services we run for you: remote + access (no port-forwarding), off-site encrypted backups, a free subdomain + with automatic HTTPS, phone notifications, and more. +- **Managed hosting** — we run LibrePortal for you, if you'd rather not. +- **Support** — priority help for those who want it. + +## The line we will not cross + +**Every paid service has a free, self-hostable equivalent in the open code.** +You pay us for the convenience of not running it yourself — *never* to +*unlock* a capability. Want to run your own relay or point backups at your +own storage? The code to do that is right here, free. + +**Our litmus test:** before anything becomes paid, we ask — *could you do +this yourself with the open code?* If yes, we may also offer a hosted +version. If the only way to get it is to pay us, we don't ship it. + +## What we will never do + +- Paywall a feature of the software you run on your own machine. +- Add tracking or telemetry. +- **Rug-pull.** What is open stays open — we will never relicense released + code out from under the community. + +That's the deal. Thanks for trusting us with your corner of the internet. 🕊️ diff --git a/README.md b/README.md new file mode 100755 index 0000000..8ffc4ab --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# LibrePortal + +**Your own private corner of the internet — free, open, and yours.** + +LibrePortal is a self-hosted platform for running the apps you rely on, on +your own server: one-click installs, a reverse proxy with automatic SSL, +rootless Docker, optional VPN routing, and a clean web dashboard to manage +it all. + +> ⚠️ **v0.1.0 — early days.** Expect rough edges while things settle. + +## Free & open — forever + +The entire platform is **free software under the [GNU AGPLv3](LICENSE)**. +Self-host it and you get **everything** — every feature, no paywalls, no +telemetry. See [our Promise](PROMISE.md) for exactly what that means. + +## What you get + +- 📦 One-click self-hosted apps (Nextcloud, Vaultwarden, Jellyfin, Gitea, …) +- 🔀 Traefik reverse proxy + automatic Let's Encrypt SSL +- 🔒 Rootless Docker, CrowdSec, sane security defaults +- 🛡️ Optional VPN routing (gluetun) for any app +- 🖥️ A web dashboard to install, configure, back up, and monitor everything + +## Quick start + +```bash +git clone https://gitea.scottwebstar.co.uk/Webstar/LibrePortal.git +cd LibrePortal +./init.sh +``` + +## LibrePortal Cloud (optional) + +Self-hosting is free and complete. If you'd rather not run the fiddly parts +yourself, **LibrePortal Cloud** offers them as paid, hosted services — remote +access, off-site backups, notifications, and more. **Every one has a free, +self-hostable equivalent in this repo** — you pay for convenience, never to +unlock. [Our Promise](PROMISE.md) spells out exactly where that line sits. + +## Contributing + +PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). We use a lightweight +DCO sign-off (`git commit -s`), no CLA. + +## License + +[GNU AGPLv3](LICENSE). What's open stays open. diff --git a/configs/backup/.category b/configs/backup/.category new file mode 100644 index 0000000..8cf02aa --- /dev/null +++ b/configs/backup/.category @@ -0,0 +1,5 @@ +TITLE=Backup +DESCRIPTION=Backup schedules, retention, and engine settings +ICON=backup +ORDER=3 +SUBCATEGORY_ORDER=backup_general,backup_retention,backup_advanced diff --git a/configs/backup/backup_advanced b/configs/backup/backup_advanced new file mode 100644 index 0000000..ead67e8 --- /dev/null +++ b/configs/backup/backup_advanced @@ -0,0 +1,8 @@ +# ================================================================================ +# Backup Advanced - **ADVANCED** Engine-level knobs most users won't need to touch +# ================================================================================ +CFG_BACKUP_ENGINE=restic # Default Backup Engine - Fallback engine for new locations (each location can override) [restic:restic|borg:BorgBackup|kopia:Kopia] +CFG_BACKUP_STRATEGY=stop-snapshot-start # Backup Strategy - How containers are quiesced before snapshotting [stop-snapshot-start:Stop → snapshot → start (safe default)|pause-snapshot-unpause:Pause → snapshot → unpause (less downtime)|live:Live — snapshot while running (only with DB dump hooks)] +CFG_BACKUP_VERIFY_AFTER=true # Verify After Backup - Run integrity check after each backup +CFG_BACKUP_VERIFY_DATA_PERCENT=5 # Verify Data Sample % - Percentage of repo data to checksum-verify weekly +CFG_BACKUP_PARALLEL_REPOS=true # Parallel Repos - Push to all enabled locations in parallel diff --git a/configs/backup/backup_general b/configs/backup/backup_general new file mode 100755 index 0000000..285cffd --- /dev/null +++ b/configs/backup/backup_general @@ -0,0 +1,5 @@ +# ================================================================================ +# Backup General - Scheduling +# ================================================================================ +CFG_BACKUP_CRONTAB_APP="0 5 * * *" # App Backup Schedule - Crontab schedule for application backups +CFG_BACKUP_CRONTAB_APP_INTERVAL=3 # App Backup Interval - Minutes between app backup checks diff --git a/configs/backup/backup_retention b/configs/backup/backup_retention new file mode 100644 index 0000000..b596800 --- /dev/null +++ b/configs/backup/backup_retention @@ -0,0 +1,13 @@ +# ================================================================================ +# Backup Retention - Default retention policy applied at every forget pass. +# Per-location overrides supported via the Locations edit modal. +# +# Most users should pick a "Backup style" preset on the Schedule page rather +# than editing these directly. Blank values mean "do not enforce this tier". +# ================================================================================ +CFG_BACKUP_KEEP_LAST= # Keep Last N - Always keep this many most-recent snapshots +CFG_BACKUP_KEEP_DAILY=30 # Keep Daily - Keep one snapshot per day for this many days +CFG_BACKUP_KEEP_WEEKLY= # Keep Weekly - Keep one snapshot per week for this many weeks +CFG_BACKUP_KEEP_MONTHLY= # Keep Monthly - Keep one snapshot per month for this many months +CFG_BACKUP_KEEP_YEARLY= # Keep Yearly - Keep one snapshot per year for this many years +CFG_BACKUP_PRUNE_AFTER_FORGET=true # Prune After Forget - Reclaim repo space after forgetting snapshots diff --git a/configs/backup/locations/1/location.config b/configs/backup/locations/1/location.config new file mode 100644 index 0000000..c30ec1b --- /dev/null +++ b/configs/backup/locations/1/location.config @@ -0,0 +1,27 @@ +# Backup location 1 — default local repo seeded with LibrePortal. +# Edit via the Locations page on /backup, or directly here. +CFG_BACKUP_LOC_1_NAME="Local disk" # Location Name - Friendly label shown in the UI +CFG_BACKUP_LOC_1_ENABLED=true # Enabled - Snapshot to this location +CFG_BACKUP_LOC_1_ENGINE=restic # Engine - Backup engine used at this location [restic:restic|borg:BorgBackup|kopia:Kopia] +CFG_BACKUP_LOC_1_PASSWORD=RANDOMIZEDPASSWORD1 # Repository Password - Used to encrypt/decrypt snapshots — back up offline! +CFG_BACKUP_LOC_1_TYPE=local # Type - Backend [local:Local / mounted path|sftp:SFTP|rest:REST|s3:S3|b2:Backblaze B2|gs:Google Cloud Storage|azure:Azure|rclone:rclone] +CFG_BACKUP_LOC_1_PATH_MODE=auto # Path Mode - Where this location stores its data [auto:Automatic (/docker/backups/)|custom:Custom path] +CFG_BACKUP_LOC_1_PATH= # Custom Path - Filesystem path on this server (used when Path Mode = Custom) +CFG_BACKUP_LOC_1_URI= # URI Override - Custom restic URI (leave blank to build from the fields below) +CFG_BACKUP_LOC_1_SSH_USER= # SSH User - For sftp type +CFG_BACKUP_LOC_1_SSH_HOST= # SSH Host - For sftp type +CFG_BACKUP_LOC_1_SSH_PORT=22 # SSH Port - For sftp type +CFG_BACKUP_LOC_1_SSH_PATH= # SSH Remote Path - Path on the remote host where the repo lives +CFG_BACKUP_LOC_1_SSH_AUTH=key # SSH Authentication - [key:SSH key (~/.ssh/id_rsa)|password:Password (via sshpass)] +CFG_BACKUP_LOC_1_SSH_PASS= # SSH Password - Used only when SSH Authentication is set to Password +CFG_BACKUP_LOC_1_S3_ACCESS_KEY= # S3 Access Key - For s3 type +CFG_BACKUP_LOC_1_S3_SECRET_KEY= # S3 Secret Key - For s3 type +CFG_BACKUP_LOC_1_B2_ACCOUNT_ID= # B2 Account ID - For b2 type +CFG_BACKUP_LOC_1_B2_ACCOUNT_KEY= # B2 Account Key - For b2 type +CFG_BACKUP_LOC_1_APPEND_ONLY=false # Append-only - Refuse forget/prune for this location (ransomware-safe) +CFG_BACKUP_LOC_1_CUSTOM_RETENTION=false # Custom Retention - Override the global retention for this location +CFG_BACKUP_LOC_1_KEEP_LAST= # Keep Last - Snapshots to always retain (blank = global) +CFG_BACKUP_LOC_1_KEEP_DAILY= # Keep Daily - Days (blank = global) +CFG_BACKUP_LOC_1_KEEP_WEEKLY= # Keep Weekly - Weeks (blank = global) +CFG_BACKUP_LOC_1_KEEP_MONTHLY= # Keep Monthly - Months (blank = global) +CFG_BACKUP_LOC_1_KEEP_YEARLY= # Keep Yearly - Years (blank = global) diff --git a/configs/features/.category b/configs/features/.category new file mode 100755 index 0000000..e2c1a54 --- /dev/null +++ b/configs/features/.category @@ -0,0 +1,5 @@ +TITLE=Features +DESCRIPTION=Toggle system components and features +ICON=features +ORDER=5 +SUBCATEGORY_ORDER=features_core,features_security,features_terminal diff --git a/configs/features/features_core b/configs/features/features_core new file mode 100755 index 0000000..999f5f2 --- /dev/null +++ b/configs/features/features_core @@ -0,0 +1,18 @@ +# ================================================================================ +# Core Features - Essential LibrePortal functionality and core services +# ================================================================================ +CFG_REQUIREMENT_CONFIG=true # Configuration Management - Enable configuration management system for LibrePortal settings +CFG_REQUIREMENT_COMMAND=true # Command Line Tool - Install the libreportal command line tool for system management +CFG_REQUIREMENT_WEBUI=true # Web Interface - Install and manage the LibrePortal web based management interface +CFG_REQUIREMENT_WEBUI_SERVICE=true # Web Task Service - Install the task management systemd service for the web interface +CFG_REQUIREMENT_DATABASE=true # Database Support - Install and configure database support for application data storage +CFG_REQUIREMENT_PASSWORDS=true # Password Management - Enable password generation and management features +CFG_REQUIREMENT_DOCKER_CE=true # Docker CE - Install Docker Community Edition instead of the default Docker version +CFG_REQUIREMENT_DOCKER_COMPOSE=true # Docker Compose - Install Docker Compose for multi container application management +CFG_REQUIREMENT_DOCKER_NETWORK=true # Docker Network - Create and manage Docker network for container communication +CFG_REQUIREMENT_UFW=true # Firewall Protection - Install and configure the Uncomplicated Firewall for system security +CFG_REQUIREMENT_UFWD=true # Docker Firewall - Install UFW Docker for container aware firewall management which is rooted Docker specific +CFG_REQUIREMENT_SSLCERTS=true # SSL Certificates - Generate and manage SSL certificates for secure HTTPS connections +CFG_REQUIREMENT_CRONTAB=true # Scheduled Tasks - Setup scheduled tasks and automated maintenance jobs +CFG_REQUIREMENT_WHITELIST_PORT_UPDATER=true # Auto Port Management - Automatically update port whitelist when applications are installed or removed +CFG_REQUIREMENT_BCRYPT_SAVE=true # Password Encryption - Encrypt saved passwords using bcrypt for enhanced security diff --git a/configs/features/features_security b/configs/features/features_security new file mode 100755 index 0000000..ba079dd --- /dev/null +++ b/configs/features/features_security @@ -0,0 +1,7 @@ +# ================================================================================ +# Security and Authentication - SSH access and security configuration +# ================================================================================ +CFG_REQUIREMENT_SSHKEY_DOWNLOADER=false # SSH Key Downloader - Enable SSH key download functionality for remote access +CFG_REQUIREMENT_SSH_DISABLE_PASSWORDS=false # SSH Password Disable - Disable password authentication for SSH requiring key based access only +CFG_REQUIREMENT_GLUETUN_FOR_ALL=false # Gluetun For All Apps - Allow routing through Gluetun VPN for every app (default: only curated categories) + diff --git a/configs/features/features_terminal b/configs/features/features_terminal new file mode 100755 index 0000000..c508195 --- /dev/null +++ b/configs/features/features_terminal @@ -0,0 +1,11 @@ +# ================================================================================ +# Terminal Only - Advanced terminal based features and utilities **ADVANCED** +# ================================================================================ +CFG_REQUIREMENT_SUGGEST_INSTALLS=false # Install Suggestions - Enable application suggestions and recommendations during installation +CFG_REQUIREMENT_SUGGEST_METRICS=true # Metrics Suggestions - Offer Prometheus and Grafana during first install (requires Install Suggestions enabled) +CFG_REQUIREMENT_CONTINUE_PROMPT=false # Continue Prompts - Show continue prompts during installation for user confirmation +CFG_REQUIREMENT_CONFIGS_CHECK=true # Config Validation - Validate configuration files on startup for errors and consistency +CFG_REQUIREMENT_CONFIGS_AUTO_UPDATE=true # Auto Config Updates - Automatically update configuration files when system changes are detected +CFG_REQUIREMENT_MISSING_IPS=false # IP Configuration Check - Check for and alert about missing IP configurations +CFG_REQUIREMENT_DOCKER_NETWORK_PRUNE=true # Docker Network Cleanup - Enable automatic cleanup of unused Docker networks +CFG_REQUIREMENT_DOCKER_SWITCHER=true # Docker Switcher - Install Docker version switching utility for managing multiple Docker versions diff --git a/configs/general/.category b/configs/general/.category new file mode 100755 index 0000000..81beef1 --- /dev/null +++ b/configs/general/.category @@ -0,0 +1,5 @@ +TITLE=General +DESCRIPTION=Basic system settings and identification +ICON=general +ORDER=1 +SUBCATEGORY_ORDER=general_basic,general_mail,general_install,general_docker_install,general_terminal,general_libreportal diff --git a/configs/general/general_basic b/configs/general/general_basic new file mode 100755 index 0000000..caf8a3d --- /dev/null +++ b/configs/general/general_basic @@ -0,0 +1,5 @@ +# ================================================================================ +# General - Basic system settings and identification +# ================================================================================ +CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance +CFG_TIMEZONE=Etc/UTC # System Timezone - Timezone for scheduled tasks and logging timestamps diff --git a/configs/general/general_docker_install b/configs/general/general_docker_install new file mode 100755 index 0000000..0c0b615 --- /dev/null +++ b/configs/general/general_docker_install @@ -0,0 +1,7 @@ +# ================================================================================ +# Docker - Container runtime installation and configuration **ADVANCED** +# ================================================================================ +CFG_DOCKER_INSTALL_TYPE=rooted # Docker Installation Type - Security based setup rooted or rootless Docker installation [rooted|rootless] +CFG_DOCKER_INSTALL_USER=dockerinstall # Docker Install User - Username for Docker installation operations +CFG_DOCKER_INSTALL_PASS=RANDOMIZEDPASSWORD2 # Docker Install Password - Password for Docker install user + diff --git a/configs/general/general_install b/configs/general/general_install new file mode 100755 index 0000000..a587914 --- /dev/null +++ b/configs/general/general_install @@ -0,0 +1,9 @@ +# ================================================================================ +# Installation Setup - Local or Git Repository configuration and version control +# ================================================================================ +CFG_INSTALL_MODE=local # Installation Mode - Method used for installation of LibrePortal +CFG_GIT_URL=changeme # Git Repository URL - Git repository URL for LibrePortal configuration +CFG_GIT_USER=changeme # Git Username - Git username for repository authentication +CFG_GIT_KEY=changeme # Git Access Key - SSH key or API key for Git repository access +CFG_GIT_UPDATES=true # Auto Check Updates - Check for Git repository updates automatically +CFG_GIT_AUTO_UPDATES=true # Auto Apply Updates - Automatically apply Git updates when available diff --git a/configs/general/general_libreportal b/configs/general/general_libreportal new file mode 100755 index 0000000..523a463 --- /dev/null +++ b/configs/general/general_libreportal @@ -0,0 +1,4 @@ +# ================================================================================ +# LibrePortal - Specific LibrePortal configurations **ADVANCED** +# ================================================================================ +CFG_LIBREPORTAL_USER_PASS=changeme # LibrePortal User Password - Password for the LibrePortal system user account diff --git a/configs/general/general_mail b/configs/general/general_mail new file mode 100755 index 0000000..78f344d --- /dev/null +++ b/configs/general/general_mail @@ -0,0 +1,10 @@ +# ================================================================================ +# Mail - Mail Server Settings +# ================================================================================ +CFG_MAIL_ENABLED=false # Mail Enabled - Enable mail server configuration for applications +CFG_MAIL_HOST=mail.domain.com # Mail Server Host - Your mail server hostname +CFG_MAIL_PORT=587 # Mail Server Port - Usually 587 for TLS, 465 for SSL, 25 for none +CFG_MAIL_SECURE=tls # Security - tls, ssl, or none +CFG_MAIL_USERNAME=your-email@domain.com # Mail Username - Your email address for authentication +CFG_MAIL_PASSWORD=your-app-password # Mail Password - Use app password for authentication +CFG_MAIL_FROM=noreply@domain.com # From Email Address - Sender email address diff --git a/configs/general/general_terminal b/configs/general/general_terminal new file mode 100755 index 0000000..6ed31a1 --- /dev/null +++ b/configs/general/general_terminal @@ -0,0 +1,9 @@ +# ================================================================================ +# Terminal - System utilities and advanced settings **ADVANCED** +# ================================================================================ +CFG_UPDATER_CHECK=60 # Update Check Interval - Hours between system update checks +CFG_SWAPFILE_SIZE=2G # Swap File Size - Size of swap file for memory management +CFG_GENERATED_PASS_LENGTH=14 # Password Length - Length for auto generated passwords +CFG_GENERATED_USER_LENGTH=8 # Username Length - Length for auto generated usernames +CFG_UFW_LOGGING=off # Firewall Logging - UFW firewall logging level [off|low|medium|high|full] +CFG_TEXT_EDITOR=nano # Text Editor - Default text editor for system operations [nano|vim] diff --git a/configs/network/.category b/configs/network/.category new file mode 100755 index 0000000..29ed2fa --- /dev/null +++ b/configs/network/.category @@ -0,0 +1,5 @@ +TITLE=Network +DESCRIPTION=Network configuration and domain management +ICON=network +ORDER=4 +SUBCATEGORY_ORDER=network_domains,network_whitelist,network_dns,network_docker,network_ports,network_headscale diff --git a/configs/network/network_dns b/configs/network/network_dns new file mode 100755 index 0000000..93ad288 --- /dev/null +++ b/configs/network/network_dns @@ -0,0 +1,8 @@ +# ================================================================================ +# DNS - Dynamic Name Server Addresses +# ================================================================================ + +CFG_DNS_SERVER_1=9.9.9.9 # Primary DNS - Primary DNS server for network resolution +CFG_DNS_SERVER_2=9.9.9.11 # Secondary DNS - Secondary DNS server for network resolution + +CFG_REQUIREMENT_DNS_UPDATER=false # DNS Updater - Use AdGuard or Pi-hole as this server's DNS resolver when installed (rewrites /etc/resolv.conf). Off by default. diff --git a/configs/network/network_docker b/configs/network/network_docker new file mode 100755 index 0000000..9eaf31f --- /dev/null +++ b/configs/network/network_docker @@ -0,0 +1,7 @@ +# ================================================================================ +# Docker Network - Network settings for the Docker Network **ADVANCED** +# ================================================================================ + +CFG_NETWORK_NAME=vpn # Network Name - Docker network name for container communication +CFG_NETWORK_SUBNET=10.100.0.0/16 # Network Subnet - Subnet range for Docker network +CFG_NETWORK_MTU=1500 # Network MTU - Maximum transmission unit for network packets diff --git a/configs/network/network_domains b/configs/network/network_domains new file mode 100755 index 0000000..9a87300 --- /dev/null +++ b/configs/network/network_domains @@ -0,0 +1,12 @@ +# ================================================================================ +# Domains - Domain configuration for Traefik web services +# ================================================================================ +CFG_DOMAIN_1= # Domain 1 - Domain slot 1 for a Traefik +CFG_DOMAIN_2= # Domain 2 - Domain slot 2 for a Traefik +CFG_DOMAIN_3= # Domain 3 - Domain slot 3 for a Traefik +CFG_DOMAIN_4= # Domain 4 - Domain slot 4 for a Traefik +CFG_DOMAIN_5= # Domain 5 - Domain slot 5 for a Traefik +CFG_DOMAIN_6= # Domain 6 - Domain slot 6 for a Traefik +CFG_DOMAIN_7= # Domain 7 - Domain slot 7 for a Traefik +CFG_DOMAIN_8= # Domain 8 - Domain slot 8 for a Traefik +CFG_DOMAIN_9= # Domain 9 - Domain slot 9 for a Traefik diff --git a/configs/network/network_headscale b/configs/network/network_headscale new file mode 100755 index 0000000..27659de --- /dev/null +++ b/configs/network/network_headscale @@ -0,0 +1,5 @@ +# ================================================================================ +# Headscale - VPN service configuration **ADVANCED** +# ================================================================================ +CFG_HEADSCALE_HOST= # Headscale Host - Headscale server hostname for VPN services +CFG_HEADSCALE_KEY= # Headscale Key - Authentication key for Headscale server diff --git a/configs/network/network_ports b/configs/network/network_ports new file mode 100755 index 0000000..34b2e30 --- /dev/null +++ b/configs/network/network_ports @@ -0,0 +1,5 @@ +# ================================================================================ +# Ports - Settings for the Network Ports **ADVANCED** +# ================================================================================ + +CFG_PORT_RANGE=3000-9999 # Port allocation range - Range for port allocation Start to End diff --git a/configs/network/network_whitelist b/configs/network/network_whitelist new file mode 100755 index 0000000..2a32a83 --- /dev/null +++ b/configs/network/network_whitelist @@ -0,0 +1,4 @@ +# ================================================================================ +# Whitelist - Allow specific IPs for Specified Treafik Apps +# ================================================================================ +CFG_IPS_WHITELIST=HOSTIPHERE # IP Whitelist - Comma separated list of allowed IP addresses diff --git a/configs/security/.category b/configs/security/.category new file mode 100644 index 0000000..dab1646 --- /dev/null +++ b/configs/security/.category @@ -0,0 +1,5 @@ +TITLE=Security +DESCRIPTION=Intrusion prevention, bouncers, and host firewall configuration +ICON=security +ORDER=5 +SUBCATEGORY_ORDER=security_logins diff --git a/configs/security/security_logins b/configs/security/security_logins new file mode 100755 index 0000000..728d63a --- /dev/null +++ b/configs/security/security_logins @@ -0,0 +1,5 @@ +# ================================================================================ +# Logins - User accounts and authentication credentials +# ================================================================================ +CFG_TRAEFIK_USER=RANDOMIZEDUSERNAME1 # Traefik Username - Username for Traefik Admin Panel login and protected apps +CFG_TRAEFIK_PASS=RANDOMIZEDPASSWORD2 # Traefik Password - Password for Traefik Admin Panel login and protected apps diff --git a/configs/webui/.category b/configs/webui/.category new file mode 100755 index 0000000..30bf00c --- /dev/null +++ b/configs/webui/.category @@ -0,0 +1,5 @@ +TITLE=WebUI +DESCRIPTION=Web interface settings and preferences +ICON=webui +ORDER=2 +SUBCATEGORY_ORDER=webui_logins,webui_logs diff --git a/configs/webui/webui_logins b/configs/webui/webui_logins new file mode 100755 index 0000000..87db237 --- /dev/null +++ b/configs/webui/webui_logins @@ -0,0 +1,5 @@ +# ================================================================================ +# WebUI Logins - Web interface authentication settings +# ================================================================================ +CFG_WEBUI_USERNAME=RANDOMIZEDUSERNAME1 # WebUI Username - Username for web interface login +CFG_WEBUI_PASSWORD=RANDOMIZEDPASSWORD1 # WebUI Password - Password for web interface login diff --git a/configs/webui/webui_logs b/configs/webui/webui_logs new file mode 100644 index 0000000..fa03fcf --- /dev/null +++ b/configs/webui/webui_logs @@ -0,0 +1,6 @@ +# ================================================================================ +# WebUI Logs - Log-streaming behaviour for the Services tab **ADVANCED** +# ================================================================================ +CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES=10 # Idle Timeout - Disconnect a log stream after this much silence. The viewer overlays a Resume button so the user can re-open the stream. 0 disables. +CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES=60 # Max Duration - Hard cap on a single stream. Resume button appears at the cap. 0 disables (not recommended). +CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC=200 # Max Lines per Second - Burst ceiling. Excess lines drop with a per-second notice. diff --git a/containers/adguard/adguard.config b/containers/adguard/adguard.config new file mode 100755 index 0000000..18ab8a8 --- /dev/null +++ b/containers/adguard/adguard.config @@ -0,0 +1,79 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# USER = admin username for the AdGuardHome web UI +# PASSWORD = plain text admin password for the AdGuardHome web UI +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_ADGUARD_APP_NAME=adguard +CFG_ADGUARD_BACKUP=true +CFG_ADGUARD_COMPOSE_FILE=default +CFG_ADGUARD_HEALTHCHECK=true +CFG_ADGUARD_AUTHELIA=false +CFG_ADGUARD_HEADSCALE=false +CFG_ADGUARD_USER=admin +CFG_ADGUARD_PASSWORD=RANDOMIZEDPASSWORD1 +CFG_ADGUARD_MONITORING=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_ADGUARD_CATEGORY="networking" +CFG_ADGUARD_TITLE="AdGuard" +CFG_ADGUARD_DESCRIPTION="DNS based Ad Blocking" +CFG_ADGUARD_LONG_DESCRIPTION="AdGuard Home is a network-wide software for advertisements and tracking blocking that operates as a DNS server and returns the IP address of a local, blackhole DNS server for domains that should be blocked" +CFG_ADGUARD_URL="https://github.com/AdguardTeam/AdGuardHome" +CFG_ADGUARD_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips on traefik, if false allow all +# +CFG_ADGUARD_DOMAIN=1 +CFG_ADGUARD_WHITELIST=false +CFG_ADGUARD_HOST_NAME=adguard +CFG_ADGUARD_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_ADGUARD_PORT_1="adguard-service|webui|random:3000|public|tcp|true|true|true|Admin Interface|" +CFG_ADGUARD_PORT_2="adguard-service|dns-tcp|random:53|public|tcp|false|false|false|DNS Server (TCP)|" +CFG_ADGUARD_PORT_3="adguard-service|dns-udp|random:53|public|udp|false|false|false|DNS Server (UDP)|" +CFG_ADGUARD_PORT_4="adguard-service|dns-alt|random:8053|disabled|tcp|false|false|false|Alternative DNS|" +CFG_ADGUARD_PORT_5="adguard-service|dot|random:853|disabled|tcp|false|false|false|DNS-over-TLS|" +CFG_ADGUARD_PORT_6="adguard-exporter|metrics|9617:9617|disabled|tcp|false|false|false|Metrics Exporter (sidecar, docker-network only)|" + + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +CFG_ADGUARD_AUTH_PROFILE=single_password +CFG_ADGUARD_ADMIN_USER= +CFG_ADGUARD_ADMIN_PASSWORD=RANDOMIZEDPASSWORD2 diff --git a/containers/adguard/adguard.sh b/containers/adguard/adguard.sh new file mode 100644 index 0000000..9f5f1b8 --- /dev/null +++ b/containers/adguard/adguard.sh @@ -0,0 +1,276 @@ +#!/bin/bash + +# Category : Networking +# Description : AdGuard - DNS based Ad Blocking (c/u/s/r/i): + +installAdguard() +{ + local config_variables="$1" + + if [[ "$adguard" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent adguard; + local app_name=$CFG_ADGUARD_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$adguard" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$adguard" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$adguard" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$adguard" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$adguard" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + monitoringToggleAppConfig "$app_name" "docker-compose.yml"; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Completing AdGuardHome initial setup automatically" + echo "" + + # The legacy `$usedport1` variable is no longer populated by the + # current install pipeline; the resolved host port is stored in the + # PORTS_TAG_1 docker-compose tag (format `external:internal`). Pull + # it from there so the curl + URL printout actually point somewhere. + local adguard_compose_file="$containers_dir$app_name/docker-compose.yml" + local adguard_port_pair + adguard_port_pair=$(tagsManagerGetTagContent "$adguard_compose_file" "PORTS_TAG_1") + local adguard_admin_port="${adguard_port_pair%%:*}" + + if [[ -n "$public_ip_v4" && -n "$adguard_admin_port" ]]; then + echo " External : http://$public_ip_v4:$adguard_admin_port/" + fi + if [[ -n "$host_setup" ]]; then + echo " Hostname : http://$host_setup/" + fi + echo "" + + # AdGuardHome ships a setup wizard that normally needs five clicks in a + # browser before the daemon writes its config file. Same wizard is + # exposed as an HTTP API (POST /control/install/configure), so we + # drive it from here and skip the manual interaction. We pre-poll the + # admin endpoint until the container is up, then send the form, then + # let the existing post-install sed edits run against the freshly + # written AdGuardHome.yaml. + local adguard_setup_url="http://127.0.0.1:${adguard_admin_port}" + local adguard_attempts=0 + local adguard_max_attempts=60 + while ((adguard_attempts < adguard_max_attempts)); do + if curl -fsS -o /dev/null --max-time 2 "${adguard_setup_url}/control/status" 2>/dev/null \ + || curl -fsS -o /dev/null --max-time 2 "${adguard_setup_url}/control/install/get_addresses" 2>/dev/null; then + break + fi + sleep 2 + ((adguard_attempts++)) + done + + if ((adguard_attempts >= adguard_max_attempts)); then + isError "AdGuardHome admin endpoint did not respond on $adguard_setup_url within $((adguard_max_attempts * 2))s — open the URL and complete setup manually, then re-run the installer to apply the post-setup tweaks." + else + local adguard_user="${CFG_ADGUARD_USER:-admin}" + local adguard_pass="${CFG_ADGUARD_PASSWORD:-}" + if [[ -z "$adguard_pass" ]]; then + adguard_pass=$(generateRandomPassword) + updateConfigOption "CFG_ADGUARD_PASSWORD" "$adguard_pass" >/dev/null 2>&1 || true + isNotice "Generated a random AdGuardHome admin password and saved it to CFG_ADGUARD_PASSWORD." + fi + + # Internal container ports are fixed (3000 admin, 53 DNS); host + # mapping is what `usedport1` etc. handle. + local adguard_payload + adguard_payload=$(cat </dev/null 2>&1; then + isSuccessful "AdGuardHome admin setup completed automatically (user: $adguard_user)." + else + # 422/403 here typically means setup was already done on a + # previous install; the post-setup tweaks below are still + # safe to run against the existing yaml. + isNotice "AdGuardHome /control/install/configure rejected the request — assuming it's already configured. If this is a fresh install, complete setup manually at $adguard_setup_url." + fi + # Small breather so AdGuardHome finishes flushing AdGuardHome.yaml + # to disk before the sed edits below touch it. + #sleep 3 + fi + + #result=$(sudo sed -i "s/address: 0.0.0.0:80/address: 0.0.0.0:${usedport2}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + #checkSuccess "Changing port 80 to $usedport2 for Admin Panel" + + #result=$(sudo sed -i "s/port: 53/port: ${usedport3}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + #checkSuccess "Changing port 53 to $usedport3 for DNS Port" + + #result=$(sudo sed -i "s/port_https: 443/port_https: ${usedport4}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + #checkSuccess "Changing port 443 to $usedport4 for DNS Port" + + #result=$(sudo sed -i "s/port_dns_over_tls: 853/port_dns_over_tls: ${usedport5}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + #checkSuccess "Changing port 853 to $usedport5 for port_dns_over_tls" + + #result=$(sudo sed -i "s/port_dns_over_quic: 853/port_dns_over_quic: ${usedport5}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + #checkSuccess "Changing port 853 to $usedport5 for port_dns_over_quic" + + # NOTE: We deliberately do *not* force `tls.enabled: true` here. + # That section configures encrypted DNS (DoT/DoH/DoQ) and AdGuardHome + # crash-loops on startup with `[fatal] creating dns server: parsing + # tls key pair: tls: failed to find any PEM data in certificate input` + # if `enabled: true` is set without a real certificate pair pointed + # at by `certificate_path` / `private_key_path`. The admin user can + # opt into encrypted DNS from Settings → Encryption once they've + # provided a cert. + + if [[ $public == "true" ]]; then + result=$(sudo sed -i "s|allow_unencrypted_doh: false|allow_unencrypted_doh: true|g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + checkSuccess "Setting allow_unencrypted_doh to false for Traefik" + fi + + result=$(sudo sed -i "s|anonymize_client_ip: false: false|anonymize_client_ip: true|g" "$containers_dir$app_name/conf/AdGuardHome.yaml") + checkSuccess "Setting anonymize_client_ip to true for privacy reasons" + + # Force the admin web bind back to 0.0.0.0:3000 inside the container. + # The docker-compose mapping is `:3000`, so the container + # MUST listen on 3000 internally for the host port to reach it. After + # the install API call AdGuardHome sometimes ends up bound to 0.0.0.0:80 + # (its build-time default) instead of the port we sent — which is + # exactly what causes "unable to connect" on the host port. + local adguard_yaml="$containers_dir$app_name/conf/AdGuardHome.yaml" + if [[ -f "$adguard_yaml" ]]; then + # New schema (v0.107+): single `address: 0.0.0.0:NN` line under `http:`. + sudo sed -i 's|^\(\s*address:\s*\)0\.0\.0\.0:[0-9]\+|\10.0.0.0:3000|' "$adguard_yaml" + # Old schema fallback: separate `bind_host:` / `bind_port:` keys. + sudo sed -i 's|^\(\s*bind_host:\s*\).*|\10.0.0.0|' "$adguard_yaml" + sudo sed -i 's|^\(\s*bind_port:\s*\)[0-9]\+|\13000|' "$adguard_yaml" + checkSuccess "Pinned AdGuardHome admin bind to 0.0.0.0:3000 (matches the compose port mapping)." + fi + + dockerComposeRestart "$app_name"; + + # Health-check after the restart so the user finds out *here* if + # AdGuardHome didn't come back up cleanly, rather than later when + # they try to open the URL and just see "unable to connect". + # + # Drop `-f` and accept any HTTP status code: now that the admin + # account is configured, `/control/status` returns 401 to an + # unauthenticated request — which is fine, it means the server is + # up and answering. We only care whether the connection succeeded + # at all, not what the response body says. `-w '%{http_code}'` + # gives us a 3-digit code on success and an empty string on a + # connection failure / timeout. + local adguard_health_attempts=0 + while ((adguard_health_attempts < 20)); do + local adguard_health_code + adguard_health_code=$(curl -sS -o /dev/null --max-time 2 \ + -w '%{http_code}' "${adguard_setup_url}/control/status" 2>/dev/null) + if [[ "$adguard_health_code" =~ ^[1-5][0-9][0-9]$ ]]; then + isSuccessful "AdGuardHome admin UI is reachable on $adguard_setup_url (HTTP $adguard_health_code)" + break + fi + sleep 1 + ((adguard_health_attempts++)) + done + if ((adguard_health_attempts >= 20)); then + isError "AdGuardHome admin UI did not respond after restart on $adguard_setup_url. Check the container logs (\`docker logs adguard-service\`) and the conf/AdGuardHome.yaml bind address." + fi + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating the WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Refreshing monitoring integration." + echo "" + + monitoringRefreshAll; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + + # Same final-summary call shape as wireguard / vaultwarden. Pass the + # admin user/password we just configured so the user sees the + # credentials exactly once, at the end of the install. + menuShowFinalMessages "$app_name" "${CFG_ADGUARD_USER:-admin}" "$CFG_ADGUARD_PASSWORD"; + + menu_number=0 + #sleep 3s + cd + fi + adguard=n +} diff --git a/containers/adguard/adguard.svg b/containers/adguard/adguard.svg new file mode 100755 index 0000000..f6118fc --- /dev/null +++ b/containers/adguard/adguard.svg @@ -0,0 +1 @@ + diff --git a/containers/adguard/docker-compose.yml b/containers/adguard/docker-compose.yml new file mode 100644 index 0000000..10c9bc5 --- /dev/null +++ b/containers/adguard/docker-compose.yml @@ -0,0 +1,61 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + adguard-service: #LIBREPORTAL|SERVICE_TAG_1|adguard-service + container_name: adguard-service + image: adguard/adguardhome + restart: unless-stopped + hostname: adguard + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + - "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2 + - "PORTS_DATA_3" #LIBREPORTAL|PORTS_TAG_3|PORTS_DATA_3 + - "PORTS_DATA_4" #LIBREPORTAL|PORTS_TAG_4|PORTS_DATA_4 + - "PORTS_DATA_5" #LIBREPORTAL|PORTS_TAG_5|PORTS_DATA_5 + # GLUETUN_OFF_END + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.adguard-service-webui.entrypoints: web,websecure + traefik.http.routers.adguard-service-webui.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.adguard-service-webui.service: adguard-service-webui + traefik.http.routers.adguard-service-webui.tls: true + traefik.http.routers.adguard-service-webui.tls.certresolver: production + traefik.http.services.adguard-service-webui.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.adguard-service-webui.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1 + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - "./work:/opt/adguardhome/work" + - "./conf:/opt/adguardhome/conf" + - "./tailscale:/usr/local/bin/" + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + + # >>> libreportal-monitoring >>> + #adguard-exporter: + # container_name: adguard-exporter + # image: ebrianne/adguard-exporter:latest + # restart: unless-stopped + # environment: + # - ADGUARD_PROTOCOL=http + # - ADGUARD_HOSTNAME=adguard-service:PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + # - ADGUARD_USERNAME=ADGUARD_USER_DATA #LIBREPORTAL|ADGUARD_USER_TAG|ADGUARD_USER_DATA + # - ADGUARD_PASSWORD=ADGUARD_PASSWORD_DATA #LIBREPORTAL|ADGUARD_PASSWORD_TAG|ADGUARD_PASSWORD_DATA + # - INTERVAL=30s + # - LOG_LIMIT=10000 + # - SERVER_PORT=PORT_INTERNAL_DATA_6 #LIBREPORTAL|PORT_INTERNAL_TAG_6|PORT_INTERNAL_DATA_6 + # networks: + # DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # <<< libreportal-monitoring <<< diff --git a/containers/adguard/resources/monitoring/grafana-dashboards/adguard.json b/containers/adguard/resources/monitoring/grafana-dashboards/adguard.json new file mode 100644 index 0000000..e604fa2 --- /dev/null +++ b/containers/adguard/resources/monitoring/grafana-dashboards/adguard.json @@ -0,0 +1,74 @@ +{ + "annotations": { "list": [{ "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" }] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "adguard_num_dns_queries", "refId": "A" }], + "title": "DNS Queries", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } } }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "adguard_num_blocked_filtering", "refId": "A" }], + "title": "Blocked (Filtering)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "id": 3, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "adguard_num_replaced_safebrowsing", "refId": "A" }], + "title": "Replaced (Safe Browsing)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, "unit": "s" } }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "id": 4, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "adguard_avg_processing_time", "refId": "A" }], + "title": "Avg Processing Time", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false } } }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 4 }, + "id": 5, + "options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "rate(adguard_num_dns_queries[5m])", "legendFormat": "queries/s", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "rate(adguard_num_blocked_filtering[5m])", "legendFormat": "blocked/s", "refId": "B" } + ], + "title": "DNS Activity", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["libreportal", "adguard"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "AdGuard Home", + "uid": "libreportal-adguard", + "version": 1, + "weekStart": "" +} diff --git a/containers/adguard/resources/monitoring/prometheus-scrape.yml b/containers/adguard/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..830a13e --- /dev/null +++ b/containers/adguard/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,4 @@ +- job_name: adguard + metrics_path: /metrics + static_configs: + - targets: ['adguard-exporter:PORT_INTERNAL_DATA_6'] #LIBREPORTAL|PORT_INTERNAL_TAG_6|PORT_INTERNAL_DATA_6 diff --git a/containers/authelia/authelia.config b/containers/authelia/authelia.config new file mode 100755 index 0000000..bd19af9 --- /dev/null +++ b/containers/authelia/authelia.config @@ -0,0 +1,74 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# REQUIRES = comma-separated install prerequisites (see scripts/checks/requirements/check_app_install.sh) +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# THEME = UI theme for Authelia interface +# ADMIN_USERNAME = username for the seeded Authelia admin account +# ADMIN_PASSWORD = password for the seeded Authelia admin account +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_AUTHELIA_APP_NAME=authelia +CFG_AUTHELIA_REQUIRES="domain,traefik" +CFG_AUTHELIA_BACKUP=true +CFG_AUTHELIA_COMPOSE_FILE=default +CFG_AUTHELIA_HEALTHCHECK=true +CFG_AUTHELIA_AUTHELIA=false +CFG_AUTHELIA_HEADSCALE=false +CFG_AUTHELIA_THEME=dark +CFG_AUTHELIA_ADMIN_USERNAME=admin +CFG_AUTHELIA_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1 +CFG_AUTHELIA_MONITORING=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# REQUIRES_SERVICE = name of another LibrePortal app that must be installed before this one can be configured +# +CFG_AUTHELIA_CATEGORY="security" +CFG_AUTHELIA_TITLE="Authelia" +CFG_AUTHELIA_DESCRIPTION="Authentication & SSO" +CFG_AUTHELIA_LONG_DESCRIPTION="Authelia is an open-source authentication and authorization server providing 2-factor authentication and single sign-on for your applications" +CFG_AUTHELIA_URL="https://github.com/authelia/authelia" +CFG_AUTHELIA_ACTIONS="configure|install|restart|shutdown|uninstall" +CFG_AUTHELIA_REQUIRES_SERVICE=traefik +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_AUTHELIA_DOMAIN=1 +CFG_AUTHELIA_WHITELIST=false +CFG_AUTHELIA_HOST_NAME=authelia +CFG_AUTHELIA_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_AUTHELIA_PORT_1="authelia-service|webui|random:9091|public|tcp|false|true|true|Web Interface|" diff --git a/containers/authelia/authelia.sh b/containers/authelia/authelia.sh new file mode 100755 index 0000000..5d11e99 --- /dev/null +++ b/containers/authelia/authelia.sh @@ -0,0 +1,208 @@ +#!/bin/bash + +# Category : Security +# Description : Authelia - Authentication & SSO (c/u/s/r/i): + +installAuthelia() +{ + local config_variables="$1" + + if [[ "$authelia" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent authelia; + local app_name=$CFG_AUTHELIA_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$authelia" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$authelia" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$authelia" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$authelia" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$authelia" == *[iI]* ]]; then + isHeader "Install $app_name" + + # Pre-flight: bail out before touching any compose/config if the + # global prerequisites aren't met. CFG_AUTHELIA_REQUIRES lists + # what's needed (currently "domain,traefik"); the helper prints a + # clear list of what's missing so the user knows what to fix. + if ! appInstallCheckRequirements "$app_name" "$CFG_AUTHELIA_REQUIRES"; then + authelia=n + return 1 + fi + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + local result=$(copyResource "$app_name" "configuration.yml" "config" | sudo tee -a "$logs_dir/$docker_log_file" 2>&1) + checkSuccess "Copying configuration.yml to $containers_dir$app_name/config" + + local result=$(copyResource "$app_name" "users_database.yml" "config" | sudo tee -a "$logs_dir/$docker_log_file" 2>&1) + checkSuccess "Copying users_database.yml to $containers_dir$app_name/config" + + local authelia_config_file="$containers_dir$app_name/config/configuration.yml" + sudo sed -i "s|AUTHELIA_THEME_PLACEHOLDER|$CFG_AUTHELIA_THEME|g" "$authelia_config_file" + sudo sed -i "s|AUTHELIA_DOMAIN_PLACEHOLDER|$domain_full|g" "$authelia_config_file" + sudo sed -i "s|AUTHELIA_HOST_PLACEHOLDER|$host_setup|g" "$authelia_config_file" + checkSuccess "Substituting Authelia configuration values (theme=$CFG_AUTHELIA_THEME domain=$domain_full host=$host_setup)" + + local authelia_secrets_dir="$containers_dir$app_name/secrets" + sudo mkdir -p "$authelia_secrets_dir" + for secret_name in JWT_SECRET SESSION_SECRET STORAGE_ENCRYPTION_KEY; do + local secret_file="$authelia_secrets_dir/$secret_name" + if [[ ! -s "$secret_file" ]]; then + openssl rand -hex 64 | sudo tee "$secret_file" >/dev/null + sudo chmod 600 "$secret_file" + fi + done + sudo chown -R "$docker_install_user":"$docker_install_user" "$authelia_secrets_dir" + checkSuccess "Generated Authelia secrets at $authelia_secrets_dir" + + # Enable Authelia's telemetry/metrics endpoint only when + # CFG_AUTHELIA_MONITORING=true (toggles the libreportal-monitoring + # marker block in configuration.yml). + monitoringToggleAppConfig "$app_name" "config/configuration.yml"; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Configuring Authelia admin account" + echo "" + + local authelia_admin_user="${CFG_AUTHELIA_ADMIN_USERNAME:-admin}" + local authelia_admin_pass="${CFG_AUTHELIA_ADMIN_PASSWORD:-authelia}" + local authelia_users_file="$containers_dir$app_name/config/users_database.yml" + local authelia_attempts=0 + while ((authelia_attempts < 30)); do + if sudo docker exec authelia-service authelia --version >/dev/null 2>&1; then + break + fi + sleep 2 + ((authelia_attempts++)) + done + + if ((authelia_attempts >= 30)); then + isNotice "Authelia container did not become responsive in time — admin left at default (admin / authelia)." + else + local authelia_hash + authelia_hash=$(sudo docker exec authelia-service authelia crypto hash generate argon2 --password "$authelia_admin_pass" 2>/dev/null \ + | grep -oE '\$argon2[^[:space:]]+') + if [[ -z "$authelia_hash" ]]; then + isNotice "Could not generate Authelia password hash — admin left at default (admin / authelia)." + else + sudo tee "$authelia_users_file" >/dev/null < diff --git a/containers/authelia/docker-compose.yml b/containers/authelia/docker-compose.yml new file mode 100755 index 0000000..65a0a8d --- /dev/null +++ b/containers/authelia/docker-compose.yml @@ -0,0 +1,43 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + authelia-service: #LIBREPORTAL|SERVICE_TAG_1|authelia-service + container_name: authelia-service + image: docker.io/authelia/authelia:latest + restart: unless-stopped + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + environment: + - AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/secrets/JWT_SECRET + - AUTHELIA_SESSION_SECRET_FILE=/secrets/SESSION_SECRET + - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/secrets/STORAGE_ENCRYPTION_KEY + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./config:/config + - ./secrets:/secrets + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.authelia-service.entrypoints: web,websecure + traefik.http.routers.authelia-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.authelia-service.tls: true + traefik.http.routers.authelia-service.tls.certresolver: production + traefik.http.services.authelia-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.authelia-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/authelia/resources/configuration.yml b/containers/authelia/resources/configuration.yml new file mode 100644 index 0000000..3510f38 --- /dev/null +++ b/containers/authelia/resources/configuration.yml @@ -0,0 +1,76 @@ +--- +theme: AUTHELIA_THEME_PLACEHOLDER + +default_2fa_method: "" + +server: + address: 'tcp://0.0.0.0:9091/' + buffers: + read: 4096 + write: 4096 + +log: + level: info + +# >>> libreportal-monitoring >>> +#telemetry: +# metrics: +# enabled: true +# address: 'tcp://0.0.0.0:9959/' +# <<< libreportal-monitoring <<< + +totp: + disable: false + issuer: AUTHELIA_DOMAIN_PLACEHOLDER + algorithm: sha1 + digits: 6 + period: 30 + skew: 1 + +authentication_backend: + password_reset: + disable: false + refresh_interval: 5m + file: + path: /config/users_database.yml + watch: false + search: + email: false + case_insensitive: false + password: + algorithm: argon2 + argon2: + variant: argon2id + iterations: 3 + memory: 65536 + parallelism: 4 + key_length: 32 + salt_length: 16 + +access_control: + default_policy: one_factor + +session: + name: authelia_session + expiration: 1h + inactivity: 5m + remember_me: 1M + cookies: + - name: authelia_session + domain: AUTHELIA_DOMAIN_PLACEHOLDER + authelia_url: https://AUTHELIA_HOST_PLACEHOLDER + default_redirection_url: https://AUTHELIA_DOMAIN_PLACEHOLDER + +regulation: + max_retries: 3 + find_time: 2m + ban_time: 5m + +storage: + local: + path: /config/db.sqlite3 + +notifier: + disable_startup_check: true + filesystem: + filename: /config/notification.txt diff --git a/containers/authelia/resources/monitoring/grafana-dashboards/authelia.json b/containers/authelia/resources/monitoring/grafana-dashboards/authelia.json new file mode 100644 index 0000000..f51463a --- /dev/null +++ b/containers/authelia/resources/monitoring/grafana-dashboards/authelia.json @@ -0,0 +1,1211 @@ +{ + "__elements": {}, + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": false, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 8, + "panels": [], + "title": "Requests", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 5, + "x": 0, + "y": 1 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.1.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "count(count by (instance) (authelia_request))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Authelia Instances", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 10, + "x": 5, + "y": 1 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(method, code) (rate(authelia_request{instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{method}}[{{code}}]", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "false" + }, + "properties": [ + { + "id": "displayName", + "value": "failure" + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "true" + }, + "properties": [ + { + "id": "displayName", + "value": "success" + }, + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "shades" + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 9, + "x": 15, + "y": 1 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "code", + "expr": "topk(15, authelia_request_duration_sum{instance=~\"$instance\"}) / authelia_request_duration_count{instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{method}}[{{code}}]", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Top slow requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [], + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 5, + "x": 0, + "y": 4 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": [ + "percent" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(method, code) (rate(authelia_request{instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "{{method}}[{{code}}]", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Http Code", + "type": "piechart" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 4, + "panels": [], + "title": "Authentication", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "{banned=\"false\", success=\"false\"}" + }, + "properties": [ + { + "id": "displayName", + "value": "failure" + }, + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{banned=\"false\", success=\"true\"}" + }, + "properties": [ + { + "id": "displayName", + "value": "success" + }, + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{banned=\"true\", success=\"false\"}" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "shades" + } + }, + { + "id": "displayName", + "value": "banned" + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 11 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": [ + "percent" + ] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(success, banned) (authelia_authn{instance=~\"$instance\"})", + "format": "time_series", + "fullMetaSearch": true, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "First Factor Authentication", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 11 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": [ + "percent" + ] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.4.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(type) (authelia_authn_second_factor{instance=~\"$instance\", success=\"true\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Second Factor Method", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 11 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(code) (rate(authelia_authz{instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "interval": "", + "legendFormat": "{{method}}[{{code}}]", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Authz requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "{banned=\"false\", success=\"false\"}" + }, + "properties": [ + { + "id": "displayName", + "value": "failed" + }, + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{banned=\"false\", success=\"true\"}" + }, + "properties": [ + { + "id": "displayName", + "value": "success" + }, + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{banned=\"true\", success=\"false\"}" + }, + "properties": [ + { + "id": "displayName", + "value": "banned" + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "shades" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 17 + }, + "id": 11, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(success, banned) (rate(authelia_authn{instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Authn requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 17 + }, + "id": 12, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(success, type) (rate(authelia_authn_second_factor{instance=~\"$instance\", banned=\"false\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "[{{type}}] success: {{success}}", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(banned, type) (rate(authelia_authn_second_factor{instance=~\"$instance\", banned=\"true\"}[$__rate_interval]))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "[{{type}}] banned", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "Authn second factor requests", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 25 + }, + "id": 5, + "panels": [], + "title": "OpenID Connect", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 26 + }, + "id": 13, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.4.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(endpoint, code) (rate(authelia_request_duration_openid_connect_count{instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{method}}[{{code}}] on {{endpoint}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "OIDC Requests", + "type": "timeseries" + } + ], + "refresh": "1m", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [ + { + "current": {}, + "hide": 0, + "includeAll": false, + "label": "datasource", + "multi": false, + "name": "DS_PROMETHEUS", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "definition": "label_values(authelia_request,instance)", + "hide": 0, + "includeAll": true, + "label": "instance", + "multi": false, + "name": "instance", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(authelia_request,instance)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Authelia Community Dashboard", + "uid": "ddixu7wrrpuyod", + "version": 25, + "weekStart": "" +} diff --git a/containers/authelia/resources/monitoring/prometheus-scrape.yml b/containers/authelia/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..7d4f549 --- /dev/null +++ b/containers/authelia/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,9 @@ +# Prometheus scrape job for Authelia. Gathered into +# prometheus/scrape.d/authelia.yml by monitoringRefreshPrometheus when +# CFG_AUTHELIA_MONITORING=true. Authelia's telemetry.metrics endpoint listens +# on :9959 inside the container; reachable from the Prometheus container by +# the service name on the shared docker network. +- job_name: authelia + metrics_path: /metrics + static_configs: + - targets: ['authelia-service:9959'] diff --git a/containers/authelia/resources/users_database.yml b/containers/authelia/resources/users_database.yml new file mode 100644 index 0000000..e7c407e --- /dev/null +++ b/containers/authelia/resources/users_database.yml @@ -0,0 +1,11 @@ +--- +users: + admin: + disabled: false + displayname: "Admin User" + # Password: authelia + password: "$argon2id$v=19$m=65536,t=3,p=4$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + email: admin@example.com + groups: + - admins + - dev diff --git a/containers/bookstack/bookstack.config b/containers/bookstack/bookstack.config new file mode 100644 index 0000000..e577ddf --- /dev/null +++ b/containers/bookstack/bookstack.config @@ -0,0 +1,76 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# ADMIN_EMAIL = email used for the Bookstack admin account +# ADMIN_PASSWORD = password used for the Bookstack admin account +# +CFG_BOOKSTACK_APP_NAME=bookstack +CFG_BOOKSTACK_BACKUP=true +CFG_BOOKSTACK_COMPOSE_FILE=default +CFG_BOOKSTACK_HEALTHCHECK=true +CFG_BOOKSTACK_AUTHELIA=false +CFG_BOOKSTACK_HEADSCALE=false +CFG_BOOKSTACK_ADMIN_EMAIL=admin@example.com +CFG_BOOKSTACK_ADMIN_PASSWORD=RANDOMIZEDPASSWORD3 +# Secrets below feed the compose via #LIBREPORTAL|BOOKSTACK__TAG| tags — +# auto-generated, and (unlike a RANDOMIZED* placeholder in the compose) +# preserved across reinstalls. DB_PASSWORD is shared by the app + db services. +CFG_BOOKSTACK_APP_KEY=RANDOMIZEDAPPKEY1 +CFG_BOOKSTACK_DB_PASSWORD=RANDOMIZEDPASSWORD1 +CFG_BOOKSTACK_DB_ROOT_PASSWORD=RANDOMIZEDPASSWORD2 +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_BOOKSTACK_CATEGORY="knowledge" +CFG_BOOKSTACK_TITLE="Bookstack" +CFG_BOOKSTACK_DESCRIPTION="Wiki/Knowledge Base" +CFG_BOOKSTACK_LONG_DESCRIPTION="BookStack is a simple, self-hosted wiki and documentation platform that provides a pleasant and simple way to organize and store information" +CFG_BOOKSTACK_URL="https://github.com/BookStackApp/BookStack" +CFG_BOOKSTACK_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_BOOKSTACK_DOMAIN=1 +CFG_BOOKSTACK_WHITELIST=false +CFG_BOOKSTACK_HOST_NAME=bookstack +CFG_BOOKSTACK_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_BOOKSTACK_PORT_1="bookstack-service|webui|random:80|public|tcp|false|true|true|Web Interface|" + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +CFG_BOOKSTACK_AUTH_PROFILE=multi_user +CFG_BOOKSTACK_ADMIN_USER= diff --git a/containers/bookstack/bookstack.sh b/containers/bookstack/bookstack.sh new file mode 100755 index 0000000..eafd58e --- /dev/null +++ b/containers/bookstack/bookstack.sh @@ -0,0 +1,180 @@ +#!/bin/bash + +# Category : Knowledge Management +# Description : Bookstack - Wiki/Knowledge Base (c/u/s/r/i): + +installBookstack() +{ + local config_variables="$1" + + if [[ "$bookstack" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent bookstack; + local app_name=$CFG_BOOKSTACK_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$bookstack" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$bookstack" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$bookstack" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$bookstack" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$bookstack" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + bookstack_target_email="${CFG_BOOKSTACK_ADMIN_EMAIL:-admin@admin.com}" + bookstack_target_pass="${CFG_BOOKSTACK_ADMIN_PASSWORD:-password}" + + bookstack_compose_file="$containers_dir$app_name/docker-compose.yml" + bookstack_port_pair=$(tagsManagerGetTagContent "$bookstack_compose_file" "PORTS_TAG_1") + bookstack_host_port="${bookstack_port_pair%%:*}" + bookstack_probe_url="http://127.0.0.1:${bookstack_host_port}/login" + + isNotice "Waiting for Bookstack to come online at ${bookstack_probe_url} ..." + isNotice "This may take up to 20 seconds, please wait..." + + bookstack_attempts=0 + bookstack_ready=0 + while ((bookstack_attempts < 60)); do + bookstack_http_code=$(curl -sS -o /dev/null --max-time 3 -w '%{http_code}' "$bookstack_probe_url" 2>/dev/null) + if [[ "$bookstack_http_code" =~ ^(200|302)$ ]]; then + bookstack_ready=1 + break + fi + sleep 2 + ((bookstack_attempts++)) + done + + if ((bookstack_ready == 0)); then + isNotice "Bookstack did not respond on ${bookstack_probe_url} within $((60 * 2))s — admin account left at upstream defaults." + echo "" + isNotice "Bookstack admin login (default):" + echo "" + echo " Email : admin@admin.com" + echo " Password : password" + echo "" + else + isSuccessful "Bookstack is online (HTTP ${bookstack_http_code})." + + bookstack_create_output=$(sudo docker exec \ + -e EZ_BS_NEW_EMAIL="$bookstack_target_email" \ + -e EZ_BS_NEW_PASS="$bookstack_target_pass" \ + bookstack sh -c 'cd /app/www && s6-setuidgid abc php artisan bookstack:create-admin --no-ansi --email="$EZ_BS_NEW_EMAIL" --name=Admin --password="$EZ_BS_NEW_PASS" 2>&1') + bookstack_create_rc=$? + if [[ $bookstack_create_rc -eq 0 ]]; then + isSuccessful "Bookstack admin account created (email: $bookstack_target_email)." + + if [[ "$bookstack_target_email" != "admin@admin.com" ]]; then + sudo docker exec -i bookstack php /app/www/artisan tinker --no-ansi >/dev/null 2>&1 <<'PHP' +$c = class_exists('\BookStack\Users\Models\User') ? '\BookStack\Users\Models\User' : '\BookStack\Auth\User'; +optional($c::where('email', 'admin@admin.com')->first())->delete(); +PHP + isSuccessful "Removed seeded admin@admin.com account." + fi + + echo "" + isNotice "Bookstack admin login:" + echo "" + echo " Email : ${bookstack_target_email}" + echo " Password : ${bookstack_target_pass}" + echo "" + else + isNotice "Bookstack admin auto-create failed (exit $bookstack_create_rc). Output:" + echo "$bookstack_create_output" | sed 's/^/ /' + echo "" + isNotice "Falling back to upstream defaults — update from inside Bookstack." + echo "" + isNotice "Bookstack admin login (default):" + echo "" + echo " Email : admin@admin.com" + echo " Password : password" + echo "" + fi + fi + + menu_number=0 + #sleep 3s + cd + fi + bookstack=n +} diff --git a/containers/bookstack/bookstack.svg b/containers/bookstack/bookstack.svg new file mode 100755 index 0000000..a6ad581 --- /dev/null +++ b/containers/bookstack/bookstack.svg @@ -0,0 +1 @@ + diff --git a/containers/bookstack/docker-compose.yml b/containers/bookstack/docker-compose.yml new file mode 100755 index 0000000..ebeafa5 --- /dev/null +++ b/containers/bookstack/docker-compose.yml @@ -0,0 +1,68 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + bookstack-service: #LIBREPORTAL|SERVICE_TAG_1|bookstack-service + image: lscr.io/linuxserver/bookstack + container_name: bookstack + environment: + - PUID=1000 + - PGID=1000 + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - APP_URL=APP_URL_DATA #LIBREPORTAL|APP_URL_TAG|APP_URL_DATA + - APP_KEY=BOOKSTACK_APP_KEY_DATA #LIBREPORTAL|BOOKSTACK_APP_KEY_TAG|BOOKSTACK_APP_KEY_DATA + - DB_HOST=bookstack_db + - DB_PORT=3306 + - DB_USERNAME=bookstack + - DB_PASSWORD=BOOKSTACK_DB_PASSWORD_DATA #LIBREPORTAL|BOOKSTACK_DB_PASSWORD_TAG|BOOKSTACK_DB_PASSWORD_DATA + - DB_DATABASE=bookstackapp + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./data:/config + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + restart: unless-stopped + depends_on: + - bookstack_db + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.bookstack-service.entrypoints: web,websecure + traefik.http.routers.bookstack-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.bookstack-service.tls: true + traefik.http.routers.bookstack-service.tls.certresolver: production + traefik.http.services.bookstack-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.bookstack-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + + bookstack_db: #LIBREPORTAL|SERVICE_TAG_2|bookstack_db + image: lscr.io/linuxserver/mariadb + container_name: bookstack_db + environment: + - PUID=1000 + - PGID=1000 + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - MYSQL_ROOT_PASSWORD=BOOKSTACK_DB_ROOT_PASSWORD_DATA #LIBREPORTAL|BOOKSTACK_DB_ROOT_PASSWORD_TAG|BOOKSTACK_DB_ROOT_PASSWORD_DATA + - MYSQL_DATABASE=bookstackapp + - MYSQL_USER=bookstack + - MYSQL_PASSWORD=BOOKSTACK_DB_PASSWORD_DATA #LIBREPORTAL|BOOKSTACK_DB_PASSWORD_TAG|BOOKSTACK_DB_PASSWORD_DATA + volumes: + - ./db:/config + restart: unless-stopped + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 diff --git a/containers/crowdsec/crowdsec.config b/containers/crowdsec/crowdsec.config new file mode 100644 index 0000000..7146041 --- /dev/null +++ b/containers/crowdsec/crowdsec.config @@ -0,0 +1,68 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# HOST_INSTALL = true means apt + systemd install on the host, not Docker +# HOST_PACKAGE = dpkg package name; drives the "installed" badge +# HOST_SERVICE = primary systemd unit; stop/restart actions hit this +# HOST_SERVICES = all units; feeds the Services + Logs tabs +# HOST_LOG_FILES = |,... mapping for the log viewer +# BACKUP = include in backup operations +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed; ships the official CrowdSec Grafana dashboards) +# PROMETHEUS_LISTEN = address CrowdSec's metrics endpoint binds to; must be reachable from the Prometheus container (default: all interfaces, port 6060 — keep the :6060 port) +# +CFG_CROWDSEC_APP_NAME=crowdsec +CFG_CROWDSEC_HOST_INSTALL=true +CFG_CROWDSEC_HOST_PACKAGE=crowdsec +CFG_CROWDSEC_HOST_SERVICE=crowdsec +CFG_CROWDSEC_HOST_SERVICES=crowdsec.service,crowdsec-firewall-bouncer.service +CFG_CROWDSEC_HOST_LOG_FILES="crowdsec.service|/var/log/crowdsec.log,crowdsec-firewall-bouncer.service|/var/log/crowdsec-firewall-bouncer.log" +CFG_CROWDSEC_BACKUP=true +CFG_CROWDSEC_MONITORING=false +CFG_CROWDSEC_PROMETHEUS_LISTEN=0.0.0.0:6060 +# +# ============================================================================= +# BEHAVIOUR +# ============================================================================= +# ENABLED = master switch; false disables services (package stays) +# AUTO_UPDATE = pull hub parser/scenario updates from hub.crowdsec.net +# COMMUNITY_BLOCKLIST = subscribe to the free pooled blocklist (CAPI) +# CONSOLE_ENROLL = enroll this agent with the hosted SaaS at app.crowdsec.net (NOT the local dashboard) +# CONSOLE_TOKEN = enrollment token from app.crowdsec.net (only used when CONSOLE_ENROLL=true) +# BOUNCER = attach the Traefik bouncer middleware to every public route +# +CFG_CROWDSEC_ENABLED=true +CFG_CROWDSEC_AUTO_UPDATE=true +CFG_CROWDSEC_COMMUNITY_BLOCKLIST=true +CFG_CROWDSEC_CONSOLE_ENROLL=false +CFG_CROWDSEC_CONSOLE_TOKEN= +CFG_CROWDSEC_BOUNCER=true +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = grouping in the app grid +# TITLE = display name +# DESCRIPTION = one-liner +# LONG_DESCRIPTION = card body text +# URL = source / docs link +# ACTIONS = available lifecycle verbs +# +CFG_CROWDSEC_CATEGORY="security,recommended" +CFG_CROWDSEC_TITLE="CrowdSec" +CFG_CROWDSEC_DESCRIPTION="Intrusion Prevention" +CFG_CROWDSEC_LONG_DESCRIPTION="CrowdSec is an open-source intrusion prevention system. It detects attacks from log patterns — brute-force, scans, web exploits — and blocks offending IPs at the firewall. Includes community-shared threat intelligence." +CFG_CROWDSEC_URL="https://www.crowdsec.net" +CFG_CROWDSEC_ACTIONS="configure|install|restart|shutdown|uninstall|tools" +# +# ============================================================================= +# ADVANCED +# ============================================================================= +# LAPI_HOST = LAPI bind address; 0.0.0.0 so Traefik can reach via host.docker.internal +# BOUNCER_NAME_TRAEFIK = bouncer name registered with cscli bouncers add +# TRAEFIK_LAPI_KEY = auto-generated by installCrowdsec; use the rotate Tools action to change +# +CFG_CROWDSEC_LAPI_HOST=0.0.0.0:8080 +CFG_CROWDSEC_BOUNCER_NAME_TRAEFIK=traefik-bouncer +CFG_CROWDSEC_TRAEFIK_LAPI_KEY= diff --git a/containers/crowdsec/crowdsec.sh b/containers/crowdsec/crowdsec.sh new file mode 100644 index 0000000..7b0cd38 --- /dev/null +++ b/containers/crowdsec/crowdsec.sh @@ -0,0 +1,100 @@ +#!/bin/bash + +# Category : Security +# Description : CrowdSec - Intrusion Prevention (c/u/s/r/i): +# +# Host-installed agent (apt + systemd) — no Docker container. Host install +# logic lives in scripts/install/install_crowdsec.sh (installCrowdsecHost); +# install registration uses the shared hostAppInstall helper +# (scripts/install/host_app.sh). uninstall/stop/restartCrowdsec (below) are the +# host-side hooks dockerUninstallApp / dockerStopApp / dockerRestartApp invoke. + +installCrowdsec() +{ + local config_variables="$1" + + if [[ "$crowdsec" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent crowdsec; + initializeAppVariables "$CFG_CROWDSEC_APP_NAME"; + fi + local app_name=$CFG_CROWDSEC_APP_NAME + + if [[ "$crowdsec" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + # Uninstall / stop / restart are NOT dispatched here — the CLI and menu call + # dockerUninstallApp / dockerStopApp / dockerRestartApp directly. Those run + # the generic docker teardown (a no-op for a host app) and then invoke the + # uninstall/stop/restartCrowdsec hooks (bottom of this file) for the + # host-side work. + + if [[ "$crowdsec" == *[iI]* ]]; then + installCrowdsecHost; + + if command -v cscli >/dev/null 2>&1; then + # Register crowdsec as an installed host app — apps DB row + WebUI regen. + hostAppInstall "$app_name"; + + # Monitoring: gather crowdsec's scrape fragment + Grafana dashboards + # into Prometheus/Grafana. Run unconditionally — the refresh is + # self-correcting (adds when CFG_CROWDSEC_MONITORING=true, removes + # crowdsec's entry when it's been toggled off). No-ops with a notice + # when Prometheus/Grafana aren't installed. + monitoringRefreshAll; + else + isNotice "cscli missing — crowdsec host install did not complete. Skipping registration." + fi + fi +} + +# Host-side uninstall, invoked by dockerUninstallApp's uninstall hook. +# dockerUninstallApp already handles the generic teardown (data dir, DB rows, +# WebUI regen) — this does what the generic path can't: stopping + purging the +# apt packages and detaching the log bind-mounts. +uninstallCrowdsec() +{ + ((menu_number++)) + echo "" + echo "---- $menu_number. Stopping CrowdSec host services." + echo "" + local result=$(sudo systemctl disable --now crowdsec-firewall-bouncer 2>&1) + checkSuccess "Disabling firewall bouncer" + local result=$(sudo systemctl disable --now crowdsec 2>&1) + checkSuccess "Disabling agent" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Removing CrowdSec packages." + echo "" + local result=$(sudo DEBIAN_FRONTEND=noninteractive apt-get purge -y -q crowdsec crowdsec-firewall-bouncer-nftables &1) + checkSuccess "Purged packages" + local result=$(sudo DEBIAN_FRONTEND=noninteractive apt-get autoremove -y -q &1) + checkSuccess "Removed orphaned dependencies" + + crowdsecToggleLibrePortalLogMounts off +} + +# Host-side stop, invoked by dockerStopApp's stop hook. crowdsec ships no +# docker container, so dockerStopApp is a no-op — this stops the host agent + +# bouncer. The package stays installed; only Uninstall removes it. +stopCrowdsec() +{ + isNotice "Stopping CrowdSec host services..." + local result=$(sudo systemctl stop crowdsec-firewall-bouncer 2>&1) + checkSuccess "Stopped firewall bouncer" + local result=$(sudo systemctl stop crowdsec 2>&1) + checkSuccess "Stopped agent" +} + +# Host-side restart, invoked by dockerRestartApp's restart hook. crowdsec +# ships no docker container, so dockerRestartApp is a no-op — this restarts the +# host agent + bouncer. +restartCrowdsec() +{ + isNotice "Restarting CrowdSec host services..." + local result=$(sudo systemctl restart crowdsec 2>&1) + checkSuccess "Restarted agent" + local result=$(sudo systemctl restart crowdsec-firewall-bouncer 2>&1) + checkSuccess "Restarted firewall bouncer" +} diff --git a/containers/crowdsec/crowdsec.svg b/containers/crowdsec/crowdsec.svg new file mode 100644 index 0000000..fd4ffac --- /dev/null +++ b/containers/crowdsec/crowdsec.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/crowdsec/resources/monitoring/grafana-dashboards/details-per-machine.json b/containers/crowdsec/resources/monitoring/grafana-dashboards/details-per-machine.json new file mode 100644 index 0000000..a0bd2e8 --- /dev/null +++ b/containers/crowdsec/resources/monitoring/grafana-dashboards/details-per-machine.json @@ -0,0 +1,1520 @@ +{ + "__elements": [], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "iteration": 1659090580950, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": true, + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 22, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 1 + }, + "hiddenSeries": false, + "id": 18, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "process_resident_memory_bytes{instance=\"$instance\"}", + "interval": "", + "intervalFactor": 1, + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Process Mem Usage", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "decbytes", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 1 + }, + "hiddenSeries": false, + "id": 20, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "rate(process_cpu_seconds_total{instance=\"$instance\"}[$__interval])*100", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Process CPU Usage", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "dateTimeAsIso" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 0, + "y": 12 + }, + "id": 16, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "9.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "(process_start_time_seconds{instance=\"$instance\"})*1000", + "instant": false, + "interval": "", + "intervalFactor": 1, + "legendFormat": "", + "refId": "A" + } + ], + "title": "Up Since", + "transparent": true, + "type": "stat" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "refId": "A" + } + ], + "title": "System", + "type": "row" + }, + { + "collapsed": true, + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 1 + }, + "id": 28, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 2 + }, + "id": 32, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "9.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "sum(cs_alerts)", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Alerts Count", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 2 + }, + "id": 30, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "cs_alerts{instance=\"$instance\"}", + "interval": "", + "legendFormat": "{{reason}}", + "refId": "A" + } + ], + "title": "Alerts per Scenario", + "type": "timeseries" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "refId": "A" + } + ], + "title": "Alerts", + "type": "row" + }, + { + "collapsed": true, + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 2 + }, + "id": 24, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 3 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "sum(increase(cs_filesource_hits_total{instance=\"$instance\"}[$__interval])) by (source)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{source}}", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "sum(increase(cs_journalctlsource_hits_total{instance=\"$instance\"}[$__interval])) by (source)", + "hide": false, + "interval": "", + "legendFormat": "{{source}}", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "sum(increase(cs_cloudwatch_stream_hits_total{instance=\"$instance\"}[$__interval])) by (group, stream)", + "hide": false, + "interval": "", + "legendFormat": "{{group}} - {{stream}}", + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "exemplar": true, + "expr": "sum(increase(cs_syslogsource_hits_total{instance=\"$instance\"}[$__interval])) by (source)", + "hide": false, + "interval": "", + "legendFormat": "", + "refId": "D" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Acquisition", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 15 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(increase(cs_parser_hits_ok_total{instance=\"$instance\"}[$__interval])) by (source)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{source}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Parsed lines", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 15 + }, + "hiddenSeries": false, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(increase(cs_parser_hits_ko_total{instance=\"$instance\"}[$__interval])) by (source)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{source}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Unparsed lines", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 34, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true + }, + "pluginVersion": "9.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(cs_parsing_time_seconds_bucket{instance=\"$instance\", type=\"$datasource_type\", source=\"$source\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "legendFormat": "{{le}}", + "range": true, + "refId": "A" + } + ], + "title": "Parsing time", + "type": "heatmap" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "refId": "A" + } + ], + "title": "Parsing", + "type": "row" + }, + { + "collapsed": true, + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 3 + }, + "id": 26, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 4 + }, + "hiddenSeries": false, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(cs_buckets{instance=\"$instance\"}) by (name)", + "interval": "", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Bucket Timeline", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 12 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(increase(cs_bucket_created_total{instance=\"$instance\"}[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Buckets creation", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 21 + }, + "hiddenSeries": false, + "id": 11, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(increase(cs_bucket_overflowed_total{instance=\"$instance\"}[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Buckets overflow", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 29 + }, + "hiddenSeries": false, + "id": 14, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "9.0.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(increase(cs_bucket_underflowed_total{instance=\"$instance\"}[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Buckets underflow", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 0, + "y": 37 + }, + "id": 35, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true + }, + "pluginVersion": "9.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(cs_bucket_pour_seconds_bucket{instance=\"$instance\", type=\"$datasource_type\", source=\"$source\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "legendFormat": "{{le}}", + "range": true, + "refId": "A" + } + ], + "title": "Bucket pour time", + "type": "heatmap" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "IH0jqv6nz" + }, + "refId": "A" + } + ], + "title": "Buckets", + "type": "row" + } + ], + "refresh": false, + "schemaVersion": 36, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "definition": "label_values(cs_info, instance)", + "hide": 0, + "includeAll": false, + "label": "instance", + "multi": false, + "name": "instance", + "options": [], + "query": { + "query": "label_values(cs_info, instance)", + "refId": "Prometheus-instance-Variable-Query" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "definition": "label_values(cs_parsing_time_seconds_bucket{instance=\"$instance\"}, type)", + "hide": 0, + "includeAll": false, + "multi": false, + "name": "datasource_type", + "options": [], + "query": { + "query": "label_values(cs_parsing_time_seconds_bucket{instance=\"$instance\"}, type)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "definition": "label_values(cs_parsing_time_seconds_bucket{instance=\"$instance\", type=\"$datasource_type\"}, source)", + "hide": 0, + "includeAll": false, + "multi": false, + "name": "source", + "options": [], + "query": { + "query": "label_values(cs_parsing_time_seconds_bucket{instance=\"$instance\", type=\"$datasource_type\"}, source)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Crowdsec Details per instance", + "uid": "6L2GdB47z", + "version": 7, + "weekStart": "" +} diff --git a/containers/crowdsec/resources/monitoring/grafana-dashboards/insight.json b/containers/crowdsec/resources/monitoring/grafana-dashboards/insight.json new file mode 100644 index 0000000..c73423e --- /dev/null +++ b/containers/crowdsec/resources/monitoring/grafana-dashboards/insight.json @@ -0,0 +1,811 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1655915159751, + "links": [], + "panels": [ + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 22, + "panels": [ + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "dateTimeAsIso" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 2, + "y": 1 + }, + "id": 2, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "(process_start_time_seconds{instance=\"$instance\"})*1000", + "interval": "", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Up since", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "displayName": "", + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 7, + "y": 1 + }, + "id": 4, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "process_resident_memory_bytes{instance=\"$instance\"}", + "format": "time_series", + "interval": "", + "intervalFactor": 3, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Average Mem Usage", + "transparent": true, + "type": "gauge" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 12, + "y": 1 + }, + "id": 6, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "rate(process_cpu_seconds_total{instance=\"$instance\"}[$__interval])*100", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Average CPU Usage", + "transparent": true, + "type": "gauge" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 17, + "y": 1 + }, + "id": 20, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "process_max_fds{instance=\"$instance\"}", + "interval": "", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Process Max FDs", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 2, + "y": 10 + }, + "id": 11, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(increase(cs_filesource_hits_total{instance=\"$instance\"}[$__interval]) or up * 0) + sum(increase(cs_cloudwatch_stream_hits_total{instance=\"$instance\"}[$__interval]) or up * 0) + sum(increase(cs_journalctlsource_hits_total{instance=\"$instance\"}[$__interval]) or up * 0) + sum(increase(cs_syslogsource_hits_total{instance=\"$instance\"}[$__interval]) or up * 0)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Total raw lines", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 7, + "y": 10 + }, + "id": 12, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(increase(cs_parser_hits_ok_total{instance=\"$instance\"}[$__interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Total parsed lines", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 12, + "y": 10 + }, + "id": 8, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(increase(cs_bucket_overflowed_total{instance=\"$instance\"}[$__interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Total Overflows", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 17, + "y": 10 + }, + "id": 18, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "process_open_fds{instance=\"$instance\"}", + "interval": "", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Process Open FDs", + "type": "stat" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 4, + "y": 19 + }, + "id": 14, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "expr": "sum(increase(cs_parser_hits_ko_total{instance=\"$instance\"}[$__interval]))", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Total unparsed lines", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 10, + "x": 9, + "y": 19 + }, + "id": 16, + "options": { + "displayMode": "gradient", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "repeat": null, + "repeatDirection": "v", + "targets": [ + { + "exemplar": true, + "expr": "sum(increase(cs_bucket_overflowed_total{instance=\"$instance\"}[$__interval])) by (name)", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Top scenarios", + "transparent": true, + "type": "bargauge" + } + ], + "repeat": "instance", + "title": "$instance", + "type": "row" + } + ], + "schemaVersion": 30, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "definition": "label_values(cs_info, instance)", + "description": null, + "error": null, + "hide": 0, + "includeAll": true, + "label": "instance", + "multi": true, + "name": "instance", + "options": [], + "query": { + "query": "label_values(cs_info, instance)", + "refId": "Prometheus-instance-Variable-Query" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Crowdsec Insight", + "uid": "e7sWOBVnk", + "version": 8 +} diff --git a/containers/crowdsec/resources/monitoring/grafana-dashboards/lapi-metrics.json b/containers/crowdsec/resources/monitoring/grafana-dashboards/lapi-metrics.json new file mode 100644 index 0000000..81a33c0 --- /dev/null +++ b/containers/crowdsec/resources/monitoring/grafana-dashboards/lapi-metrics.json @@ -0,0 +1,515 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1655915193937, + "links": [], + "panels": [ + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 10, + "panels": [], + "title": "Agents", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 2, + "options": { + "displayMode": "gradient", + "orientation": "vertical", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "repeat": "query0", + "repeatDirection": "h", + "targets": [ + { + "exemplar": false, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint=\"/v1/watchers/login\", instance=\"$lapi\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Agents Login", + "type": "heatmap" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 6, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint=\"/v1/watchers/login\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Heartbeat", + "type": "heatmap" + }, + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 12, + "panels": [], + "title": "Decisions", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 4, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint= \"/v1/decisions\", instance=\"$lapi\", method=~\"(GET)|(HEAD)\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Decisions GET (live)", + "type": "heatmap" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 13, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint= \"/v1/decisions/stream\", instance=\"$lapi\", method=~\"(GET)|(HEAD)\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Decisions GET (stream)", + "type": "heatmap" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 8, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint=~\"/v1/decisions.*\", instance=\"$lapi\", method=\"DELETE\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Decisions DELETE", + "type": "heatmap" + }, + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 26 + }, + "id": 15, + "panels": [], + "title": "Alerts", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 27 + }, + "id": 17, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint=\"/v1/alerts\",instance=\"$lapi\",method=\"POST\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Alerts POST", + "type": "heatmap" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 18, + "options": { + "displayMode": "gradient", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "text": {} + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(rate(cs_lapi_request_duration_seconds_bucket{endpoint=\"/v1/alerts\",instance=\"$lapi\",method=~\"(GET)|(HEAD)\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "interval": "", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Alerts GET", + "type": "heatmap" + } + ], + "schemaVersion": 30, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "Prometheus", + "definition": "label_values(cs_info, instance)", + "description": null, + "error": null, + "hide": 0, + "includeAll": false, + "label": null, + "multi": false, + "name": "lapi", + "options": [], + "query": { + "query": "label_values(cs_info, instance)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "LAPI Metrics", + "uid": "ofdKJG37k", + "version": 11 +} diff --git a/containers/crowdsec/resources/monitoring/grafana-dashboards/overview.json b/containers/crowdsec/resources/monitoring/grafana-dashboards/overview.json new file mode 100644 index 0000000..bdef3ab --- /dev/null +++ b/containers/crowdsec/resources/monitoring/grafana-dashboards/overview.json @@ -0,0 +1,1321 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 24, + "panels": [], + "title": "Summary", + "type": "row" + }, + { + "cacheTimeout": null, + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "match": "null", + "result": { + "text": "N/A" + } + }, + "type": "special" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#E02F44", + "value": null + }, + { + "color": "#E02F44", + "value": 10 + }, + { + "color": "#299c46", + "value": 10 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 2, + "interval": null, + "links": [], + "maxDataPoints": 100, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "count(cs_info)", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Running Crowdsec", + "transparent": true, + "type": "stat" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 18, + "x": 6, + "y": 1 + }, + "hiddenSeries": false, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum(increase(cs_filesource_hits_total[$__interval])) by (instance)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{instance}}", + "refId": "A" + }, + { + "exemplar": true, + "expr": "sum(increase(cs_journalctlsource_hits_total[$__interval])) by (instance)", + "hide": false, + "interval": "", + "legendFormat": "{{instance}}", + "refId": "B" + }, + { + "exemplar": true, + "expr": "sum(increase(cs_cloudwatch_stream_hits_total[$__interval])) by (instance)", + "hide": false, + "interval": "", + "legendFormat": "{{instance}}", + "refId": "C" + }, + { + "exemplar": true, + "expr": "sum(increase(cs_syslogsource_hits_total[$__interval])) by (instance)", + "hide": false, + "interval": "", + "legendFormat": "{{instance}}", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Acquisitions", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "hiddenSeries": false, + "id": 10, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_parser_hits_total[$__interval])) by (instance)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Parsers", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_bucket_overflowed_total[$__interval])) by (instance)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Buckets overflow", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 17 + }, + "id": 30, + "panels": [], + "title": "Alerts", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 36, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(cs_active_decisions)", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Decisions Count", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 12, + "x": 12, + "y": 18 + }, + "id": 38, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.1.2", + "targets": [ + { + "exemplar": true, + "expr": "sum(cs_alerts)", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Alerts Count", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 32, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "exemplar": true, + "expr": "sum(cs_active_decisions) by (reason)", + "interval": "", + "legendFormat": "{{reason}}", + "refId": "A" + } + ], + "title": "Decisions by scenario", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 33 + }, + "id": 34, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "exemplar": true, + "expr": "sum(cs_active_decisions) by (action)", + "interval": "", + "legendFormat": "{{action}}", + "refId": "A" + } + ], + "title": "Decisions By Type", + "type": "timeseries" + }, + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 26, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 3 + }, + "hiddenSeries": false, + "id": 4, + "interval": "", + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_node_hits_ok_total[$__interval])) by (name)", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Parsers ok", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 3 + }, + "hiddenSeries": false, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sideWidth": null, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_node_hits_ko_total[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Parsers nok", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "title": "Parsers", + "type": "row" + }, + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 43 + }, + "id": 28, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 3 + }, + "hiddenSeries": false, + "id": 18, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_bucket_created_total[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Buckets created", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 3 + }, + "hiddenSeries": false, + "id": 20, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(increase(cs_bucket_overflowed_total[$__interval])) by (name)", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Buckets overflow", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 1, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 12 + }, + "hiddenSeries": false, + "id": 22, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "total", + "sortDesc": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.1.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(cs_buckets) by (name)", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Buckets Timeline", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "title": "Buckets", + "type": "row" + } + ], + "refresh": false, + "schemaVersion": 30, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Crowdsec Overview", + "uid": "hjmZdB4nk", + "version": 7 +} diff --git a/containers/crowdsec/resources/monitoring/prometheus-scrape.yml b/containers/crowdsec/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..094d6c4 --- /dev/null +++ b/containers/crowdsec/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,9 @@ +# Prometheus scrape job for the CrowdSec host agent. Gathered into +# prometheus/scrape.d/crowdsec.yml by monitoringRefreshPrometheus when +# CFG_CROWDSEC_MONITORING=true. CrowdSec runs on the host, so the target is +# host.docker.internal (the Prometheus container has the host-gateway alias); +# installCrowdsecHost binds CrowdSec's metrics endpoint to a reachable address. +- job_name: crowdsec + metrics_path: /metrics + static_configs: + - targets: ['host.docker.internal:6060'] diff --git a/containers/dashy/dashy.config b/containers/dashy/dashy.config new file mode 100755 index 0000000..235942b --- /dev/null +++ b/containers/dashy/dashy.config @@ -0,0 +1,85 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_DASHY_APP_NAME=dashy +CFG_DASHY_BACKUP=true +CFG_DASHY_COMPOSE_FILE=default +CFG_DASHY_HEALTHCHECK=true +CFG_DASHY_AUTHELIA=false +CFG_DASHY_HEADSCALE=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_DASHY_CATEGORY="productivity" +CFG_DASHY_TITLE="Dashy" +CFG_DASHY_DESCRIPTION="Dashboard Tool" +CFG_DASHY_LONG_DESCRIPTION="Dashy is a self-hostable personal dashboard for monitoring your self-hosted services and applications with a beautiful, intuitive interface" +CFG_DASHY_URL="https://github.com/Lissy93/dashy" +CFG_DASHY_ACTIONS="configure|install|restart|shutdown|uninstall|tools" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_DASHY_DOMAIN=1 +CFG_DASHY_WHITELIST=false +CFG_DASHY_HOST_NAME=dashy +CFG_DASHY_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_DASHY_PORT_1="dashy-service|webui|random:8080|private|tcp|false|false|true|Dashboard|" +# Comma-separated list of installed app slugs to surface as shortcuts on +# the dashy dashboard. Managed via the Tools tab → "Manage Shortcuts". +# Empty = no app shortcuts (only the static page header survives). +CFG_DASHY_SHORTCUTS= +# +# ============================================================================= +# DASHBOARD CUSTOMIZATION +# ============================================================================= +# THEME = built-in dashy theme name (Nord-Frost, Dracula, Cyberpunk, ...) +# PAGE_TITLE = page header title (blank = "Dashy - LibrePortal - ") +# PAGE_DESCRIPTION = page header subtitle (blank = default welcome line) +# LAYOUT = item layout: auto / horizontal / vertical +# ICON_SIZE = tile icon size: small / medium / large +# OPEN_TARGET = where tile clicks open: newtab / sametab / modal / workspace +# STATUS_CHECK = if true, dashy pings each tile and shows up/down status +# +CFG_DASHY_THEME="Nord-Frost" +CFG_DASHY_PAGE_TITLE= +CFG_DASHY_PAGE_DESCRIPTION= +CFG_DASHY_LAYOUT="auto" +CFG_DASHY_ICON_SIZE="medium" +CFG_DASHY_OPEN_TARGET="newtab" +CFG_DASHY_STATUS_CHECK=false diff --git a/containers/dashy/dashy.sh b/containers/dashy/dashy.sh new file mode 100755 index 0000000..ba9eef8 --- /dev/null +++ b/containers/dashy/dashy.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +# Category : Miscellaneous +# Description : Dashy - Dashboard Tool (c/t/u/s/r/i): + +installDashy() +{ + local config_variables="$1" + + if [[ "$dashy" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent dashy; + local app_name=$CFG_DASHY_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$dashy" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$dashy" == *[tT]* ]]; then + dashyToolsMenu; + fi + + if [[ "$dashy" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$dashy" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$dashy" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$dashy" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your new service using one of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + dashy=n +} diff --git a/containers/dashy/dashy.svg b/containers/dashy/dashy.svg new file mode 100755 index 0000000..ce68744 --- /dev/null +++ b/containers/dashy/dashy.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/dashy/docker-compose.yml b/containers/dashy/docker-compose.yml new file mode 100755 index 0000000..a67aff2 --- /dev/null +++ b/containers/dashy/docker-compose.yml @@ -0,0 +1,45 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + dashy-service: #LIBREPORTAL|SERVICE_TAG_1|dashy-service + image: lissy93/dashy + container_name: dashy + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./etc:/app/user-data + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + environment: + - NODE_ENV=production + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + restart: unless-stopped + healthcheck: + test: ['CMD', 'node', '/app/services/healthcheck'] + interval: 1m30s + timeout: 10s + retries: 3 + start_period: 40s + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.dashy-service.entrypoints: web,websecure + traefik.http.routers.dashy-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.dashy-service.tls: true + traefik.http.routers.dashy-service.tls.certresolver: production + traefik.http.services.dashy-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.dashy-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/dashy/resources/conf.yml b/containers/dashy/resources/conf.yml new file mode 100755 index 0000000..5554b67 --- /dev/null +++ b/containers/dashy/resources/conf.yml @@ -0,0 +1,232 @@ +--- +# Page meta info, like heading, footer text and nav links +pageInfo: + title: INSTALLNAMEHERE - LibrePortal Dashy + description: Welcome to your LibrePortal Dashy dashboard! + navLinks: + - title: Dashy GitHub + path: http://github.com/Lissy93/dashy + - title: Dashy Documentation + path: http://dashy.to/docs + +# Optional app settings and configuration +appConfig: + theme: Nord-Frost + +# Main content - An array of sections, each containing an array of items +sections: + +#### category system +#- name: System Applications +# icon: fas fa-rocket +# items: + + #### app traefik + #- title: Traefik + # description: For VPN connections + # icon: si-traefikproxy + # url: http://APPADDRESSHERE/ + # statusCheck: false + # target: newtab + + #### app adguard + #- title: Adguard + # description: DNS Server with AdBlocking DNS-over-TLS + # icon: hl-adblock + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app pihole + #- title: Pi-Hole + # description: DNS Server with AdBlocking and Unbound + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app portainer + #- title: Portainer + # description: Docker service management panel + # icon: hl-portainer + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app authelia + #- title: Authelia + # description: Single Sign-On Platform + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app grafana + #- title: Grafana + # description: Metrics Visualizer + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app prometheus + #- title: Prometheus + # description: Metrics Collector + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app headscale + #- title: Headscale + # description: VPN Networking + # icon: si-traefikmesh + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app duplicati + #- title: Duplicati + # description: Docker backup system + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + +#### category privacy +#- name: Privacy Apps +# icon: fas fa-rocket +# items: + + #### app mailcow + #- title: Mail Cow + # description: Mail Server + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app vaultwarden + #- title: VaultWarden + # description: Password Manager + # icon: hl-bitwarden + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app trillium + #- title: Trilium + # description: Note Manager + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app invidious + #- title: Invidious + # description: Alternative YouTube Frontend + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app searxng + #- title: SearXNG + # description: Privacy based Search Engine + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app speedtest + #- title: SpeedTest + # description: Used for testing the speed of your internet + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app firefly + #- title: FireFly iii + # description: Money Budgetting Platform + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app ipinfo + #- title: IP Info + # description: Shows your current IP Address + # icon: hl-docker + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + +#### category user +#- name: User Apps +# icon: fas fa-rocket +# items: + + #### app mattermost + #- title: Mattermost + # description: Project Management & Chat Platform + # icon: hl-mattermost + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app jitsimeet + #- title: Jitsi Meet + # description: Video Conferencing Platform (Zoom alternative) + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app owncloud + #- title: OwnCloud + # description: File & Document Hosting + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app onlyoffice + #- title: OnlyOffice + # description: Document Collaboration Connector + # icon: hl-onlyoffice + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app kimai + #- title: Kimai + # description: Time Management Client Platform + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app killbill + #- title: Kill Bill + # description: Payment Processing Platform + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app rustdesk + #- title: RustDesk + # description: Remote Desktop Server + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab + + #### app tiledesk + #- title: TileDesk + # description: Live Chat Platform + # icon: favicon-local + # url: http://APPADDRESSHERE/ + # statusCheck: true + # target: newtab \ No newline at end of file diff --git a/containers/focalboard/docker-compose.yml b/containers/focalboard/docker-compose.yml new file mode 100755 index 0000000..e3c4152 --- /dev/null +++ b/containers/focalboard/docker-compose.yml @@ -0,0 +1,44 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + focalboard-service: #LIBREPORTAL|SERVICE_TAG_1|focalboard-service + image: mattermost/focalboard + container_name: focalboard-service + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + volumes: + - ./data:/data + environment: + - VIRTUAL_HOST:DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - VIRTUAL_PORT:8000 + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + restart: unless-stopped + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.focalboard-service.entrypoints: web,websecure + traefik.http.routers.focalboard-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.focalboard-service.tls: true + traefik.http.routers.focalboard-service.tls.certresolver: production + traefik.http.services.focalboard-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.focalboard-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + logging: + driver: "json-file" + options: + max-size: "1m" diff --git a/containers/focalboard/focalboard.config b/containers/focalboard/focalboard.config new file mode 100755 index 0000000..21c4388 --- /dev/null +++ b/containers/focalboard/focalboard.config @@ -0,0 +1,68 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_FOCALBOARD_APP_NAME=focalboard +CFG_FOCALBOARD_BACKUP=true +CFG_FOCALBOARD_COMPOSE_FILE=default +CFG_FOCALBOARD_HEALTHCHECK=true +CFG_FOCALBOARD_AUTHELIA=false +CFG_FOCALBOARD_HEADSCALE=false + +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_FOCALBOARD_CATEGORY="productivity" +CFG_FOCALBOARD_TITLE="Focalboard" +CFG_FOCALBOARD_DESCRIPTION="Project Management" +CFG_FOCALBOARD_LONG_DESCRIPTION="Focalboard is an open source, self-hosted alternative to Trello, Notion, and Asana that helps organize projects and tasks" +CFG_FOCALBOARD_URL="https://github.com/mattermost/focalboard" +CFG_FOCALBOARD_ACTIONS="configure|install|restart|shutdown|uninstall" + +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_FOCALBOARD_DOMAIN=1 +CFG_FOCALBOARD_WHITELIST=false +CFG_FOCALBOARD_HOST_NAME=board +CFG_FOCALBOARD_NETWORK=default + +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_FOCALBOARD_PORT_1="focalboard-service|webui|random:8000|public|tcp|false|true|true|Web Interface|" + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +CFG_FOCALBOARD_AUTH_PROFILE=multi_user +CFG_FOCALBOARD_ADMIN_USER= +CFG_FOCALBOARD_ADMIN_EMAIL= +CFG_FOCALBOARD_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1 diff --git a/containers/focalboard/focalboard.sh b/containers/focalboard/focalboard.sh new file mode 100755 index 0000000..0ce9317 --- /dev/null +++ b/containers/focalboard/focalboard.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +# Category : Productivity +# Description : Focalboard - Project Management (c/u/s/r/i): + +installFocalboard() +{ + local config_variables="$1" + + if [[ "$focalboard" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent focalboard; + local app_name=$CFG_FOCALBOARD_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$focalboard" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$focalboard" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$focalboard" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$focalboard" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$focalboard" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using one of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + focalboard=n +} diff --git a/containers/focalboard/focalboard.svg b/containers/focalboard/focalboard.svg new file mode 100755 index 0000000..b78e7e3 --- /dev/null +++ b/containers/focalboard/focalboard.svg @@ -0,0 +1 @@ + diff --git a/containers/gitea/docker-compose.yml b/containers/gitea/docker-compose.yml new file mode 100755 index 0000000..e6869a3 --- /dev/null +++ b/containers/gitea/docker-compose.yml @@ -0,0 +1,92 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + gitea-service: #LIBREPORTAL|SERVICE_TAG_1|gitea-service + container_name: gitea-service + image: gitea/gitea:latest + restart: unless-stopped + # GLUETUN_OFF_BEGIN + depends_on: + gitea-cache: + condition: service_healthy + # GLUETUN_OFF_END + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./data/gitea:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + logging: + driver: "json-file" + options: + max-size: "1m" + environment: + - APP_NAME="Gitea" + - USER_UID=1000 + - USER_GID=1000 + - USER=git + - RUN_MODE=prod + - DOMAIN=DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - SSH_DOMAIN=DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - HTTP_PORT=PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + - ROOT_URL=https://DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - SSH_PORT=PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 + - SSH_LISTEN_PORT=PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 + - DB_TYPE=sqlite3 + - GITEA__cache__ENABLED=true + - GITEA__cache__ADAPTER=redis + - GITEA__cache__HOST=redis://gitea-cache:6379/0?pool_size=100&idle_timeout=180s + - GITEA__cache__ITEM_TTL=24h + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + # >>> libreportal-monitoring >>> + #- GITEA__metrics__ENABLED=true + #- GITEA__metrics__TOKEN=GITEA_METRICS_TOKEN_DATA #LIBREPORTAL|GITEA_METRICS_TOKEN_TAG|GITEA_METRICS_TOKEN_DATA + # <<< libreportal-monitoring <<< + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + - "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2 + # GLUETUN_OFF_END + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # depends_on: + # gitea-cache: + # condition: service_healthy + # GLUETUN_ON_END + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.gitea-service.entrypoints: web,websecure + traefik.http.routers.gitea-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.gitea.tls: true + traefik.http.routers.gitea.tls.certresolver: production + traefik.http.services.gitea.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.gitea.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + + gitea-cache: #LIBREPORTAL|SERVICE_TAG_2|gitea-cache + container_name: gitea-cache + image: redis:6-alpine + restart: unless-stopped + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + test: ["CMD", "redis-cli", "ping"] + interval: 15s + timeout: 3s + retries: 30 + logging: + driver: "json-file" + options: + max-size: "1m" + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 diff --git a/containers/gitea/gitea.config b/containers/gitea/gitea.config new file mode 100755 index 0000000..8f9da82 --- /dev/null +++ b/containers/gitea/gitea.config @@ -0,0 +1,73 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# METRICS_TOKEN = bearer token guarding /metrics (rides the public web port); auto-generated, mirrored into the Prometheus scrape config +# +CFG_GITEA_APP_NAME=gitea +CFG_GITEA_BACKUP=true +CFG_GITEA_COMPOSE_FILE=default +CFG_GITEA_HEALTHCHECK=true +CFG_GITEA_AUTHELIA=false +CFG_GITEA_HEADSCALE=false +CFG_GITEA_MONITORING=false +CFG_GITEA_METRICS_TOKEN=RANDOMIZEDPASSWORD1 + +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_GITEA_CATEGORY="development" +CFG_GITEA_TITLE="Gitea" +CFG_GITEA_DESCRIPTION="Git Repository Management" +CFG_GITEA_LONG_DESCRIPTION="Gitea is a lightweight, self-hosted Git service written in Go that provides a painless self-hosted Git service with a minimal setup" +CFG_GITEA_URL="https://github.com/go-gitea/gitea" +CFG_GITEA_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_GITEA_DOMAIN=1 +CFG_GITEA_WHITELIST=false +CFG_GITEA_HOST_NAME=gitea +CFG_GITEA_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_GITEA_PORT_1="gitea-service|webui|random:3000|public|tcp|false|true|true|Web Interface|" +CFG_GITEA_PORT_2="gitea-service|ssh|random:22|private|tcp|false|false|false|Git SSH Access|" + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +CFG_GITEA_AUTH_PROFILE=multi_user +CFG_GITEA_ADMIN_USER= +CFG_GITEA_ADMIN_EMAIL= +CFG_GITEA_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1 diff --git a/containers/gitea/gitea.sh b/containers/gitea/gitea.sh new file mode 100755 index 0000000..c47864c --- /dev/null +++ b/containers/gitea/gitea.sh @@ -0,0 +1,139 @@ +#!/bin/bash + +# Category : Development & Version Control +# Description : Gitea - Git Repository Management (c/u/s/r/i): + +installGitea() +{ + local config_variables="$1" + + if [[ "$gitea" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent gitea; + local app_name=$CFG_GITEA_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$gitea" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$gitea" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$gitea" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$gitea" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$gitea" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + # Enable Gitea's /metrics endpoint only when CFG_GITEA_MONITORING=true + # (toggles the libreportal-monitoring marker block in the compose). + monitoringToggleAppConfig "$app_name" "docker-compose.yml"; + + # /metrics rides Gitea's public web port, so it's locked behind a + # bearer token. CFG_GITEA_METRICS_TOKEN lives in the .config (filled + # once by the RANDOMIZEDPASSWORD scanner, preserved across reinstalls) + # and reaches the compose via the GITEA_METRICS_TOKEN_TAG tag — mirror + # that same value into the Prometheus scrape fragment so the two agree. + if monitoringAppEnabled "$app_name"; then + if [[ -n "$CFG_GITEA_METRICS_TOKEN" ]]; then + sudo sed -i "s|GITEA_METRICS_TOKEN_PLACEHOLDER|${CFG_GITEA_METRICS_TOKEN}|g" \ + "$containers_dir$app_name/resources/monitoring/prometheus-scrape.yml" + checkSuccess "Synced Gitea /metrics token to the Prometheus scrape config" + else + isNotice "CFG_GITEA_METRICS_TOKEN is empty — Gitea /metrics scrape may 401." + fi + fi + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Refreshing monitoring integration." + echo "" + + # Self-correcting: adds Gitea's scrape target + dashboard to + # Prometheus/Grafana when CFG_GITEA_MONITORING=true, removes them when + # it's off. No-ops with a notice if either app isn't installed. + monitoringRefreshAll; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using one of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + gitea=n +} diff --git a/containers/gitea/gitea.svg b/containers/gitea/gitea.svg new file mode 100755 index 0000000..11c6df8 --- /dev/null +++ b/containers/gitea/gitea.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/gitea/resources/monitoring/grafana-dashboards/gitea.json b/containers/gitea/resources/monitoring/grafana-dashboards/gitea.json new file mode 100644 index 0000000..3e0f64c --- /dev/null +++ b/containers/gitea/resources/monitoring/grafana-dashboards/gitea.json @@ -0,0 +1,1096 @@ +{ + "annotations": { + "list": [ + { + "$$hashKey": "object:2075", + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Monitor Gitea server", + "editable": true, + "gnetId": 13192, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "fillGradient": 5, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 31, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 0, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pluginVersion": "6.7.3", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_resident_memory_bytes{job=\"gitea\"}", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Resident memory", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "fillGradient": 5, + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 0 + }, + "hiddenSeries": false, + "id": 32, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 0, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pluginVersion": "6.7.3", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_cpu_seconds_total{job=\"gitea\"}", + "instant": false, + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "CPU time", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "fillGradient": 5, + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 0 + }, + "hiddenSeries": false, + "id": 33, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 0, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pluginVersion": "6.7.3", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_open_fds{job=\"gitea\"}", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 1, + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Open FDS", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 6 + }, + "id": 2, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_accesses", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Accesses", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 6 + }, + "id": 18, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_repositories", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Repositories", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 6 + }, + "id": 24, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_users", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Users", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 6 + }, + "id": 5, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_issues", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Issues", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 10 + }, + "id": 11, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_actions", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Actions", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 10 + }, + "id": 10, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_labels", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Labels", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 10 + }, + "id": 21, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_organizations", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Organizations", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 10 + }, + "id": 16, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_teams", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Teams", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 10 + }, + "id": 8, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_follows", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Follows", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 10 + }, + "id": 20, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-yellow", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_publickeys", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of PublicKeys", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 14 + }, + "id": 17, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_stars", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Stars", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 14 + }, + "id": 14, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_webhooks", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Webhooks", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 14 + }, + "id": 19, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_releases", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Releases", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 14 + }, + "id": 7, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_comments", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Comments", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 14 + }, + "id": 3, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_milestones", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Milestones", + "type": "stat" + }, + { + "datasource": "Prometheus", + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 14 + }, + "id": 25, + "options": { + "colorMode": "value", + "fieldOptions": { + "calcs": [ + "lastNotNull" + ], + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-purple", + "value": null + } + ] + } + }, + "overrides": [], + "values": false + }, + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto" + }, + "pluginVersion": "6.7.3", + "targets": [ + { + "expr": "gitea_watches", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Number of Watches", + "type": "stat" + } + ], + "refresh": "1m", + "schemaVersion": 22, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "browser", + "title": "Gitea", + "uid": "nNq1Iw5Gz", + "variables": { + "list": [] + }, + "version": 7 +} diff --git a/containers/gitea/resources/monitoring/prometheus-scrape.yml b/containers/gitea/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..e280967 --- /dev/null +++ b/containers/gitea/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,13 @@ +# Prometheus scrape job for Gitea. Gathered into +# prometheus/scrape.d/gitea.yml by monitoringRefreshPrometheus when +# CFG_GITEA_MONITORING=true. Gitea's /metrics rides the main HTTP port (3000), +# which is the public web port — so it's locked behind a bearer token. +# The compose fills GITEA__metrics__TOKEN via the framework's RANDOMIZEDPASSWORD +# mechanism; gitea.sh mirrors that generated value into this fragment so the +# two always match. +- job_name: gitea + metrics_path: /metrics + authorization: + credentials: GITEA_METRICS_TOKEN_PLACEHOLDER + static_configs: + - targets: ['gitea-service:3000'] diff --git a/containers/gluetun/docker-compose.yml b/containers/gluetun/docker-compose.yml new file mode 100644 index 0000000..4e3fd49 --- /dev/null +++ b/containers/gluetun/docker-compose.yml @@ -0,0 +1,55 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + gluetun-service: #LIBREPORTAL|SERVICE_TAG_1|gluetun-service + container_name: gluetun-service + image: qmcgaw/gluetun:latest + restart: unless-stopped + hostname: gluetun + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + environment: + - TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - VPN_SERVICE_PROVIDER=GLUETUN_VPN_SERVICE_PROVIDER_DATA #LIBREPORTAL|GLUETUN_VPN_SERVICE_PROVIDER_TAG|GLUETUN_VPN_SERVICE_PROVIDER_DATA + - VPN_TYPE=GLUETUN_VPN_TYPE_DATA #LIBREPORTAL|GLUETUN_VPN_TYPE_TAG|GLUETUN_VPN_TYPE_DATA + - SERVER_COUNTRIES=GLUETUN_VPN_COUNTRIES_DATA #LIBREPORTAL|GLUETUN_VPN_COUNTRIES_TAG|GLUETUN_VPN_COUNTRIES_DATA + - OPENVPN_USER=GLUETUN_OPENVPN_USER_DATA #LIBREPORTAL|GLUETUN_OPENVPN_USER_TAG|GLUETUN_OPENVPN_USER_DATA + - OPENVPN_PASSWORD=GLUETUN_OPENVPN_PASSWORD_DATA #LIBREPORTAL|GLUETUN_OPENVPN_PASSWORD_TAG|GLUETUN_OPENVPN_PASSWORD_DATA + - WIREGUARD_PRIVATE_KEY=GLUETUN_WIREGUARD_PRIVATE_KEY_DATA #LIBREPORTAL|GLUETUN_WIREGUARD_PRIVATE_KEY_TAG|GLUETUN_WIREGUARD_PRIVATE_KEY_DATA + - WIREGUARD_ADDRESSES=GLUETUN_WIREGUARD_ADDRESSES_DATA #LIBREPORTAL|GLUETUN_WIREGUARD_ADDRESSES_TAG|GLUETUN_WIREGUARD_ADDRESSES_DATA + - HTTP_CONTROL_SERVER_AUTH_FILE_PATH=/gluetun/auth/config.toml + - HEALTH_TARGET_ADDRESSES=GLUETUN_HEALTH_TARGETS_DATA #LIBREPORTAL|GLUETUN_HEALTH_TARGETS_TAG|GLUETUN_HEALTH_TARGETS_DATA + - HEALTH_ICMP_TARGET_IPS=GLUETUN_HEALTH_ICMP_IPS_DATA #LIBREPORTAL|GLUETUN_HEALTH_ICMP_IPS_TAG|GLUETUN_HEALTH_ICMP_IPS_DATA + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + - ./gluetun-data:/gluetun + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_FORWARDED_PORTS_BEGIN + # GLUETUN_FORWARDED_PORTS_END + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + + # >>> libreportal-monitoring >>> + #gluetun-exporter: + # container_name: gluetun-exporter + # image: damianr1/gluetun-exporter:latest + # restart: unless-stopped + # depends_on: + # - gluetun-service + # environment: + # - GLUETUN_API_URL=http://gluetun-service:8000 + # - LISTEN_ADDRESS=:PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 + # networks: + # DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # <<< libreportal-monitoring <<< diff --git a/containers/gluetun/gluetun.config b/containers/gluetun/gluetun.config new file mode 100644 index 0000000..03d060b --- /dev/null +++ b/containers/gluetun/gluetun.config @@ -0,0 +1,96 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_GLUETUN_APP_NAME=gluetun +CFG_GLUETUN_BACKUP=true +CFG_GLUETUN_COMPOSE_FILE=default +CFG_GLUETUN_HEALTHCHECK=true +CFG_GLUETUN_AUTHELIA=false +CFG_GLUETUN_HEADSCALE=false +CFG_GLUETUN_MONITORING=false +# +# ============================================================================= +# APPLICATION CONFIGURATION +# ============================================================================= +# VPN_SERVICE_PROVIDER = VPN provider name (mullvad, nordvpn, protonvpn, surfshark, expressvpn, etc.) +# VPN_TYPE = wireguard or openvpn +# VPN_COUNTRIES = comma-separated country list (e.g. "Switzerland,Sweden") or empty for any +# OPENVPN_USER = OpenVPN account username (only when VPN_TYPE=openvpn) +# OPENVPN_PASSWORD = OpenVPN account password (only when VPN_TYPE=openvpn) +# WIREGUARD_PRIVATE_KEY = WireGuard private key (only when VPN_TYPE=wireguard) +# WIREGUARD_ADDRESSES = WireGuard interface address (e.g. "10.64.0.2/32") +# CONTROL_SERVER_API_KEY = API key for the gluetun HTTP control server, blank to disable auth +# +CFG_GLUETUN_VPN_SERVICE_PROVIDER=mullvad +CFG_GLUETUN_VPN_TYPE=wireguard +CFG_GLUETUN_VPN_COUNTRIES= +CFG_GLUETUN_OPENVPN_USER= +CFG_GLUETUN_OPENVPN_PASSWORD= +CFG_GLUETUN_WIREGUARD_PRIVATE_KEY= +CFG_GLUETUN_WIREGUARD_ADDRESSES= +CFG_GLUETUN_CONTROL_SERVER_API_KEY=RANDOMIZEDPASSWORD1 +# HEALTH_TARGETS = comma-separated host:port list pinged over HTTPS to +# confirm the VPN tunnel is healthy. Defaults are privacy-respecting +# (Mullvad — your VPN provider; EFF — privacy non-profit). Override +# with your own targets if you want to check different sites. +# HEALTH_ICMP_IPS = comma-separated IPv4 list pinged over ICMP for the +# small recurring health check. Default Quad9 (Swiss non-profit DNS, +# no logging). +# +CFG_GLUETUN_HEALTH_TARGETS="mullvad.net:443,eff.org:443" +CFG_GLUETUN_HEALTH_ICMP_IPS="9.9.9.9" +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_GLUETUN_CATEGORY="networking,recommended" +CFG_GLUETUN_TITLE="Gluetun" +CFG_GLUETUN_DESCRIPTION="VPN Container Router" +CFG_GLUETUN_LONG_DESCRIPTION="Run all of your containers through a VPN provider. Supports 30+ providers over WireGuard and OpenVPN with a built-in kill-switch, DNS-over-TLS, port forwarding, and an HTTP control server." +CFG_GLUETUN_URL="https://github.com/qdm12/gluetun" +CFG_GLUETUN_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips on traefik, if false allow all +# +CFG_GLUETUN_DOMAIN=1 +CFG_GLUETUN_WHITELIST=false +CFG_GLUETUN_HOST_NAME=gluetun +CFG_GLUETUN_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, api, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_GLUETUN_PORT_1="gluetun-service|control|random:8000|private|tcp|false|false|false|HTTP Server|" +CFG_GLUETUN_PORT_2="gluetun-exporter|metrics|8090:8090|disabled|tcp|false|false|false|Metrics Exporter (sidecar, docker-network only)|" diff --git a/containers/gluetun/gluetun.sh b/containers/gluetun/gluetun.sh new file mode 100644 index 0000000..bcf88c4 --- /dev/null +++ b/containers/gluetun/gluetun.sh @@ -0,0 +1,136 @@ +#!/bin/bash + +# Category : Networking +# Description : Gluetun - VPN client for routing other containers (c/u/s/r/i): + +installGluetun() +{ + local config_variables="$1" + + if [[ "$gluetun" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent gluetun; + local app_name=$CFG_GLUETUN_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$gluetun" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$gluetun" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$gluetun" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$gluetun" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$gluetun" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + monitoringToggleAppConfig "$app_name" "docker-compose.yml"; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating the WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Refreshing Gluetun provider snapshot." + echo "" + + webuiGenerateGluetunProviders; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Re-attaching gluetun-routed apps (post-recreate)." + echo "" + + # Gluetun was just (re)created — every existing routed app holds a + # stale container ID in its network_mode. Reattach them now so the + # user doesn't have to chase silent netns drift later. + appGluetunRoutedRecreate + + ((menu_number++)) + echo "" + echo "---- $menu_number. Routing existing apps through Gluetun (optional)." + echo "" + + gluetunRouteExistingAppsPrompt; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Refreshing monitoring integration." + echo "" + + monitoringRefreshAll; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + + menuShowFinalMessages "$app_name"; + + menu_number=0 + cd + fi + gluetun=n +} diff --git a/containers/gluetun/gluetun.svg b/containers/gluetun/gluetun.svg new file mode 100644 index 0000000..a39521c --- /dev/null +++ b/containers/gluetun/gluetun.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/containers/gluetun/resources/monitoring/grafana-dashboards/gluetun.json b/containers/gluetun/resources/monitoring/grafana-dashboards/gluetun.json new file mode 100644 index 0000000..8c1b100 --- /dev/null +++ b/containers/gluetun/resources/monitoring/grafana-dashboards/gluetun.json @@ -0,0 +1,67 @@ +{ + "annotations": { "list": [{ "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" }] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [{ "options": { "0": { "text": "DOWN" }, "1": { "text": "UP" } }, "type": "value" }], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + } + }, + "gridPos": { "h": 4, "w": 8, "x": 0, "y": 0 }, + "id": 1, + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "gluetun_up", "refId": "A" }], + "title": "VPN Tunnel", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, + "gridPos": { "h": 4, "w": 8, "x": 8, "y": 0 }, + "id": 2, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "gluetun_public_ip_info", "legendFormat": "{{country}} / {{public_ip}}", "refId": "A" }], + "title": "Public IP / Country", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, "unit": "s" } }, + "gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 }, + "id": 3, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "time() - gluetun_start_timestamp_seconds", "refId": "A" }], + "title": "Uptime", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false } } }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 4 }, + "id": 4, + "options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "gluetun_up", "legendFormat": "up", "refId": "A" }], + "title": "Tunnel Health Over Time", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["libreportal", "gluetun"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Gluetun", + "uid": "libreportal-gluetun", + "version": 1, + "weekStart": "" +} diff --git a/containers/gluetun/resources/monitoring/prometheus-scrape.yml b/containers/gluetun/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..e5c6192 --- /dev/null +++ b/containers/gluetun/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,4 @@ +- job_name: gluetun + metrics_path: /metrics + static_configs: + - targets: ['gluetun-exporter:PORT_INTERNAL_DATA_2'] #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 diff --git a/containers/grafana/docker-compose.yml b/containers/grafana/docker-compose.yml new file mode 100755 index 0000000..4329afe --- /dev/null +++ b/containers/grafana/docker-compose.yml @@ -0,0 +1,43 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + grafana-service: #LIBREPORTAL|SERVICE_TAG_1|grafana-service + image: grafana/grafana-enterprise + container_name: grafana-service + restart: unless-stopped + environment: + - GF_SERVER_ROOT_URL:https://DOMAINSUBNAME_DATA/ #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - GF_INSTALL_PLUGINS=grafana-clock-panel + - GF_SERVER_HTTP_PORT:PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + volumes: + - ./grafana_storage:/var/lib/grafana + - ./provisioning:/etc/grafana/provisioning + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.grafana-service.entrypoints: web,websecure + traefik.http.routers.grafana-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.grafana-service.tls: true + traefik.http.routers.grafana-service.tls.certresolver: production + traefik.http.services.grafana-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.grafana-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/grafana/grafana.config b/containers/grafana/grafana.config new file mode 100755 index 0000000..47226b8 --- /dev/null +++ b/containers/grafana/grafana.config @@ -0,0 +1,67 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# REQUIRES = comma-separated install prerequisites (see scripts/checks/requirements/check_app_install.sh) +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_GRAFANA_APP_NAME=grafana +CFG_GRAFANA_REQUIRES="prometheus" +CFG_GRAFANA_BACKUP=true +CFG_GRAFANA_COMPOSE_FILE=default +CFG_GRAFANA_HEALTHCHECK=true +CFG_GRAFANA_AUTHELIA=false +CFG_GRAFANA_HEADSCALE=false +CFG_GRAFANA_MONITORING=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_GRAFANA_CATEGORY="monitoring" +CFG_GRAFANA_TITLE="Grafana" +CFG_GRAFANA_DESCRIPTION="Metrics Visualizer" +CFG_GRAFANA_LONG_DESCRIPTION="The open source analytics and monitoring solution for every database with beautiful dashboards and alerting capabilities" +CFG_GRAFANA_URL="https://github.com/grafana/grafana" +CFG_GRAFANA_ACTIONS="configure|install|restart|shutdown|uninstall" +CFG_GRAFANA_REQUIRES_SERVICE=prometheus +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_GRAFANA_DOMAIN=1 +CFG_GRAFANA_WHITELIST=false +CFG_GRAFANA_HOST_NAME=grafana +CFG_GRAFANA_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_GRAFANA_PORT_1="grafana-service|webui|random:3000|public|tcp|false|true|true|Web Interface|" diff --git a/containers/grafana/grafana.sh b/containers/grafana/grafana.sh new file mode 100755 index 0000000..0be5b1b --- /dev/null +++ b/containers/grafana/grafana.sh @@ -0,0 +1,133 @@ +#!/bin/bash + +# Category : Development & Version Control +# Description : Grafana - Metrics Visualizer (c/u/s/r/i): + +installGrafana() +{ + local config_variables="$1" + + if [[ "$grafana" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent grafana; + local app_name=$CFG_GRAFANA_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$grafana" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$grafana" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$grafana" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$grafana" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$grafana" == *[iI]* ]]; then + isHeader "Install $app_name" + + if ! appInstallCheckRequirements "$app_name" "$CFG_GRAFANA_REQUIRES"; then + grafana=n + return 1 + fi + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + # Grafana + if [ -d "${containers_dir}grafana/grafana_storage" ]; then + local result=$(sudo chmod -R 777 "${containers_dir}grafana/grafana_storage") + checkSuccess "Set permissions to grafana_storage folder." + fi + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Provisioning monitoring dashboards from installed apps." + echo "" + + # Re-gather the Prometheus datasource + every monitoring-enabled app's + # dashboards into provisioning/ — so a fresh (or re-)install of Grafana + # picks up the apps that already had CFG__MONITORING=true. + # monitoringRefreshAll also covers Grafana's own scrape target when + # CFG_GRAFANA_MONITORING=true. + monitoringRefreshAll; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + grafana=n +} diff --git a/containers/grafana/grafana.svg b/containers/grafana/grafana.svg new file mode 100755 index 0000000..54be1e2 --- /dev/null +++ b/containers/grafana/grafana.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/containers/grafana/resources/monitoring/grafana-dashboards/grafana.json b/containers/grafana/resources/monitoring/grafana-dashboards/grafana.json new file mode 100644 index 0000000..2ff8126 --- /dev/null +++ b/containers/grafana/resources/monitoring/grafana-dashboards/grafana.json @@ -0,0 +1,233 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "grafana_stat_totals_dashboard", + "refId": "A" + } + ], + "title": "Total Dashboards", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "grafana_stat_totals_orgs", + "refId": "A" + } + ], + "title": "Total Orgs", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "grafana_stat_totals_users", + "refId": "A" + } + ], + "title": "Total Users", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "process_resident_memory_bytes{job=\"grafana\"}", + "refId": "A" + } + ], + "title": "Memory (RSS)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 4 }, + "id": 5, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "rate(grafana_http_request_duration_seconds_count[5m])", + "legendFormat": "{{method}} {{status_code}}", + "refId": "A" + } + ], + "title": "HTTP Request Rate", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 4 }, + "id": 6, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(grafana_http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "histogram_quantile(0.50, sum by (le) (rate(grafana_http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p50", + "refId": "B" + } + ], + "title": "HTTP Request Latency", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["libreportal", "grafana"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Grafana", + "uid": "libreportal-grafana", + "version": 1, + "weekStart": "" +} diff --git a/containers/grafana/resources/monitoring/prometheus-scrape.yml b/containers/grafana/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..8d1c7b4 --- /dev/null +++ b/containers/grafana/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,4 @@ +- job_name: grafana + metrics_path: /metrics + static_configs: + - targets: ['grafana-service:3000'] diff --git a/containers/headscale/docker-compose.yml b/containers/headscale/docker-compose.yml new file mode 100755 index 0000000..ac5fc6f --- /dev/null +++ b/containers/headscale/docker-compose.yml @@ -0,0 +1,70 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + headscale-service: #LIBREPORTAL|SERVICE_TAG_1|headscale-service + container_name: headscale-service + image: headscale/headscale:latest + volumes: + - ./config:/etc/headscale/ + - ./data:/var/lib/headscale + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + command: headscale serve + restart: unless-stopped + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + + headscale-webui-service: #LIBREPORTAL|SERVICE_TAG_2|headscale-webui-service + image: ghcr.io/ifargle/headscale-webui:latest + container_name: headscale-webui + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - PGID=1000 + - PUID=1000 + - COLOR=blue # Use the base colors (ie, no darken-3, etc) - + - HS_SERVER:https://DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - DOMAIN_NAME:https://DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - SCRIPT_NAME=/admin # This is your applications base path (wsgi requires the name "SCRIPT_NAME"). Remove if you are hosing at the root / + - KEY="a-really-long-key-you-create-with-the-command-in-the-comment" # Generate with "openssl rand -base64 32" - used to encrypt your key on disk. + - AUTH_TYPE=Basic # AUTH_TYPE is either Basic or OIDC. Empty for no authentication + - LOG_LEVEL=info # Log level. "DEBUG", "ERROR", "WARNING", or "INFO". Default "INFO" + # ENV for Basic Auth (Used only if AUTH_TYPE is "Basic"). Can be omitted if you aren't using Basic Auth + - BASIC_AUTH_USER=libreportal # Used for basic auth + - BASIC_AUTH_PASS=HEADSCALE_BASIC_AUTH_PASS_DATA #LIBREPORTAL|HEADSCALE_BASIC_AUTH_PASS_TAG|HEADSCALE_BASIC_AUTH_PASS_DATA + # ENV for OIDC (Used only if AUTH_TYPE is "OIDC"). Can be omitted if you aren't using OIDC + #- OIDC_AUTH_URL=https://yourauthserver.com/application/o/headscale/.well-known/openid-configuration # URL for your OIDC issuer's well-known endpoint + #- OIDC_CLIENT_ID=your-auth-server-client-id-info-here # Your OIDC Issuer's Client ID for Headscale-WebUI + #- OIDC_CLIENT_SECRET=your-oidc-auth-server-client-secret-key-will-go-here-and-be-very-long-indeed # Your OIDC Issuer's Secret Key for Headscale-WebUI + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.headscale-webui-service.rule: Host(`admin.DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.services.headscale-webui-service.loadbalancer.server.port: PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 + traefik.http.routers.headscale-webui-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ports: + - "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2 + volumes: + - ./volume:/data # Headscale-WebUI's storage. Make sure ./volume is readable by UID 1000 (chown 1000:1000 ./volume) + - ./config/:/etc/headscale/:ro # Headscale's config storage location. Used to read your Headscale config. + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 diff --git a/containers/headscale/headscale.config b/containers/headscale/headscale.config new file mode 100755 index 0000000..234c0d2 --- /dev/null +++ b/containers/headscale/headscale.config @@ -0,0 +1,63 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# BASIC_AUTH_PASS = password for the headscale-ui basic auth; auto-generated, fed to the compose via HEADSCALE_BASIC_AUTH_PASS_TAG +# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) +# +CFG_HEADSCALE_APP_NAME=headscale +CFG_HEADSCALE_BACKUP=true +CFG_HEADSCALE_COMPOSE_FILE=default +CFG_HEADSCALE_HEALTHCHECK=true +CFG_HEADSCALE_BASIC_AUTH_PASS=RANDOMIZEDPASSWORD1 +CFG_HEADSCALE_MONITORING=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_HEADSCALE_CATEGORY="networking" +CFG_HEADSCALE_TITLE="Headscale" +CFG_HEADSCALE_DESCRIPTION="WireGuard VPN Controller" +CFG_HEADSCALE_LONG_DESCRIPTION="Headscale is an open source, self-hosted implementation of the Tailscale control server that works with the Tailscale client" +CFG_HEADSCALE_URL="https://github.com/juanfont/headscale" +CFG_HEADSCALE_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_HEADSCALE_DOMAIN=1 +CFG_HEADSCALE_WHITELIST=false +CFG_HEADSCALE_HOST_NAME=headscale +CFG_HEADSCALE_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_HEADSCALE_PORT_1="headscale-service|api|random:8080|private|tcp|false|false|false|Headscale API Server|" +CFG_HEADSCALE_PORT_2="headscale-webui-service|webui|random:5000|private|tcp|false|true|true|Web UI|" diff --git a/containers/headscale/headscale.sh b/containers/headscale/headscale.sh new file mode 100755 index 0000000..b837c3f --- /dev/null +++ b/containers/headscale/headscale.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# Category : Networking +# Description : Self-hosted WireGuard orchestrator (c/u/s/r/i): + +installHeadscale() +{ + local config_variables="$1" + + if [[ "$headscale" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent headscale; + local app_name=$CFG_HEADSCALE_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$headscale" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$headscale" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$headscale" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$headscale" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$headscale" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + local result=$(createFolders "loud" $docker_install_user $containers_dir$app_name/config) + checkSuccess "Create config folder" + + local result=$(copyResource "$app_name" "config.yaml" "config" | sudo tee -a "$logs_dir/$docker_log_file" 2>&1) + checkSuccess "Copying config.yaml to config folder." + + configSetupFileWithData $app_name "config.yaml" "config"; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up database records" + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Refreshing monitoring integration." + echo "" + + monitoringRefreshAll; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + echo " NOTE - The password to login in defined in the yml install file that was installed" + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + headscale=n +} diff --git a/containers/headscale/headscale.svg b/containers/headscale/headscale.svg new file mode 100755 index 0000000..06f406a --- /dev/null +++ b/containers/headscale/headscale.svg @@ -0,0 +1 @@ + diff --git a/containers/headscale/resources/config.yaml b/containers/headscale/resources/config.yaml new file mode 100755 index 0000000..ce87482 --- /dev/null +++ b/containers/headscale/resources/config.yaml @@ -0,0 +1,327 @@ +--- +# headscale will look for a configuration file named `config.yaml` (or `config.json`) in the following order: +# +# - `/etc/headscale` +# - `~/.headscale` +# - current working directory + +# The url clients will connect to. +# Typically this will be a domain like: +# +# https://myheadscale.example.com:443 +# +server_url: https://DOMAINSUBNAMEHERE + +# Address to listen to / bind to on the server +# +# For production: +# listen_addr: 0.0.0.0:8080 +listen_addr: 0.0.0.0:PORT1 + +# Address to listen to /metrics, you may want +# to keep this endpoint private to your internal +# network. Bound to the docker network only (no compose port mapping), so +# only sibling containers like Prometheus can reach it. +# +metrics_listen_addr: 0.0.0.0:9090 + +# Address to listen for gRPC. +# gRPC is used for controlling a headscale server +# remotely with the CLI +# Note: Remote access _only_ works if you have +# valid certificates. +# +# For production: +# grpc_listen_addr: 0.0.0.0:50443 +grpc_listen_addr: 127.0.0.1:50443 + +# Allow the gRPC admin interface to run in INSECURE +# mode. This is not recommended as the traffic will +# be unencrypted. Only enable if you know what you +# are doing. +grpc_allow_insecure: false + +# Private key used to encrypt the traffic between headscale +# and Tailscale clients. +# The private key file will be autogenerated if it's missing. +# +private_key_path: /var/lib/headscale/private.key + +# The Noise section includes specific configuration for the +# TS2021 Noise protocol +noise: + # The Noise private key is used to encrypt the + # traffic between headscale and Tailscale clients when + # using the new Noise-based protocol. It must be different + # from the legacy private key. + private_key_path: /var/lib/headscale/noise_private.key + +# List of IP prefixes to allocate tailaddresses from. +# Each prefix consists of either an IPv4 or IPv6 address, +# and the associated prefix length, delimited by a slash. +# It must be within IP ranges supported by the Tailscale +# client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48. +# See below: +# IPv6: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#LL81C52-L81C71 +# IPv4: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#L33 +# Any other range is NOT supported, and it will cause unexpected issues. +ip_prefixes: + - 100.64.0.0/10 + - fd7a:115c:a1e0::/48 + +# DERP is a relay system that Tailscale uses when a direct +# connection cannot be established. +# https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp +# +# headscale needs a list of DERP servers that can be presented +# to the clients. +derp: + server: + # If enabled, runs the embedded DERP server and merges it into the rest of the DERP config + # The Headscale server_url defined above MUST be using https, DERP requires TLS to be in place + enabled: false + + # Region ID to use for the embedded DERP server. + # The local DERP prevails if the region ID collides with other region ID coming from + # the regular DERP config. + region_id: 999 + + # Region code and name are displayed in the Tailscale UI to identify a DERP region + region_code: "headscale" + region_name: "Headscale Embedded DERP" + + # Listens over UDP at the configured address for STUN connections - to help with NAT traversal. + # When the embedded DERP server is enabled stun_listen_addr MUST be defined. + # + # For more details on how this works, check this great article: https://tailscale.com/blog/how-tailscale-works/ + stun_listen_addr: "0.0.0.0:3478" + + # List of externally available DERP maps encoded in JSON + urls: + - https://controlplane.tailscale.com/derpmap/default + + # Locally available DERP map files encoded in YAML + # + # This option is mostly interesting for people hosting + # their own DERP servers: + # https://tailscale.com/kb/1118/custom-derp-servers/ + # + # paths: + # - /etc/headscale/derp-example.yaml + paths: [] + + # If enabled, a worker will be set up to periodically + # refresh the given sources and update the derpmap + # will be set up. + auto_update_enabled: true + + # How often should we check for DERP updates? + update_frequency: 24h + +# Disables the automatic check for headscale updates on startup +disable_check_updates: true + +# Time before an inactive ephemeral node is deleted? +ephemeral_node_inactivity_timeout: 30m + +# Period to check for node updates within the tailnet. A value too low will severely affect +# CPU consumption of Headscale. A value too high (over 60s) will cause problems +# for the nodes, as they won't get updates or keep alive messages frequently enough. +# In case of doubts, do not touch the default 10s. +node_update_check_interval: 10s + +# SQLite config +db_type: sqlite3 + +# For production: +db_path: /var/lib/headscale/db.sqlite + +# # Postgres config +# If using a Unix socket to connect to Postgres, set the socket path in the 'host' field and leave 'port' blank. +# db_type: postgres +# db_host: localhost +# db_port: 5432 +# db_name: headscale +# db_user: foo +# db_pass: bar + +# If other 'sslmode' is required instead of 'require(true)' and 'disabled(false)', set the 'sslmode' you need +# in the 'db_ssl' field. Refers to https://www.postgresql.org/docs/current/libpq-ssl.html Table 34.1. +# db_ssl: false + +### TLS configuration +# +## Let's encrypt / ACME +# +# headscale supports automatically requesting and setting up +# TLS for a domain with Let's Encrypt. +# +# URL to ACME directory +acme_url: https://acme-v02.api.letsencrypt.org/directory + +# Email to register with ACME provider +acme_email: "" + +# Domain name to request a TLS certificate for: +tls_letsencrypt_hostname: "" + +# Path to store certificates and metadata needed by +# letsencrypt +# For production: +tls_letsencrypt_cache_dir: /var/lib/headscale/cache + +# Type of ACME challenge to use, currently supported types: +# HTTP-01 or TLS-ALPN-01 +# See [docs/tls.md](docs/tls.md) for more information +tls_letsencrypt_challenge_type: HTTP-01 +# When HTTP-01 challenge is chosen, letsencrypt must set up a +# verification endpoint, and it will be listening on: +# :http = port 80 +tls_letsencrypt_listen: ":http" + +## Use already defined certificates: +tls_cert_path: "" +tls_key_path: "" + +log: + # Output formatting for logs: text or json + format: text + level: info + +# Path to a file containg ACL policies. +# ACLs can be defined as YAML or HUJSON. +# https://tailscale.com/kb/1018/acls/ +acl_policy_path: "" + +## DNS +# +# headscale supports Tailscale's DNS configuration and MagicDNS. +# Please have a look to their KB to better understand the concepts: +# +# - https://tailscale.com/kb/1054/dns/ +# - https://tailscale.com/kb/1081/magicdns/ +# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/ +# +dns_config: + # Whether to prefer using Headscale provided DNS or use local. + override_local_dns: true + + # List of DNS servers to expose to clients. + nameservers: + - 9.9.9.9 + + # NextDNS (see https://tailscale.com/kb/1218/nextdns/). + # "abc123" is example NextDNS ID, replace with yours. + # + # With metadata sharing: + # nameservers: + # - https://dns.nextdns.io/abc123 + # + # Without metadata sharing: + # nameservers: + # - 2a07:a8c0::ab:c123 + # - 2a07:a8c1::ab:c123 + + # Split DNS (see https://tailscale.com/kb/1054/dns/), + # list of search domains and the DNS to query for each one. + # + # restricted_nameservers: + # foo.bar.com: + # - 1.1.1.1 + # darp.headscale.net: + # - 1.1.1.1 + # - 8.8.8.8 + + # Search domains to inject. + domains: [] + + # Extra DNS records + # so far only A-records are supported (on the tailscale side) + # See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations + # extra_records: + # - name: "grafana.myvpn.example.com" + # type: "A" + # value: "100.64.0.3" + # + # # you can also put it in one line + # - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" } + + # Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). + # Only works if there is at least a nameserver defined. + magic_dns: true + + # Defines the base domain to create the hostnames for MagicDNS. + # `base_domain` must be a FQDNs, without the trailing dot. + # The FQDN of the hosts will be + # `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_). + base_domain: DOMAINSUBNAMEHERE + +# Unix socket used for the CLI to connect without authentication +# Note: for production you will want to set this to something like: +unix_socket: /var/run/headscale/headscale.sock +unix_socket_permission: "0770" +# +# headscale supports experimental OpenID connect support, +# it is still being tested and might have some bugs, please +# help us test it. +# OpenID Connect +# oidc: +# only_start_if_oidc_is_available: true +# issuer: "https://your-oidc.issuer.com/path" +# client_id: "your-oidc-client-id" +# client_secret: "your-oidc-client-secret" +# # Alternatively, set `client_secret_path` to read the secret from the file. +# # It resolves environment variables, making integration to systemd's +# # `LoadCredential` straightforward: +# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" +# # client_secret and client_secret_path are mutually exclusive. +# +# # The amount of time from a node is authenticated with OpenID until it +# # expires and needs to reauthenticate. +# # Setting the value to "0" will mean no expiry. +# expiry: 180d +# +# # Use the expiry from the token received from OpenID when the user logged +# # in, this will typically lead to frequent need to reauthenticate and should +# # only been enabled if you know what you are doing. +# # Note: enabling this will cause `oidc.expiry` to be ignored. +# use_expiry_from_token: false +# +# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query +# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email". +# +# scope: ["openid", "profile", "email", "custom"] +# extra_params: +# domain_hint: example.com +# +# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the +# # authentication request will be rejected. +# +# allowed_domains: +# - example.com +# # Note: Groups from keycloak have a leading '/' +# allowed_groups: +# - /headscale +# allowed_users: +# - alice@example.com +# +# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed. +# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name` +# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following +# user: `first-name.last-name.example.com` +# +# strip_email_domain: true + +# Logtail configuration +# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel +# to instruct tailscale nodes to log their activity to a remote server. +logtail: + # Enable logtail for this headscales clients. + # As there is currently no support for overriding the log server in headscale, this is + # disabled by default. Enabling this will make your clients send logs to Tailscale Inc. + enabled: false + +# Enabling this option makes devices prefer a random port for WireGuard traffic over the +# default static port 41641. This option is intended as a workaround for some buggy +# firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information. +randomize_client_port: false diff --git a/containers/headscale/resources/monitoring/grafana-dashboards/headscale.json b/containers/headscale/resources/monitoring/grafana-dashboards/headscale.json new file mode 100644 index 0000000..982e45f --- /dev/null +++ b/containers/headscale/resources/monitoring/grafana-dashboards/headscale.json @@ -0,0 +1,200 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "sum(headscale_machine_registrations_total)", + "refId": "A" + } + ], + "title": "Total Machine Registrations", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "headscale_machines_active_total", + "refId": "A" + } + ], + "title": "Active Machines", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "process_resident_memory_bytes{job=\"headscale\"}", + "refId": "A" + } + ], + "title": "Memory (RSS)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "go_goroutines{job=\"headscale\"}", + "refId": "A" + } + ], + "title": "Goroutines", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [] + }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 4 }, + "id": 5, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "rate(headscale_node_update_count_total[5m])", + "legendFormat": "node updates/s", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "Prometheus" }, + "expr": "rate(headscale_grpc_requests_total[5m])", + "legendFormat": "grpc {{method}} /s", + "refId": "B" + } + ], + "title": "Activity", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["libreportal", "headscale"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Headscale", + "uid": "libreportal-headscale", + "version": 1, + "weekStart": "" +} diff --git a/containers/headscale/resources/monitoring/prometheus-scrape.yml b/containers/headscale/resources/monitoring/prometheus-scrape.yml new file mode 100644 index 0000000..cb0bd82 --- /dev/null +++ b/containers/headscale/resources/monitoring/prometheus-scrape.yml @@ -0,0 +1,4 @@ +- job_name: headscale + metrics_path: /metrics + static_configs: + - targets: ['headscale-service:9090'] diff --git a/containers/invidious/docker-compose.yml b/containers/invidious/docker-compose.yml new file mode 100644 index 0000000..43e11ab --- /dev/null +++ b/containers/invidious/docker-compose.yml @@ -0,0 +1,76 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + invidious-service: #LIBREPORTAL|SERVICE_TAG_1|invidious-service + container_name: invidious-service + image: quay.io/invidious/invidious:latest + restart: unless-stopped + depends_on: + - invidious-db + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + environment: + INVIDIOUS_CONFIG: | + db: + dbname: invidious + user: kemal + password: kemal + host: invidious-db + port: 5432 + check_tables: true + # external_port: + # domain: + # https_only: false + # statistics_enabled: false + hmac_key: "INVIDIOUS_HMAC_KEY_DATA" #LIBREPORTAL|INVIDIOUS_HMAC_KEY_TAG|INVIDIOUS_HMAC_KEY_DATA + healthcheck: + test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1 + interval: 30s + timeout: 5s + retries: 2 + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + logging: + options: + max-size: "1G" + max-file: "4" + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.invidious-service.entrypoints: web,websecure + traefik.http.routers.invidious-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.invidious-service.tls: true + traefik.http.routers.invidious-service.tls.certresolver: production + traefik.http.services.invidious-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.invidious-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END + + invidious-db: #LIBREPORTAL|SERVICE_TAG_2|invidious-db + container_name: invidious-db + image: docker.io/library/postgres:14 + restart: unless-stopped + volumes: + - ./postgresdata:/var/lib/postgresql/data + environment: + POSTGRES_DB: invidious + POSTGRES_USER: kemal + POSTGRES_PASSWORD: kemal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 diff --git a/containers/invidious/invidious.config b/containers/invidious/invidious.config new file mode 100755 index 0000000..8522012 --- /dev/null +++ b/containers/invidious/invidious.config @@ -0,0 +1,71 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_INVIDIOUS_APP_NAME=invidious +CFG_INVIDIOUS_BACKUP=false +CFG_INVIDIOUS_COMPOSE_FILE=default +CFG_INVIDIOUS_HEALTHCHECK=false +CFG_INVIDIOUS_AUTHELIA=false +CFG_INVIDIOUS_HEADSCALE=false +# HMAC_KEY = signs Invidious tokens/links; auto-generated, fed to the compose +# via the INVIDIOUS_HMAC_KEY_TAG tag (preserved across reinstalls) +CFG_INVIDIOUS_HMAC_KEY=RANDOMIZEDPASSWORD1 +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_INVIDIOUS_CATEGORY="media" +CFG_INVIDIOUS_TITLE="Invidious" +CFG_INVIDIOUS_DESCRIPTION="YouTube Frontend" +CFG_INVIDIOUS_LONG_DESCRIPTION="Invidious is an alternative front-end to YouTube that focuses on privacy and providing a distraction-free viewing experience" +CFG_INVIDIOUS_URL="https://github.com/iv-org/invidious" +CFG_INVIDIOUS_ACTIONS="configure|install|restart|shutdown|uninstall|tools" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_INVIDIOUS_DOMAIN=1 +CFG_INVIDIOUS_WHITELIST=false +CFG_INVIDIOUS_HOST_NAME=invidious +CFG_INVIDIOUS_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_INVIDIOUS_PORT_1="invidious-service|webui|random:3000|public|tcp|false|true|true|Web Interface|" + +# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user) +CFG_INVIDIOUS_AUTH_PROFILE=multi_user +CFG_INVIDIOUS_ADMIN_USER= +CFG_INVIDIOUS_ADMIN_EMAIL= +CFG_INVIDIOUS_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1 diff --git a/containers/invidious/invidious.sh b/containers/invidious/invidious.sh new file mode 100755 index 0000000..8ead100 --- /dev/null +++ b/containers/invidious/invidious.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +# Category : Media & Streaming +# Description : Invidious - Privacy-focused YouTube Frontend (c/u/s/r/i/t): + +installInvidious() +{ + local config_variables="$1" + + if [[ "$invidious" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent invidious; + local app_name=$CFG_INVIDIOUS_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$invidious" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$invidious" == *[tT]* ]]; then + invidiousToolsMenu; + fi + + if [[ "$invidious" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$invidious" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$invidious" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$invidious" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + invidious=n +} diff --git a/containers/invidious/invidious.svg b/containers/invidious/invidious.svg new file mode 100755 index 0000000..80e78a4 --- /dev/null +++ b/containers/invidious/invidious.svg @@ -0,0 +1,2 @@ + + diff --git a/containers/ipinfo/docker-compose.yml b/containers/ipinfo/docker-compose.yml new file mode 100755 index 0000000..25c785c --- /dev/null +++ b/containers/ipinfo/docker-compose.yml @@ -0,0 +1,38 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + ipinfo-service: #LIBREPORTAL|SERVICE_TAG_1|ipinfo-service + container_name: ipinfo-service + image: peterdavehello/ipinfo.tw:latest + restart: unless-stopped + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.ipinfo-service.entrypoints: web,websecure + traefik.http.routers.ipinfo-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.ipinfo-service.tls: true + traefik.http.routers.ipinfo-service.tls.certresolver: production + traefik.http.services.ipinfo-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.ipinfo-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + volumes: + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/ipinfo/ipinfo.config b/containers/ipinfo/ipinfo.config new file mode 100755 index 0000000..9c0be28 --- /dev/null +++ b/containers/ipinfo/ipinfo.config @@ -0,0 +1,62 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_IPINFO_APP_NAME=ipinfo +CFG_IPINFO_BACKUP=false +CFG_IPINFO_COMPOSE_FILE=default +CFG_IPINFO_HEALTHCHECK=true +CFG_IPINFO_AUTHELIA=false +CFG_IPINFO_HEADSCALE=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_IPINFO_CATEGORY="networking" +CFG_IPINFO_TITLE="IPinfo" +CFG_IPINFO_DESCRIPTION="IP Information" +CFG_IPINFO_LONG_DESCRIPTION="IPinfo is a simple IP address lookup service that provides detailed information about IP addresses including geolocation and ISP data" +CFG_IPINFO_URL="https://github.com/ipinfo/cli" +CFG_IPINFO_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_IPINFO_DOMAIN=1 +CFG_IPINFO_WHITELIST=false +CFG_IPINFO_HOST_NAME=ipinfo +CFG_IPINFO_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_IPINFO_PORT_1="ipinfo-service|webui|random:8080|public|tcp|false|true|true|Web Interface|" diff --git a/containers/ipinfo/ipinfo.sh b/containers/ipinfo/ipinfo.sh new file mode 100755 index 0000000..51d2416 --- /dev/null +++ b/containers/ipinfo/ipinfo.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +# Category : Networking +# Description : IPinfo - IP Geolocation and Information (c/u/s/r/i): + +installIpinfo() +{ + local config_variables="$1" + + if [[ "$ipinfo" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent ipinfo; + local app_name=$CFG_IPINFO_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$ipinfo" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$ipinfo" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$ipinfo" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$ipinfo" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$ipinfo" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your $app_name service using any of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + ipinfo=n +} diff --git a/containers/ipinfo/ipinfo.svg b/containers/ipinfo/ipinfo.svg new file mode 100755 index 0000000..656169c --- /dev/null +++ b/containers/ipinfo/ipinfo.svg @@ -0,0 +1 @@ + diff --git a/containers/jellyfin/docker-compose.yml b/containers/jellyfin/docker-compose.yml new file mode 100755 index 0000000..0827078 --- /dev/null +++ b/containers/jellyfin/docker-compose.yml @@ -0,0 +1,41 @@ +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + jellyfin-service: #LIBREPORTAL|SERVICE_TAG_1|jellyfin-service + image: jellyfin/jellyfin + container_name: jellyfin-service + # GLUETUN_OFF_BEGIN + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + # GLUETUN_OFF_END + volumes: + - ./config:/config + - ./cache:/cache + - ./media:/media + - ./media2:/media2:ro + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.jellyfin-service.entrypoints: web,websecure + traefik.http.routers.jellyfin-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.jellyfin-service.tls: true + traefik.http.routers.jellyfin-service.tls.certresolver: production + traefik.http.services.jellyfin-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.jellyfin-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + restart: 'unless-stopped' + # GLUETUN_OFF_BEGIN + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + # GLUETUN_OFF_END + # GLUETUN_ON_BEGIN + # network_mode: "container:gluetun-service" + # GLUETUN_ON_END diff --git a/containers/jellyfin/jellyfin.config b/containers/jellyfin/jellyfin.config new file mode 100755 index 0000000..99947cd --- /dev/null +++ b/containers/jellyfin/jellyfin.config @@ -0,0 +1,62 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_JELLYFIN_APP_NAME=jellyfin +CFG_JELLYFIN_BACKUP=true +CFG_JELLYFIN_COMPOSE_FILE=default +CFG_JELLYFIN_HEALTHCHECK=true +CFG_JELLYFIN_AUTHELIA=false +CFG_JELLYFIN_HEADSCALE=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_JELLYFIN_CATEGORY="media" +CFG_JELLYFIN_TITLE="Jellyfin" +CFG_JELLYFIN_DESCRIPTION="Media Server" +CFG_JELLYFIN_LONG_DESCRIPTION="Jellyfin is a free software media system that puts you in control of managing and streaming your media without any locked subscriptions" +CFG_JELLYFIN_URL="https://github.com/jellyfin/jellyfin" +CFG_JELLYFIN_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_JELLYFIN_DOMAIN=1 +CFG_JELLYFIN_WHITELIST=false +CFG_JELLYFIN_HOST_NAME=jellyfin +CFG_JELLYFIN_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_JELLYFIN_PORT_1="jellyfin-service|webui|random:8096|public|tcp|false|true|true|Media Server|" diff --git a/containers/jellyfin/jellyfin.sh b/containers/jellyfin/jellyfin.sh new file mode 100755 index 0000000..bf65624 --- /dev/null +++ b/containers/jellyfin/jellyfin.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +# Category : Media & Streaming +# Description : Jellyfin - Media Server (c/u/s/r/i): + +installJellyfin() +{ + local config_variables="$1" + + if [[ "$jellyfin" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent jellyfin; + local app_name=$CFG_JELLYFIN_APP_NAME + initializeAppVariables $app_name; + fi + + if [[ "$jellyfin" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$jellyfin" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$jellyfin" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$jellyfin" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$jellyfin" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Pulling a default Jellyfin docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start Jellyfin" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your new service using one of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + jellyfin=n +} diff --git a/containers/jellyfin/jellyfin.svg b/containers/jellyfin/jellyfin.svg new file mode 100755 index 0000000..0e56a50 --- /dev/null +++ b/containers/jellyfin/jellyfin.svg @@ -0,0 +1 @@ + diff --git a/containers/jitsimeet/docker-compose.yml b/containers/jitsimeet/docker-compose.yml new file mode 100644 index 0000000..cf05fe5 --- /dev/null +++ b/containers/jitsimeet/docker-compose.yml @@ -0,0 +1,125 @@ + +networks: + meet.jitsi: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + # Frontend + jitsimeet-service: #LIBREPORTAL|SERVICE_TAG_1|jitsimeet-service + container_name: jitsimeet-service + image: jitsi/web:stable + volumes: + - ${CONFIG}/web:/config + - ${CONFIG}/transcripts:/usr/share/jitsi-meet/transcripts + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - DISABLE_HTTPS + - PUBLIC_URL:https://DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + - XMPP_DOMAIN:meet.jitsi + - ENABLE_GUESTS + - ENABLE_P2P + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.jitsimeet-service.entrypoints: web,websecure + traefik.http.routers.jitsimeet-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.jitsimeet-service.tls: true + traefik.http.routers.jitsimeet-service.tls.certresolver: production + traefik.http.services.jitsimeet-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.jitsimeet-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 + meet.jitsi: + aliases: + - meet.jitsi + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + + # XMPP server + jitsimeet-prosody: #LIBREPORTAL|SERVICE_TAG_2|jitsimeet-prosody + image: jitsi/prosody:stable + container_name: jitsimeet-prosody + expose: + - '5222' + - '5347' + - '5280' + volumes: + - ${CONFIG}/prosody:/config + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - AUTH_TYPE:internal + - ENABLE_GUESTS + - ENABLE_LOBBY + - XMPP_DOMAIN:meet.jitsi + - XMPP_AUTH_DOMAIN:auth.meet.jitsi + - XMPP_MUC_DOMAIN:muc.meet.jitsi + - JICOFO_COMPONENT_SECRET + - JICOFO_AUTH_USER + - JICOFO_AUTH_PASSWORD + - JVB_AUTH_USER + - JVB_AUTH_PASSWORD + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2 + meet.jitsi: + aliases: + - jitsimeet-prosody + + # Focus component + jitsimeet-jicofo: #LIBREPORTAL|SERVICE_TAG_3|jitsimeet-jicofo + image: jitsi/jicofo:stable + container_name: jitsimeet-jicofo + volumes: + - ${CONFIG}/jicofo:/config + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - ENABLE_CODEC_VP8 + - ENABLE_CODEC_VP9 + - ENABLE_CODEC_H264 + - JICOFO_COMPONENT_SECRET + - JICOFO_AUTH_USER + - JICOFO_AUTH_PASSWORD + - XMPP_DOMAIN:meet.jitsi + - XMPP_AUTH_DOMAIN:auth.meet.jitsi + - XMPP_MUC_DOMAIN:muc.meet.jitsi + - XMPP_SERVER:jitsimeet-prosody + depends_on: + - jitsimeet-prosody + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_3 #LIBREPORTAL|IP_TAG_3|IP_DATA_3 + meet.jitsi: + + # Video bridge + jitsimeet-jvb: #LIBREPORTAL|SERVICE_TAG_4|jitsimeet-jvb + image: jitsi/jvb:stable + container_name: jitsimeet-jvb + ports: + - "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2 + - "PORTS_DATA_3" #LIBREPORTAL|PORTS_TAG_3|PORTS_DATA_3 + volumes: + - ${CONFIG}/jvb:/config + environment: + - TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + - XMPP_AUTH_DOMAIN:auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN:internal-muc.meet.jitsi + - XMPP_SERVER:jitsimeet-prosody + - JVB_AUTH_USER + - JVB_AUTH_PASSWORD + - JVB_BREWERY_MUC:jvbbrewery + - JVB_PORT:PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2 + - JVB_TCP_PORT:PORT_INTERNAL_DATA_3 #LIBREPORTAL|PORT_INTERNAL_TAG_3|PORT_INTERNAL_DATA_3 + - PUBLIC_URL:https://DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + depends_on: + - jitsimeet-prosody + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_4 #LIBREPORTAL|IP_TAG_4|IP_DATA_4 + meet.jitsi: diff --git a/containers/jitsimeet/jitsimeet.config b/containers/jitsimeet/jitsimeet.config new file mode 100755 index 0000000..497cfb5 --- /dev/null +++ b/containers/jitsimeet/jitsimeet.config @@ -0,0 +1,64 @@ +# +# ============================================================================= +# GENERAL CONFIGURATION +# ============================================================================= +# APP_NAME = name of application for use in scripts +# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is +# BACKUP = if true, include this application in backup operations +# HEALTHCHECK = if true, default docker health checks for that container will be enabled +# AUTHELIA = if true, use Authelia authentication, if false turned off. +# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote +# +CFG_JITSIMEET_APP_NAME=jitsimeet +CFG_JITSIMEET_BACKUP=true +CFG_JITSIMEET_COMPOSE_FILE=default +CFG_JITSIMEET_HEALTHCHECK=true +CFG_JITSIMEET_AUTHELIA=false +CFG_JITSIMEET_HEADSCALE=false +# +# ============================================================================= +# METADATA +# ============================================================================= +# CATEGORY = application category for grouping +# TITLE = display name for the application +# DESCRIPTION = short description of the application +# LONG_DESCRIPTION = detailed description of the application +# URL = source repository or documentation URL +# ACTIONS = available actions for this application +# +CFG_JITSIMEET_CATEGORY="communication" +CFG_JITSIMEET_TITLE="Jitsi Meet" +CFG_JITSIMEET_DESCRIPTION="Video Conferencing" +CFG_JITSIMEET_LONG_DESCRIPTION="Jitsi Meet is an open-source video conferencing solution that provides secure, easy, and high-quality video meetings for everyone" +CFG_JITSIMEET_URL="https://github.com/jitsi/jitsi-meet" +CFG_JITSIMEET_ACTIONS="configure|install|restart|shutdown|uninstall" +# +# ============================================================================= +# NETWORK CONFIGURATION +# ============================================================================= +# DOMAIN = number of domain from the general config, useful when using multiple domains +# HOST_NAME = subdomain name e.g test is the name for test.website.com +# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all +# +CFG_JITSIMEET_DOMAIN=1 +CFG_JITSIMEET_WHITELIST=false +CFG_JITSIMEET_HOST_NAME=meet +CFG_JITSIMEET_NETWORK=default +# +# ============================================================================= +# PORT CONFIGURATION +# ============================================================================= +# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description +# - app: application name +# - name: service identifier (webui, dns, ssh, etc.) +# - external:internal: port mapping (external can be 'random' for auto-allocation) +# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running) +# - protocol: 'tcp' or 'udp' +# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true) +# - traefik: if true, Traefik handles this port (reverse proxy) +# - webui: if true, this port serves the main web interface +# - description: human-readable description of the service +# +CFG_JITSIMEET_PORT_1="jitsimeet-service|webui|random:80|public|tcp|false|true|true|Web Interface|" +CFG_JITSIMEET_PORT_2="jitsimeet-jvb|video-bridge|random:10000|public|udp|false|false|false|Jitsi Video Bridge (UDP)|" +CFG_JITSIMEET_PORT_3="jitsimeet-jvb|video-tcp|random:30300|public|tcp|false|false|false|Jitsi Video Bridge (TCP)|" diff --git a/containers/jitsimeet/jitsimeet.sh b/containers/jitsimeet/jitsimeet.sh new file mode 100755 index 0000000..c1dadc2 --- /dev/null +++ b/containers/jitsimeet/jitsimeet.sh @@ -0,0 +1,202 @@ +#!/bin/bash + +# Category : Communication & Collaboration Tools +# Description : Jitsi Meet - Video Conferencing *UNFINISHED* (c/u/s/r/i): + +installJitsimeet() +{ + local config_variables="$1" + + if [[ "$jitsimeet" == *[cCtTuUsSrRiI]* ]]; then + dockerConfigSetupToContainer silent jitsimeet; + local app_name=$CFG_JITSIMEET_APP_NAME + git_url=$CFG_JITSIMEET_GIT + initializeAppVariables $app_name; + fi + + if [[ "$jitsimeet" == *[cC]* ]]; then + editAppConfig $app_name; + fi + + if [[ "$jitsimeet" == *[uU]* ]]; then + dockerUninstallApp $app_name; + fi + + if [[ "$jitsimeet" == *[sS]* ]]; then + dockerComposeDown $app_name; + fi + + if [[ "$jitsimeet" == *[rR]* ]]; then + dockerComposeRestart $app_name; + fi + + if [[ "$jitsimeet" == *[iI]* ]]; then + isHeader "Install $app_name" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up install folder and config file for $app_name." + echo "" + + dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"; + isSuccessful "Install folders and Config files have been setup for $app_name." + + ((menu_number++)) + echo "" + + + ((menu_number++)) + echo "" + echo "---- $menu_number. Downloading latest GitHub release" + echo "" + + latest_tag=$(git ls-remote --refs --sort="version:refname" --tags $git_url | cut -d/ -f3- | tail -n1) + echo "The latest tag is: $latest_tag" + + local result=$(createFolders "loud" $docker_install_user $containers_dir$app_name) + checkSuccess "Creating $app_name container installation folder" + local result=$(cd $containers_dir$app_name && sudo rm -rf $containers_dir$app_name/$latest_tag.zip) + checkSuccess "Deleting zip file to prevent conflicts" + local result=$(createTouch $containers_dir$app_name/$latest_tag.txt $docker_install_user && echo 'Installed "$latest_tag" on "$backupDate"!' > $latest_tag.txt) + checkSuccess "Create logging txt file" + + + # Download files and unzip + local result=$(sudo wget -O $containers_dir$app_name/$latest_tag.zip $git_url/archive/refs/tags/$latest_tag.zip) + checkSuccess "Downloading tagged zip file from GitHub" + local result=$(sudo unzip -o $containers_dir$app_name/$latest_tag.zip -d $containers_dir$app_name) + checkSuccess "Unzip downloaded file" + local result=$(sudo mv $containers_dir$app_name/docker-jitsi-meet-$latest_tag/* $containers_dir$app_name) + checkSuccess "Moving all files from zip file to install directory" + local result=$(sudo rm -rf $containers_dir$app_name/$latest_tag.zip && sudo rm -rf $containers_dir$app_name/$latest_tag/) + checkSuccess "Removing downloaded zip file as no longer needed" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up the $app_name docker-compose.yml file." + echo "" + + dockerComposeSetupFile $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating file permissions before starting." + echo "" + + fixPermissionsBeforeStart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Setting up .env file for setup" + echo "" + + dockerSetupEnvFile; + + # Updating custom .env values + local result=$(sudo sed -i "s|CONFIG=~/.jitsi-meet-cfg|CONFIG=$containers_dir$app_name/.jitsi-meet-cfg|g" $containers_dir$app_name/.env) + checkSuccess "Updating .env file with new install path" + + local result=$(sudo sed -i "s|#PUBLIC_URL=https://meet.example.com|PUBLIC_URL=https://$host_setup|g" $containers_dir$app_name/.env) + checkSuccess "Updating .env file with Public URL to $host_setup" + + local result=$(sudo sed -i "s|HTTP_PORT=8000|HTTP_PORT=$usedport1|g" $containers_dir$app_name/.env) + checkSuccess "Updating .env file with HTTP_PORT to $usedport1" + + local result=$(sudo sed -i "s|HTTPS_PORT=8443|HTTPS_PORT=$usedport2|g" $containers_dir$app_name/.env) + checkSuccess "Updating .env file with HTTP_PORT to $usedport2" + + #local result=$(echo "ENABLE_HTTP_REDIRECT=1" | sudo tee -a "$containers_dir$app_name/.env") + #checkSuccess "Updating .env file with option : ENABLE_HTTP_REDIRECT" + + # Values are missing from the .env by default for some reason + # https://github.com/jitsi/docker-jitsi-meet/commit/12051700562d9826f9e024ad649c4dd9b88f94de#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5 + local result=$(echo "XMPP_DOMAIN=meet.jitsi" | sudo tee -a "$containers_dir$app_name/.env") + checkSuccess "Updating .env file with missing option : XMPP_DOMAIN" + + local result=$(echo "XMPP_SERVER=xmpp.meet.jitsi" | sudo tee -a "$containers_dir$app_name/.env") + checkSuccess "Updating .env file with missing option : XMPP_SERVER" + + local result=$(echo "JVB_PORT=$usedport4" | sudo tee -a "$containers_dir$app_name/.env") + checkSuccess "Updating .env file with missing option : JVB_PORT" + + local result=$(echo "JVB_TCP_MAPPED_PORT=$usedport5" | sudo tee -a "$containers_dir$app_name/.env") + checkSuccess "Updating .env file with missing option : JVB_TCP_MAPPED_PORT" + + local result=$(echo "JVB_TCP_PORT=$usedport5" | sudo tee -a "$containers_dir$app_name/.env") + checkSuccess "Updating .env file with missing option : JVB_TCP_PORT" + + local result=$(cd "$containers_dir$app_name" && sudo ./gen-passwords.sh) + checkSuccess "Running Jitsi Meet gen-passwords.sh script" + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name" + echo "" + + dockerComposeUpdateAndStartApp $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adjusting $app_name docker system files for port changes." + echo "" + + #dockerCommandRun "docker exec -it $app_name /bin/bash && cd /" + + #local result=$(sudo sed -i "s|80|$usedport1|g" $containers_dir$app_nameweb/default) + #checkSuccess "Updating Docker NGINX default site port 80 to $usedport1" + + #local result=$(sudo sed -i "s|443|$usedport2|g" $containers_dir$app_nameweb/default) + #checkSuccess "Updating Docker NGINX default site port 443 to $usedport2" + + local result=$(sudo sed -i "s|80|$usedport1|g" $containers_dir$app_name/web/rootfs/defaults/default) + checkSuccess "Updating NGINX default site port 80 to $usedport1" + + local result=$(sudo sed -i "s|443|$usedport2|g" $containers_dir$app_name/web/rootfs/defaults/default) + checkSuccess "Updating NGINX default site port 443 to $usedport2" + + #dockerCommandRun "docker cp '$containers_dir$app_name' '$app_name:/etc/nginx/sites-available/default'" + dockerComposeRestart $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Application specific updates (if required)" + echo "" + + appUpdateSpecifics $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Running Headscale setup (if required)" + echo "" + + setupHeadscale $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Adding $app_name to the Apps Database table." + echo "" + + databaseInstallApp $app_name; + + ((menu_number++)) + echo "" + echo "---- $menu_number. Updating WebUI config file." + echo "" + + webuiContainerSetup $app_name install; + + ((menu_number++)) + echo "" + echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name" + echo "" + echo " You can now navigate to your new service using one of the options below : " + echo "" + + menuShowFinalMessages $app_name; + + menu_number=0 + #sleep 3s + cd + fi + jitsimeet=n +} diff --git a/containers/jitsimeet/jitsimeet.svg b/containers/jitsimeet/jitsimeet.svg new file mode 100755 index 0000000..5a3526a --- /dev/null +++ b/containers/jitsimeet/jitsimeet.svg @@ -0,0 +1,650 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/.dockerignore b/containers/libreportal/.dockerignore new file mode 100644 index 0000000..c328db4 --- /dev/null +++ b/containers/libreportal/.dockerignore @@ -0,0 +1,10 @@ +backend/node_modules +backend/npm-debug.log +frontend +libreportal.config +libreportal.sh +libreportal.svg +docker-compose.yml +.git +.gitignore +*.md diff --git a/containers/libreportal/Dockerfile b/containers/libreportal/Dockerfile new file mode 100755 index 0000000..f79c96b --- /dev/null +++ b/containers/libreportal/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY ./backend/package.json ./backend/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund + +COPY ./backend/server.js ./backend/server.js +COPY ./backend/utils ./backend/utils/ +COPY ./backend/routes ./backend/routes/ + +EXPOSE 1111 + +CMD ["node", "backend/server.js"] diff --git a/containers/libreportal/backend/package-lock.json b/containers/libreportal/backend/package-lock.json new file mode 100755 index 0000000..73125ca --- /dev/null +++ b/containers/libreportal/backend/package-lock.json @@ -0,0 +1,951 @@ +{ + "name": "libreportal-web-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "libreportal-web-backend", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "express": "^4.17.1", + "jsonwebtoken": "^9.0.2", + "ssh2": "^0.8.9" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ssh2": { + "version": "0.8.9", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-0.8.9.tgz", + "integrity": "sha512-GmoNPxWDMkVpMFa9LVVzQZHF6EW3WKmBwL+4/GeILf2hFmix5Isxm7Amamo8o7bHiU0tC+wXsGcUXOxp8ChPaw==", + "dependencies": { + "ssh2-streams": "~0.4.10" + }, + "engines": { + "node": ">=5.2.0" + } + }, + "node_modules/ssh2-streams": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.4.10.tgz", + "integrity": "sha512-8pnlMjvnIZJvmTzUIIA5nT4jr2ZWNNVHwyXfMGdRJbug9TpI3kd99ffglgfSWqujVv/0gxwMsDn9j9RVst8yhQ==", + "dependencies": { + "asn1": "~0.2.0", + "bcrypt-pbkdf": "^1.0.2", + "streamsearch": "~0.1.2" + }, + "engines": { + "node": ">=5.2.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/containers/libreportal/backend/package.json b/containers/libreportal/backend/package.json new file mode 100755 index 0000000..81d76e2 --- /dev/null +++ b/containers/libreportal/backend/package.json @@ -0,0 +1,12 @@ +{ + "name": "libreportal-web-backend", + "version": "1.0.0", + "main": "server.js", + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "express": "^4.17.1", + "jsonwebtoken": "^9.0.2", + "ssh2": "^0.8.9" + } +} diff --git a/containers/libreportal/backend/routes/auth-routes.js b/containers/libreportal/backend/routes/auth-routes.js new file mode 100755 index 0000000..14842fa --- /dev/null +++ b/containers/libreportal/backend/routes/auth-routes.js @@ -0,0 +1,92 @@ +const express = require('express'); +const router = express.Router(); +const { generateToken, verifyToken, verifyPassword, getCredentials } = require('../utils/auth.js'); + +const COOKIE_NAME = 'libreportal_token'; +const COOKIE_OPTS = { + httpOnly: true, + sameSite: 'strict', + maxAge: 30 * 24 * 60 * 60 * 1000 +}; + +// Per-IP login rate limit: 10 attempts per 15 minutes. Lockout doubles each subsequent +// trip so a sustained attacker hits exponentially long waits. +const LOGIN_WINDOW_MS = 15 * 60 * 1000; +const LOGIN_MAX_ATTEMPTS = 10; +const loginAttempts = new Map(); // ip -> { count, firstAttempt, lockedUntil } + +function checkLoginRate(ip) { + const now = Date.now(); + const entry = loginAttempts.get(ip); + if (!entry) return { allowed: true }; + if (entry.lockedUntil && now < entry.lockedUntil) { + return { allowed: false, retryAfter: Math.ceil((entry.lockedUntil - now) / 1000) }; + } + if (now - entry.firstAttempt > LOGIN_WINDOW_MS) { + loginAttempts.delete(ip); + } + return { allowed: true }; +} + +function recordFailedLogin(ip) { + const now = Date.now(); + const entry = loginAttempts.get(ip) || { count: 0, firstAttempt: now, lockedUntil: 0 }; + entry.count += 1; + if (entry.count >= LOGIN_MAX_ATTEMPTS) { + const prevLockMs = entry.lockedUntil ? entry.lockedUntil - entry.firstAttempt : 0; + const lockMs = Math.max(LOGIN_WINDOW_MS, prevLockMs * 2); + entry.lockedUntil = now + lockMs; + } + loginAttempts.set(ip, entry); +} + +function clearLoginAttempts(ip) { + loginAttempts.delete(ip); +} + +router.post('/login', async (req, res) => { + const ip = req.ip || req.socket?.remoteAddress || 'unknown'; + const rate = checkLoginRate(ip); + if (!rate.allowed) { + res.setHeader('Retry-After', rate.retryAfter); + return res.status(429).json({ error: 'Too many login attempts. Try again later.' }); + } + + const { username, password } = req.body || {}; + if (!username || !password) { + return res.status(400).json({ error: 'Username and password required' }); + } + try { + const creds = getCredentials(); + const usernameMatch = username === creds.username; + const passwordMatch = await verifyPassword(password, creds.passwordHash); + if (!usernameMatch || !passwordMatch) { + recordFailedLogin(ip); + // Constant-time delay to prevent timing attacks + await new Promise(r => setTimeout(r, 500)); + return res.status(401).json({ error: 'Invalid credentials' }); + } + clearLoginAttempts(ip); + const token = generateToken(username); + res.cookie(COOKIE_NAME, token, COOKIE_OPTS); + res.json({ success: true, username }); + } catch (error) { + console.error('[Auth] Login error:', error.message); + res.status(500).json({ error: 'Internal error' }); + } +}); + +router.post('/logout', (req, res) => { + res.clearCookie(COOKIE_NAME); + res.json({ success: true }); +}); + +router.get('/status', (req, res) => { + const token = req.cookies?.[COOKIE_NAME]; + if (!token) return res.json({ authenticated: false }); + const payload = verifyToken(token); + if (!payload) return res.json({ authenticated: false }); + res.json({ authenticated: true, username: payload.sub }); +}); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/routes.js b/containers/libreportal/backend/routes/routes.js new file mode 100755 index 0000000..b0ba12f --- /dev/null +++ b/containers/libreportal/backend/routes/routes.js @@ -0,0 +1,224 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const config = require('../utils/config.js'); +const { requireAuth } = require('../utils/middleware.js'); + +const PATHS = { + FRONTEND_DATA: path.join(__dirname, '../../frontend/data'), + BASE_DIR: path.join(__dirname, '../../frontend/data') +}; + +const themeRoutes = require('./theme.js'); +const themesRoutes = require('./themes.js'); +const authRoutes = require('./auth-routes.js'); +const taskRoutes = require('./task-routes.js'); +const serviceRoutes = require('./service-routes.js'); +const setupRoutes = require('./setup-routes.js'); +const { testConnection } = require('../utils/mail.js'); + +module.exports = { + setup: (app) => { + // Auth routes — public (no requireAuth) + app.use('/api/auth', authRoutes); + // Theme discovery is public so the login overlay can pick the right + // palette before the user logs in. + app.use('/api/themes', themesRoutes); + + // Protected API routes + app.use('/api/theme', requireAuth, themeRoutes); + app.use('/api/tasks', taskRoutes); // requireAuth applied per-route inside + app.use('/api/apps', serviceRoutes); // requireAuth applied per-route inside + app.use('/api/setup', setupRoutes); // requireAuth applied per-route inside + app.post('/api/test-mail-connection', requireAuth, testConnection); + + app.post('/api/gluetun/mullvad-wireguard', requireAuth, async (req, res) => { + try { + const account = String(req.body?.accountNumber || '').replace(/\s+/g, ''); + if (!/^\d{16}$/.test(account)) { + return res.status(400).json({ success: false, error: 'Account number must be 16 digits.' }); + } + + const cryptoMod = require('crypto'); + const kp = cryptoMod.generateKeyPairSync('x25519'); + const pkcs8 = kp.privateKey.export({ format: 'der', type: 'pkcs8' }); + const spki = kp.publicKey.export({ format: 'der', type: 'spki' }); + const privateKey = pkcs8.subarray(pkcs8.length - 32).toString('base64'); + const publicKey = spki.subarray(spki.length - 32).toString('base64'); + + const body = new URLSearchParams({ account, pubkey: publicKey }).toString(); + const upstream = await fetch('https://api.mullvad.net/wg/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }); + const text = (await upstream.text()).trim(); + + if (!upstream.ok || !text) { + return res.status(502).json({ + success: false, + error: text || `Mullvad API returned ${upstream.status}.` + }); + } + if (/account/i.test(text) && /(invalid|not.*found|expired)/i.test(text)) { + return res.status(400).json({ success: false, error: text }); + } + + const ipv4Only = text + .split(',') + .map((s) => s.trim()) + .filter((s) => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(s)) + .join(','); + + res.json({ + success: true, + privateKey, + publicKey, + addresses: ipv4Only || text + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + app.post('/write-file', requireAuth, async (req, res) => { + try { + const { path: filePath, content } = req.body; + const fsPromises = require('fs').promises; + const pathModule = require('path'); + + const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath); + if (!fullPath.startsWith(PATHS.BASE_DIR)) { + return res.status(403).json({ success: false, error: 'Access denied' }); + } + + const dir = pathModule.dirname(fullPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + await fsPromises.writeFile(fullPath, content, 'utf8'); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + app.get('/read-file', requireAuth, (req, res) => { + try { + const { path: filePath, position } = req.query; + const pathModule = require('path'); + + const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath); + if (!fullPath.startsWith(PATHS.BASE_DIR)) { + return res.status(403).json({ success: false, error: 'Access denied' }); + } + + const fsPromises = require('fs').promises; + + const handleReadError = (error) => { + if (error.code === 'ENOENT') { + return res.status(404).json({ success: false, error: 'File not found' }); + } + res.status(500).json({ success: false, error: error.message }); + }; + + if (position !== undefined) { + const pos = parseInt(position) || 0; + fsPromises.readFile(fullPath, 'utf8') + .then(data => res.send(data.substring(pos))) + .catch(handleReadError); + } else { + fsPromises.readFile(fullPath, 'utf8') + .then(data => res.send(data)) + .catch(handleReadError); + } + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // Batch task loading + app.post('/read-tasks-batch', requireAuth, async (req, res) => { + try { + const { taskIds } = req.body; + const fsPromises = require('fs').promises; + + if (!Array.isArray(taskIds)) { + return res.status(400).json({ success: false, error: 'taskIds must be an array' }); + } + + const loadPromises = taskIds.map(async (taskId) => { + try { + const taskFilePath = path.join(PATHS.FRONTEND_DATA, 'tasks', `${taskId}.json`); + const taskData = await fsPromises.readFile(taskFilePath, 'utf8'); + return JSON.parse(taskData); + } catch { + return null; + } + }); + + const results = await Promise.all(loadPromises); + res.json(results.filter(Boolean)); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // Directory listing + app.get('/read-directory', requireAuth, (req, res) => { + try { + const { path: dirPath } = req.query; + const fullPath = path.join(PATHS.FRONTEND_DATA, dirPath); + + if (!fullPath.startsWith(PATHS.BASE_DIR)) { + return res.status(403).json({ success: false, error: 'Access denied' }); + } + + try { + const stats = fs.statSync(fullPath); + if (!stats.isDirectory()) { + return res.status(400).json({ success: false, error: 'Not a directory' }); + } + } catch { + return res.status(404).json({ success: false, error: 'Directory not found' }); + } + + fs.readdir(fullPath, (err, files) => { + if (err) return res.status(500).json({ success: false, error: err.message }); + res.json(files || []); + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // File delete (task files only) + app.post('/delete-file', requireAuth, async (req, res) => { + try { + const { path: filePath } = req.body; + const pathModule = require('path'); + const fsPromises = require('fs').promises; + + const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath); + if (!fullPath.startsWith(PATHS.BASE_DIR)) { + return res.status(403).json({ success: false, error: 'Access denied' }); + } + + if (!filePath.startsWith('task_') || !filePath.endsWith('.json')) { + return res.status(403).json({ success: false, error: 'Only task files can be deleted' }); + } + + await fsPromises.unlink(fullPath); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // SPA fallback — must be last + app.get('*', (req, res) => { + res.sendFile(path.join(config.FRONTEND_PATH, 'index.html')); + }); + } +}; diff --git a/containers/libreportal/backend/routes/service-routes.js b/containers/libreportal/backend/routes/service-routes.js new file mode 100644 index 0000000..b51c683 --- /dev/null +++ b/containers/libreportal/backend/routes/service-routes.js @@ -0,0 +1,565 @@ +// Per-app service routes. +// +// The "Services" tab on the app page asks: for app X, what compose +// services are defined, are they running, and how long have they been +// up — plus give me a restart button and a live log tail. +// +// Implementation notes: +// - The libreportal-service container does NOT have the `docker` CLI +// installed; it only has the docker socket bind-mounted. So instead +// of shelling out to `docker`, we talk to the Docker Engine HTTP API +// directly over the unix socket. That means no extra system deps and +// no group-level privilege grants — node only sees what the mounted +// socket lets it see. +// - Restart still goes through the existing task system. The bash task +// processor runs on the host (where `docker` IS available) so its +// `docker compose restart …` command works fine. +// - URLs / port chips for each service are read client-side from the +// existing /data/apps/generated/apps-services.json — no backend +// surface needed for that. + +const express = require('express'); +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const http = require('http'); +const { spawn } = require('child_process'); +const { requireAuth } = require('../utils/middleware.js'); +const { pokeFifo } = require('../utils/fifo.js'); +const { fileConfig } = require('../utils/config.js'); + +const router = express.Router(); + +const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks'); +const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo'); +const CONTAINERS_DIR = '/docker/containers'; +const APPS_SERVICES_JSON = path.join(__dirname, '..', '..', 'frontend', 'data', 'apps', 'generated', 'apps-services.json'); + +// ===================================================================== +// Docker socket discovery +// ===================================================================== +// Whichever socket the host bind-mounted into us — that's the one we +// can reach. Rooted installs mount /var/run/docker.sock; rootless mounts +// /run/user//docker.sock. No fallback to a docker group, no sudo, +// no daemon auth tokens — just the unix socket the host already chose +// to expose. +function detectDockerSocket() { + if (fs.existsSync('/var/run/docker.sock')) return '/var/run/docker.sock'; + try { + for (const entry of fs.readdirSync('/run/user', { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const sock = `/run/user/${entry.name}/docker.sock`; + if (fs.existsSync(sock)) return sock; + } + } catch { /* /run/user not readable — that's fine */ } + return null; +} + +const DOCKER_SOCKET = detectDockerSocket(); +console.log( + DOCKER_SOCKET + ? `[services] Docker API socket: ${DOCKER_SOCKET}` + : '[services] WARNING: no docker socket found — services tab will be empty' +); + +// ===================================================================== +// Tiny Docker HTTP API client +// ===================================================================== +// The Docker daemon speaks HTTP/1.1 over a unix socket. Versioning is +// pinned to v1.41 (Docker 20.10+, far older than anything this project +// supports). +const DOCKER_API_VERSION = 'v1.41'; + +function dockerRequest(method, pathname, query) { + return new Promise((resolve, reject) => { + if (!DOCKER_SOCKET) return reject(new Error('No docker socket available')); + const qs = query ? '?' + new URLSearchParams(query).toString() : ''; + const req = http.request( + { + socketPath: DOCKER_SOCKET, + method, + path: `/${DOCKER_API_VERSION}${pathname}${qs}`, + headers: { 'Host': 'docker', 'Accept': 'application/json' } + }, + (res) => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + if (res.statusCode >= 200 && res.statusCode < 300) { + try { resolve(body ? JSON.parse(body) : null); } + catch (e) { reject(new Error(`Docker API parse error: ${e.message}`)); } + } else { + reject(new Error(`Docker API ${res.statusCode}: ${body}`)); + } + }); + } + ); + req.on('error', reject); + req.end(); + }); +} + +// Streaming GET — caller gets the raw IncomingMessage so they can pipe +// or parse the multiplexed log frames. +function dockerStream(pathname, query) { + return new Promise((resolve, reject) => { + if (!DOCKER_SOCKET) return reject(new Error('No docker socket available')); + const qs = query ? '?' + new URLSearchParams(query).toString() : ''; + const req = http.request( + { + socketPath: DOCKER_SOCKET, + method: 'GET', + path: `/${DOCKER_API_VERSION}${pathname}${qs}`, + headers: { 'Host': 'docker' } + }, + (res) => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve({ stream: res, req }); + } else { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => reject(new Error( + `Docker API ${res.statusCode}: ${Buffer.concat(chunks).toString('utf8')}` + ))); + } + } + ); + req.on('error', reject); + req.end(); + }); +} + +// Map Docker's verbose state info to a UX-friendly status line. +// running → "Up 2 hours" +// exited → "Exited (0) 5 minutes ago" +// restarting→ "Restarting" +function statusLineFromContainer(c) { + // `Status` from /containers/json is already exactly the human form + // we want ("Up 4 minutes", "Exited (0) 2 hours ago", etc.). + return c.Status || c.State || ''; +} + +// ===================================================================== +// Validation helpers +// ===================================================================== +const SAFE_NAME = /^[a-zA-Z0-9_.-]+$/; +function safeName(name) { return typeof name === 'string' && SAFE_NAME.test(name); } + +// SSE-wrap `tail -F -n ` and emit `log` events line-by-line so +// the frontend renders host logs through the existing viewer with zero +// changes. We use file-based tailing instead of journalctl because the +// libreportal container is Alpine-based and journalctl plumbing into a +// non-systemd container is heavier than the value. CrowdSec writes +// /var/log/crowdsec.log and /var/log/crowdsec-firewall-bouncer.log by +// default — the libreportal compose bind-mounts /var/log:/host/var/log:ro +// so log paths in apps-services.json carry the /host prefix. +// +// -F (not -f): retries on missing files and follows log rotation, so a +// briefly-absent file (e.g., before the agent has started) doesn't kill the +// stream. +// Stream bounds — keep tail from forking forever and a chatty log from +// drowning the SSE channel. All three are user-configurable via +// configs/webui/webui_logs; 0 disables the limit (max-duration is the only +// one where 0 is dangerous — left to the operator's judgement). +function streamLimitsFromConfig() { + const idleMin = Number(fileConfig.CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES); + const maxMin = Number(fileConfig.CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES); + const lps = Number(fileConfig.CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC); + return { + idleMs: Number.isFinite(idleMin) && idleMin >= 0 ? idleMin * 60_000 : 10 * 60_000, + maxMs: Number.isFinite(maxMin) && maxMin >= 0 ? maxMin * 60_000 : 60 * 60_000, + maxLps: Number.isFinite(lps) && lps > 0 ? lps : 200 + }; +} + +function streamHostLogFile(unit, logFile, tail, res, send, ping) { + // Whitelist: paths must live under the bind-mounted /host/var/log/ tree + // to prevent a malformed apps-services.json from reading anywhere on + // disk. apps-services.json itself is generator-produced, but defence in + // depth. + if (typeof logFile !== 'string' || !logFile.startsWith('/host/var/log/') || logFile.includes('..')) { + send('error', { message: `Refusing to tail untrusted log path: ${logFile}` }); + send('end', { code: 400 }); + clearInterval(ping); + return res.end(); + } + const limits = streamLimitsFromConfig(); + send('ready', { + at: Date.now(), tail, transport: 'systemd', unit, logFile, + limits: { idleMinutes: limits.idleMs / 60000, maxMinutes: limits.maxMs / 60000, maxLinesPerSec: limits.maxLps } + }); + + const child = spawn('tail', ['-F', '-n', String(tail), logFile], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + // Resource ceilings. cleanup() unwinds everything; called from req-close, + // tail-exit, hard-cap timeout, and idle-disconnect path. + let lastLineAt = Date.now(); + let rateWindowStart = Date.now(); + let rateWindowLines = 0; + let rateDroppedThisWindow = 0; + + // 0 = disabled — skip the timer entirely. + const hardCapTimer = limits.maxMs > 0 ? setTimeout(() => { + send('end', { code: 0, reason: 'max-duration', limitMinutes: limits.maxMs / 60000 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }, limits.maxMs) : null; + + const idleTimer = limits.idleMs > 0 ? setInterval(() => { + if (Date.now() - lastLineAt > limits.idleMs) { + send('end', { code: 0, reason: 'idle-timeout', limitMinutes: limits.idleMs / 60000 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + } + }, 60_000) : null; + + const cleanup = () => { + clearInterval(ping); + if (idleTimer) clearInterval(idleTimer); + if (hardCapTimer) clearTimeout(hardCapTimer); + try { child.kill('SIGTERM'); } catch { /* already gone */ } + }; + res.req.on('close', cleanup); + + // stdout = log lines; stderr usually = "cannot open" notices from tail + // when the file doesn't exist yet — surface as `log` lines too so the + // user sees what's happening without panicking the viewer. + const linebuf = (which) => { + let buf = ''; + return (chunk) => { + buf += chunk.toString('utf8'); + const lines = buf.split('\n'); + buf = lines.pop(); + if (!lines.length) return; + lastLineAt = Date.now(); + + // Rate limit: rolling 1-second window. Lines past the ceiling drop; + // emit a single notice at window-close so the user knows a flood is + // ongoing without us spamming the notice line every iteration. + const now = Date.now(); + if (now - rateWindowStart >= 1000) { + if (rateDroppedThisWindow > 0) { + send('log', { stream: 'meta', lines: [`[rate-limit: ${rateDroppedThisWindow} line(s) dropped in the last second]`] }); + } + rateWindowStart = now; + rateWindowLines = 0; + rateDroppedThisWindow = 0; + } + const remaining = limits.maxLps - rateWindowLines; + if (remaining <= 0) { + rateDroppedThisWindow += lines.length; + return; + } + if (lines.length > remaining) { + send('log', { stream: which, lines: lines.slice(0, remaining) }); + rateDroppedThisWindow += lines.length - remaining; + rateWindowLines = limits.maxLps; + } else { + send('log', { stream: which, lines }); + rateWindowLines += lines.length; + } + }; + }; + child.stdout.on('data', linebuf('stdout')); + child.stderr.on('data', linebuf('stderr')); + + child.on('error', (err) => { + send('error', { message: `tail spawn failed: ${err.message}` }); + send('end', { code: 1 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }); + child.on('exit', (code) => { + send('end', { code: code ?? 0 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }); +} + +// Look up a service entry in apps-services.json (the generator-produced file +// the frontend already consumes). Host-installed apps are emitted by +// webui_services.sh with `transport: 'systemd'` and a `unit` field — that's +// our signal to route logs to journalctl instead of `docker logs`. +// +// The lookup also doubles as an allow-list: we ONLY journalctl units that +// appear in this file, so a caller can't request `journalctl -u +// arbitrary.service`. The names there originate from CFG_*_HOST_SERVICES +// declared in container configs. +async function lookupServiceTransport(appName, serviceName) { + try { + const raw = await fsp.readFile(APPS_SERVICES_JSON, 'utf8'); + const data = JSON.parse(raw); + const entries = Array.isArray(data?.services) ? data.services : []; + for (const s of entries) { + if (s.app !== appName) continue; + if (s.serviceName !== serviceName && s.name !== serviceName) continue; + if (s.transport === 'systemd' && typeof s.unit === 'string') { + return { transport: 'systemd', unit: s.unit, logFile: s.logFile || null }; + } + return { transport: 'docker' }; + } + } catch { /* fall through to docker default */ } + return { transport: 'docker' }; +} + +function appComposeFile(appName) { + return path.join(CONTAINERS_DIR, appName, 'docker-compose.yml'); +} + +// ===================================================================== +// GET /api/apps/:appName/services/status +// → [{ serviceName, state, statusText, containerName, containerId }] +// ===================================================================== +router.get('/:appName/services/status', requireAuth, async (req, res) => { + const { appName } = req.params; + if (!safeName(appName)) return res.status(400).json({ error: 'Invalid app name' }); + + try { + const filters = JSON.stringify({ + label: [`com.docker.compose.project=${appName}`] + }); + const containers = await dockerRequest('GET', '/containers/json', { all: '1', filters }); + + const services = (containers || []) + .map(c => { + const labels = c.Labels || {}; + const serviceName = labels['com.docker.compose.service']; + if (!serviceName) return null; + // c.Names is like ['/libreportal-service'] — strip leading slash. + const containerName = (c.Names && c.Names[0] || '').replace(/^\//, ''); + return { + serviceName, + state: c.State || 'unknown', + statusText: statusLineFromContainer(c), + containerName, + containerId: c.Id + }; + }) + .filter(Boolean); + + // Merge in synthetic host-service entries from apps-services.json. + // webui_services.sh emits transport=systemd rows for HOST_INSTALL apps; + // they don't appear in Docker but should still render on the Services + // tab so the user can see status + tail logs for the host agent(s). + try { + const raw = await fsp.readFile(APPS_SERVICES_JSON, 'utf8'); + const data = JSON.parse(raw); + for (const s of (data?.services || [])) { + if (s.app !== appName) continue; + if (s.transport !== 'systemd') continue; + services.push({ + serviceName: s.serviceName || s.name, + state: s.status === 'active' ? 'running' : 'exited', + statusText: s.status === 'active' ? 'Active (host service)' : 'Inactive (host service)', + containerName: s.unit || s.serviceName, + containerId: null, + transport: 'systemd', + unit: s.unit + }); + } + } catch { /* file may not exist yet on fresh install */ } + + res.json(services); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ===================================================================== +// POST /api/apps/:appName/services/:serviceName/restart +// Creates a task that runs `docker compose restart ` on the +// host. The host has `docker` available; this container does not. +// ===================================================================== +router.post('/:appName/services/:serviceName/restart', requireAuth, async (req, res) => { + const { appName, serviceName } = req.params; + if (!safeName(appName) || !safeName(serviceName)) { + return res.status(400).json({ error: 'Invalid app or service name' }); + } + const compose = appComposeFile(appName); + if (!fs.existsSync(compose)) { + return res.status(404).json({ error: `Compose file not found: ${compose}` }); + } + + const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const task = { + id, + command: `docker compose -f "${compose}" restart "${serviceName}"`, + type: 'service-restart', + app: appName, + config: serviceName, + status: 'queued', + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + heartbeatAt: null, + exitCode: null, + errorMessage: null + }; + + try { + await fsp.mkdir(TASKS_DIR, { recursive: true }); + const taskPath = path.join(TASKS_DIR, `${id}.json`); + const tmp = `${taskPath}.tmp`; + await fsp.writeFile(tmp, JSON.stringify(task, null, 2)); + await fsp.rename(tmp, taskPath); + pokeFifo(FIFO_PATH, id); + res.status(201).json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ===================================================================== +// GET /api/apps/:appName/services/:serviceName/logs +// SSE-wraps the Docker /containers//logs?follow=1 stream. +// Docker multiplexes stdout+stderr into 8-byte-framed chunks unless +// the container has tty=true; we handle both. +// ===================================================================== +router.get('/:appName/services/:serviceName/logs', requireAuth, async (req, res) => { + const { appName, serviceName } = req.params; + if (!safeName(appName) || !safeName(serviceName)) { + return res.status(400).json({ error: 'Invalid app or service name' }); + } + const tail = Math.max(1, Math.min(2000, parseInt(req.query.tail, 10) || 200)); + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders?.(); + + const send = (event, data) => { + try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ } + }; + + // Heartbeat for reverse proxies during quiet logs. + const ping = setInterval(() => { + try { res.write(': ping\n\n'); } catch { /* gone */ } + }, 25_000); + + // Fork: host-installed services (transport=systemd) get journalctl + // instead of `docker logs`. Lookup is via apps-services.json which also + // doubles as the unit-name allow-list — only units declared in + // CFG_*_HOST_SERVICES make it into that file. + const transport = await lookupServiceTransport(appName, serviceName); + if (transport.transport === 'systemd') { + if (!transport.logFile) { + send('error', { message: `Host service ${transport.unit} has no logFile configured.` }); + send('end', { code: 404 }); + clearInterval(ping); + return res.end(); + } + return streamHostLogFile(transport.unit, transport.logFile, tail, res, send, ping); + } + + let containerInspect, logStreamHandle; + + const cleanup = () => { + clearInterval(ping); + try { logStreamHandle?.req.destroy(); } catch { /* already gone */ } + }; + req.on('close', cleanup); + + try { + // 1. Resolve the container that owns this compose service. + const filters = JSON.stringify({ + label: [ + `com.docker.compose.project=${appName}`, + `com.docker.compose.service=${serviceName}` + ] + }); + const containers = await dockerRequest('GET', '/containers/json', { all: '1', filters }); + if (!containers || containers.length === 0) { + send('error', { message: `No container found for ${appName}/${serviceName}` }); + send('end', { code: 404 }); + cleanup(); + return res.end(); + } + const containerId = containers[0].Id; + + // 2. Inspect once to learn whether the container has a TTY (changes + // how the log stream is framed). + containerInspect = await dockerRequest('GET', `/containers/${containerId}/json`); + const hasTty = !!(containerInspect.Config && containerInspect.Config.Tty); + + send('ready', { at: Date.now(), tail, tty: hasTty }); + + // 3. Open the log stream. + logStreamHandle = await dockerStream(`/containers/${containerId}/logs`, { + stdout: '1', + stderr: '1', + follow: '1', + tail: String(tail), + timestamps: '0' + }); + const stream = logStreamHandle.stream; + + if (hasTty) { + // Plain text — just split on newlines. + let buf = ''; + stream.on('data', chunk => { + buf += chunk.toString('utf8'); + const lines = buf.split('\n'); + buf = lines.pop(); + if (lines.length) send('log', { stream: 'stdout', lines }); + }); + stream.on('end', () => { + if (buf) send('log', { stream: 'stdout', lines: [buf] }); + send('end', { code: 0 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }); + } else { + // Multiplexed framing: + // [stream_type:1][0:3][size:4 BE][payload:size] + // stream_type: 1=stdout, 2=stderr (0=stdin, never seen here) + let pending = Buffer.alloc(0); + let stdoutBuf = ''; + let stderrBuf = ''; + + const flush = (which, line) => { + const buf = which === 'stdout' ? stdoutBuf : stderrBuf; + const all = buf + line; + const lines = all.split('\n'); + const tailPart = lines.pop(); + if (which === 'stdout') stdoutBuf = tailPart; else stderrBuf = tailPart; + if (lines.length) send('log', { stream: which, lines }); + }; + + stream.on('data', chunk => { + pending = pending.length ? Buffer.concat([pending, chunk]) : chunk; + while (pending.length >= 8) { + const streamType = pending[0]; + const size = pending.readUInt32BE(4); + if (pending.length < 8 + size) break; // wait for more bytes + const payload = pending.slice(8, 8 + size).toString('utf8'); + pending = pending.slice(8 + size); + flush(streamType === 2 ? 'stderr' : 'stdout', payload); + } + }); + stream.on('end', () => { + if (stdoutBuf) send('log', { stream: 'stdout', lines: [stdoutBuf] }); + if (stderrBuf) send('log', { stream: 'stderr', lines: [stderrBuf] }); + send('end', { code: 0 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }); + } + + stream.on('error', err => { + send('error', { message: err.message }); + cleanup(); + try { res.end(); } catch { /* already done */ } + }); + } catch (err) { + send('error', { message: err.message }); + send('end', { code: 500 }); + cleanup(); + try { res.end(); } catch { /* already done */ } + } +}); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js new file mode 100644 index 0000000..5b8d554 --- /dev/null +++ b/containers/libreportal/backend/routes/setup-routes.js @@ -0,0 +1,253 @@ +// Setup Wizard backend. +// +// Three sync GETs (status / suggest-name / dns-check) plus one async POST +// (save) that hands off to the host task system. The lock file lives at +// /app/frontend/data/.setup_complete — under the existing frontend bind-mount +// so the container can read it and the host's setupApply can write it +// without us having to add a new bind-mount to docker-compose.yml. +// +// Sync endpoints intentionally do NOT round-trip through the task daemon — +// suggest-name and dns-check are pure read-only operations that we +// reimplement in JS, so they return in <50ms instead of waiting for the next +// cron tick. + +const express = require('express'); +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const dns = require('dns').promises; +const https = require('https'); +const { requireAuth } = require('../utils/middleware.js'); +const { pokeFifo } = require('../utils/fifo.js'); + +const router = express.Router(); + +const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks'); +const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo'); +const SETUP_LOCK_FILE = path.join(__dirname, '..', '..', 'frontend', 'data', '.setup_complete'); + +const ADJECTIVES = [ + 'Quantum', 'Neutrino', 'Photon', 'Plasma', 'Quasar', 'Pulsar', 'Tachyon', + 'Boson', 'Fermion', 'Hadron', 'Gluon', 'Muon', 'Higgs', 'Entangled', + 'Singular', 'Warped', 'Tunneling', 'Coherent', 'Superposed', 'Spectral', + 'Orbital', 'Cosmic', 'Stellar', 'Nebular', 'Astral', 'Gravitic', 'Inertial', + 'Relativistic', 'Helical', 'Toroidal', 'Holographic', 'Cryogenic', + 'Crystalline', 'Resonant', 'Harmonic', 'Phasic', 'Drifting', 'Spinning', + 'Pulsing', 'Hyper' +]; + +const NOUNS = [ + 'Frog', 'Fox', 'Otter', 'Raven', 'Wolf', 'Yak', 'Lynx', 'Owl', 'Hawk', + 'Crow', 'Newt', 'Wren', 'Eel', 'Crab', 'Squid', 'Octopus', 'Mantis', + 'Cobra', 'Viper', 'Ferret', 'Badger', 'Penguin', 'Panda', 'Lemur', 'Quark', + 'Nebula', 'Comet', 'Nova', 'Eclipse', 'Aurora', 'Vortex', 'Helix', 'Halo', + 'Phoenix', 'Hydra', 'Kraken', 'Sphinx', 'Specter', 'Phantom', 'Glyph' +]; + +function generateInstallName() { + const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; + return `${adj}${noun}`; +} + +// Install order is enforced server-side. Monitoring goes first so apps +// installing later detect a live Prometheus/Grafana and wire their metrics +// export at install time — Traefik, CrowdSec et al. are monitoring consumers. +// Grafana follows Prometheus because its datasource points at it. +const INSTALL_TIERS = [ + ['prometheus', 'grafana'], + ['traefik', 'crowdsec'] +]; + +function sortAppsByTier(apps) { + const rank = new Map(); + let r = 0; + for (const tier of INSTALL_TIERS) for (const slug of tier) rank.set(slug, r++); + return [...apps].sort((a, b) => { + const ra = rank.has(a) ? rank.get(a) : Infinity; + const rb = rank.has(b) ? rank.get(b) : Infinity; + if (ra !== rb) return ra - rb; + return apps.indexOf(a) - apps.indexOf(b); + }); +} + +function fetchPublicIp() { + return new Promise((resolve) => { + const req = https.get('https://api.ipify.org', { timeout: 3000 }, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve(body.trim() || null)); + }); + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); +} + +router.get('/status', requireAuth, async (req, res) => { + const complete = fs.existsSync(SETUP_LOCK_FILE); + res.json({ complete }); +}); + +router.get('/suggest-name', requireAuth, (req, res) => { + res.set('Cache-Control', 'no-store'); + res.json({ name: generateInstallName() }); +}); + +router.get('/dns-check', requireAuth, async (req, res) => { + const domain = String(req.query.domain || '').trim().toLowerCase(); + if (!domain || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain)) { + return res.status(400).json({ matches: false, error: 'invalid domain' }); + } + + const [serverIp, domainIps] = await Promise.all([ + fetchPublicIp(), + dns.resolve4(domain).catch(() => []) + ]); + + const domainIp = domainIps[0] || null; + const matches = !!(serverIp && domainIp && serverIp === domainIp); + + res.json({ matches, server_ip: serverIp, domain_ip: domainIp }); +}); + +// Each ticked app becomes its own `libreportal app install ` task — +// using the same task type the WebUI's app-install pipeline already +// understands, so the user sees individual progress per app instead of +// one opaque "setup apply" task. The first task writes the configs, the +// last marks the wizard complete; in between, the recommended apps run +// sequentially because the host daemon processes the FIFO in order. +async function enqueueTask(spec) { + const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const task = { + id, + command: spec.command, + type: spec.type, + app: spec.app || 'libreportal', + config: spec.config || 'setup-wizard', + status: 'queued', + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + heartbeatAt: null, + exitCode: null, + errorMessage: null, + setupGroup: spec.setupGroup, + setupRole: spec.setupRole // 'config' | 'app' | 'finalize' + }; + const taskPath = path.join(TASKS_DIR, `${id}.json`); + const tmp = `${taskPath}.tmp`; + await fsp.writeFile(tmp, JSON.stringify(task, null, 2)); + await fsp.rename(tmp, taskPath); + pokeFifo(FIFO_PATH, id); + // Tiny stagger so each task gets a unique Date.now()-based id. + await new Promise(r => setTimeout(r, 2)); + return id; +} + +router.post('/save', requireAuth, async (req, res) => { + const payload = req.body || {}; + + if (!payload.install_name || !/^[a-zA-Z0-9-]+$/.test(payload.install_name)) { + return res.status(400).json({ error: 'invalid install_name' }); + } + if (!payload.timezone) { + return res.status(400).json({ error: 'timezone required' }); + } + + // Domains are optional but each entry must be a valid hostname. Cap at + // 9 because the config schema only has CFG_DOMAIN_1..CFG_DOMAIN_9. + const domainRe = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i; + payload.domains = Array.isArray(payload.domains) + ? payload.domains.map(d => String(d).trim().toLowerCase()).filter(Boolean) + : []; + if (payload.domains.length > 9) payload.domains = payload.domains.slice(0, 9); + for (const d of payload.domains) { + if (!domainRe.test(d)) return res.status(400).json({ error: `invalid domain: ${d}` }); + } + + payload.apps = Array.isArray(payload.apps) ? payload.apps.filter(a => /^[a-z0-9_-]+$/i.test(a)) : []; + payload.apps = sortAppsByTier(payload.apps); + + // Validate appOptions — shape: { : { : bool, ... } } + const optsIn = (payload.appOptions && typeof payload.appOptions === 'object') ? payload.appOptions : {}; + const safeOpts = {}; + for (const [slug, opts] of Object.entries(optsIn)) { + if (!/^[a-z0-9_-]+$/i.test(slug)) continue; + if (!payload.apps.includes(slug)) continue; + if (!opts || typeof opts !== 'object') continue; + safeOpts[slug] = {}; + for (const [k, v] of Object.entries(opts)) { + if (/^[a-z0-9_-]+$/i.test(k) && typeof v === 'boolean') safeOpts[slug][k] = v; + } + } + payload.appOptions = safeOpts; + + const wantsTraefik = payload.apps.includes('traefik'); + if (wantsTraefik) { + if (!payload.traefik_email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.traefik_email)) { + return res.status(400).json({ error: 'traefik_email required when installing Traefik' }); + } + } else { + delete payload.traefik_email; + } + + const setupGroup = `setup_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const b64 = Buffer.from(JSON.stringify(payload)).toString('base64'); + + try { + await fsp.mkdir(TASKS_DIR, { recursive: true }); + const taskIds = []; + + taskIds.push(await enqueueTask({ + command: `libreportal setup config ${b64}`, + type: 'setup-config', + setupGroup, + setupRole: 'config' + })); + + for (const appName of payload.apps) { + // Convert appOptions sub-flags into the framework's config_variables + // arg. Convention: sub-option on app maps to + // CFG___ENABLED. dockerInstallApp parses these and writes + // them into the template config before calling install. + let command = `libreportal app install ${appName}`; + const opts = payload.appOptions[appName] || {}; + const cfgPairs = []; + const slugUpper = appName.toUpperCase().replace(/-/g, '_'); + for (const [optId, value] of Object.entries(opts)) { + if (typeof value !== 'boolean') continue; + cfgPairs.push(`CFG_${slugUpper}_${optId.toUpperCase()}_ENABLED=${value}`); + } + if (cfgPairs.length) command += ` ${cfgPairs.join('|')}`; + + taskIds.push(await enqueueTask({ + command, + type: 'app-install', + app: appName, + setupGroup, + setupRole: 'app' + })); + } + + const finalizeId = await enqueueTask({ + command: `libreportal setup finalize`, + type: 'setup-finalize', + setupGroup, + setupRole: 'finalize' + }); + taskIds.push(finalizeId); + + res.status(201).json({ + setupGroup, + taskIds, + firstTaskId: taskIds[0], + finalizeTaskId: finalizeId, + installName: payload.install_name + }); + } catch (err) { + console.error('[setup] save failed:', err); + res.status(500).json({ error: 'failed to enqueue setup tasks' }); + } +}); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/task-routes.js b/containers/libreportal/backend/routes/task-routes.js new file mode 100755 index 0000000..4a63543 --- /dev/null +++ b/containers/libreportal/backend/routes/task-routes.js @@ -0,0 +1,430 @@ +// Task API + Server-Sent Events feed. +// +// Single source of truth: the task file under FRONTEND_DATA/tasks/.json. +// Status field defines lifecycle (queued -> running -> completed|failed|cancelled). +// We never write `current.json` or `queue.json` from here — those are gone. +// +// Push model: clients subscribe to GET /api/tasks/events (SSE). We watch the +// tasks dir with fs.watch and emit events whenever: +// - a task file is created or modified -> task.upsert (full task object) +// - a task file is deleted -> task.deleted (id only) +// - a task .log file grows -> task.log (taskId, appendedText) +// +// Latency from a bash write to a connected client receiving the event is +// typically under 50ms. + +const express = require('express'); +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const { requireAuth } = require('../utils/middleware.js'); + +const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks'); +const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo'); +const PROCESSOR_LOCK = path.join(TASKS_DIR, '.processor.lock'); + +// ===================================================================== +// SSE HUB +// ===================================================================== +// One Set of `res` objects. Every event goes to all of them. + +const sseClients = new Set(); +let nextClientId = 1; + +function sseBroadcast(event, data) { + if (sseClients.size === 0) return; + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const client of sseClients) { + try { client.res.write(payload); } catch { /* client gone; cleanup below */ } + } +} + +function attachSseClient(req, res) { + const id = nextClientId++; + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders?.(); + + // Initial hello so the client knows the connection is live. + res.write(`event: ready\ndata: ${JSON.stringify({ at: Date.now() })}\n\n`); + + // Periodic comment-ping so intermediaries don't time the connection out. + const ping = setInterval(() => { + try { res.write(': ping\n\n'); } catch { /* will be cleaned on close */ } + }, 25_000); + + const client = { id, res }; + sseClients.add(client); + + req.on('close', () => { + clearInterval(ping); + sseClients.delete(client); + }); +} + +// ===================================================================== +// FILESYSTEM WATCH +// ===================================================================== +// Single dir watcher. Per-file tracking is kept in `logTails` so we know +// where each task's log ended last time we read it (for incremental tail). + +const logTails = new Map(); // taskId -> last position in bytes +const upsertDebounce = new Map(); // filename -> timer (debounce rapid writes) + +async function emitTaskUpsert(filename) { + const id = filename.replace(/\.json$/, ''); + const fullPath = path.join(TASKS_DIR, filename); + try { + const text = await fsp.readFile(fullPath, 'utf8'); + if (!text.trim()) return; + const task = JSON.parse(text); + sseBroadcast('task.upsert', task); + } catch { + // File may have been deleted between watch event and read. Treat as deleted. + sseBroadcast('task.deleted', { id }); + } +} + +// Per-task coalescing. fs.watch fires multiple events for a single append +// (a `change` and sometimes a `rename`), and a naive emitLogTail reads +// `prev` at entry but only writes `logTails.set(...)` after stat+open+read. +// Concurrent invocations therefore see the same `prev`, read the same +// range, and broadcast the chunk twice — clients render duplicate lines. +// State per id: `undefined` = idle, `false` = running, `true` = running and +// another event arrived while running (re-run after current pass). +const tailInflight = new Map(); + +async function emitLogTail(filename) { + const id = filename.replace(/\.log$/, ''); + if (tailInflight.has(id)) { + tailInflight.set(id, true); + return; + } + tailInflight.set(id, false); + try { + while (true) { + await emitLogTailOnce(id, filename); + if (tailInflight.get(id)) { + tailInflight.set(id, false); + continue; + } + break; + } + } finally { + tailInflight.delete(id); + } +} + +async function emitLogTailOnce(id, filename) { + const fullPath = path.join(TASKS_DIR, filename); + let stat; + try { stat = await fsp.stat(fullPath); } catch { return; } + + const prev = logTails.get(id) || 0; + // Truncated? Reset the cursor. + const start = stat.size < prev ? 0 : prev; + if (stat.size === start) return; + + let chunk; + try { + const fh = await fsp.open(fullPath, 'r'); + try { + const buf = Buffer.alloc(stat.size - start); + await fh.read(buf, 0, buf.length, start); + chunk = buf.toString('utf8'); + } finally { await fh.close(); } + } catch { return; } + + logTails.set(id, stat.size); + if (chunk) sseBroadcast('task.log', { id, chunk }); +} + +function startTasksWatcher() { + if (!fs.existsSync(TASKS_DIR)) { + try { fs.mkdirSync(TASKS_DIR, { recursive: true }); } catch {} + } + + // Single recursive=false watch on the tasks dir is enough — task files and + // their .log siblings live there. + try { + fs.watch(TASKS_DIR, { persistent: true }, (eventType, filename) => { + if (!filename) return; + // Skip hidden files (.processor.lock, .queue.fifo, …). + if (filename.startsWith('.')) return; + + // .json -> task upsert/delete + if (filename.startsWith('task_') && filename.endsWith('.json')) { + clearTimeout(upsertDebounce.get(filename)); + upsertDebounce.set(filename, setTimeout(() => { + upsertDebounce.delete(filename); + const fullPath = path.join(TASKS_DIR, filename); + fs.access(fullPath, (err) => { + if (err) { + const id = filename.replace(/\.json$/, ''); + logTails.delete(id); + sseBroadcast('task.deleted', { id }); + } else { + emitTaskUpsert(filename).catch(() => {}); + } + }); + }, 30)); + return; + } + + // .log -> incremental tail + if (filename.startsWith('task_') && filename.endsWith('.log')) { + emitLogTail(filename).catch(() => {}); + return; + } + }); + } catch (err) { + console.error('[tasks] failed to start fs watcher:', err.message); + } +} + +// ===================================================================== +// FIFO WAKE-UP +// ===================================================================== +// Best-effort poke at the bash processor. Never throws: if the FIFO doesn't +// exist or no reader is attached, we ignore the error and rely on the +// processor's idle timeout (≤3s) to pick the task up. + +// Per-task fs.watchFile polling fallback. fs.watch (inotify) is the primary +// notifier but can miss events on Docker bind-mounts; this 1s polling pass +// ensures status flips reach SSE within ~1s even if inotify silently drops. +// Self-disarms once the task hits a terminal state. +const activePolls = new Map(); +function armActiveTaskPoll(taskId) { + if (activePolls.has(taskId)) return; + const filePath = path.join(TASKS_DIR, `${taskId}.json`); + let lastStatus = null; + fs.watchFile(filePath, { interval: 1000 }, () => { + fs.readFile(filePath, 'utf8', (err, data) => { + if (err) { disarmActiveTaskPoll(taskId); return; } + let task; try { task = JSON.parse(data); } catch { return; } + if (task.status !== lastStatus) { + lastStatus = task.status; + sseBroadcast('task.upsert', task); + } + if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { + disarmActiveTaskPoll(taskId); + } + }); + }); + activePolls.set(taskId, filePath); +} +function disarmActiveTaskPoll(taskId) { + const fp = activePolls.get(taskId); + if (!fp) return; + fs.unwatchFile(fp); + activePolls.delete(taskId); +} + +function pokeFifo(taskId) { + fs.open(FIFO_PATH, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK, (err, fd) => { + if (err) return; // No reader / FIFO missing — fine. + fs.write(fd, `${taskId}\n`, () => fs.close(fd, () => {})); + }); +} + +// ===================================================================== +// HELPERS +// ===================================================================== + +function generateTaskId() { + return `task_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} + +function isValidTaskId(id) { + return typeof id === 'string' && /^task_[0-9]+_[a-z0-9]+$/i.test(id); +} + +async function readTask(id) { + const text = await fsp.readFile(path.join(TASKS_DIR, `${id}.json`), 'utf8'); + return JSON.parse(text); +} + +async function writeTaskAtomic(id, task) { + const final = path.join(TASKS_DIR, `${id}.json`); + const tmp = `${final}.tmp.${process.pid}.${Date.now()}`; + await fsp.writeFile(tmp, JSON.stringify(task, null, 2), 'utf8'); + await fsp.rename(tmp, final); +} + +// ===================================================================== +// ROUTES +// ===================================================================== + +const router = express.Router(); + +// SSE feed. Held open for the life of the page. +router.get('/events', requireAuth, (req, res) => { + attachSseClient(req, res); +}); + +// List all tasks (returns lightweight summaries). +router.get('/', requireAuth, async (req, res) => { + try { + const entries = await fsp.readdir(TASKS_DIR); + const out = []; + for (const entry of entries) { + if (!entry.startsWith('task_') || !entry.endsWith('.json')) continue; + try { + const text = await fsp.readFile(path.join(TASKS_DIR, entry), 'utf8'); + if (!text.trim()) continue; + const task = JSON.parse(text); + out.push(task); + } catch { /* skip unreadable entries */ } + } + out.sort((a, b) => String(b.createdAt || b.created_at || '').localeCompare(String(a.createdAt || a.created_at || ''))); + res.json(out); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Read a single task. +router.get('/:id', requireAuth, async (req, res) => { + const { id } = req.params; + if (!isValidTaskId(id)) return res.status(400).json({ error: 'Invalid task id' }); + try { + const task = await readTask(id); + res.json(task); + } catch (err) { + if (err.code === 'ENOENT') return res.status(404).json({ error: 'Task not found' }); + res.status(500).json({ error: err.message }); + } +}); + +// Create a task. +router.post('/', requireAuth, async (req, res) => { + try { + const { command, type = 'custom', app = null, config = '' } = req.body || {}; + if (typeof command !== 'string' || !command.trim()) { + return res.status(400).json({ error: '`command` is required' }); + } + const id = generateTaskId(); + const task = { + id, + command, + type, + app, + config, + status: 'queued', + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + heartbeatAt: null, + exitCode: null, + errorMessage: null + }; + await writeTaskAtomic(id, task); + pokeFifo(id); + sseBroadcast('task.upsert', task); + // fs.watch is unreliable on Docker bind-mounts; add a 1s polling + // fallback for this task until it terminates so a missed inotify + // event can't strand it as "running" in the UI. + armActiveTaskPoll(id); + res.status(201).json(task); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Cancel a task. Drops a `.cancel` marker that the processor's heartbeat +// loop notices and then SIGTERMs the running command. +router.post('/:id/cancel', requireAuth, async (req, res) => { + const { id } = req.params; + if (!isValidTaskId(id)) return res.status(400).json({ error: 'Invalid task id' }); + try { + const task = await readTask(id); + if (task.status !== 'running' && task.status !== 'queued') { + return res.status(409).json({ error: `Task is ${task.status}; cannot cancel.` }); + } + if (task.status === 'queued') { + const updated = { + ...task, + status: 'cancelled', + completedAt: new Date().toISOString(), + errorMessage: 'Cancelled before start.' + }; + await writeTaskAtomic(id, updated); + sseBroadcast('task.upsert', updated); + return res.json(updated); + } + // Drop the cancel marker; processor picks it up within HEARTBEAT_INTERVAL. + await fsp.writeFile(path.join(TASKS_DIR, `${id}.cancel`), '', 'utf8'); + res.json({ ok: true }); + } catch (err) { + if (err.code === 'ENOENT') return res.status(404).json({ error: 'Task not found' }); + res.status(500).json({ error: err.message }); + } +}); + +// Read full log (non-streaming). Use `position` for incremental polling +// fallback if SSE isn't connected for some reason. +router.get('/:id/log', requireAuth, async (req, res) => { + const { id } = req.params; + if (!isValidTaskId(id)) return res.status(400).json({ error: 'Invalid task id' }); + const pos = parseInt(req.query.position, 10) || 0; + try { + const text = await fsp.readFile(path.join(TASKS_DIR, `${id}.log`), 'utf8'); + res.type('text/plain').send(pos > 0 ? text.slice(pos) : text); + } catch (err) { + if (err.code === 'ENOENT') return res.status(404).type('text/plain').send(''); + res.status(500).json({ error: err.message }); + } +}); + +// Delete a task. Default behaviour blocks deletion of running/queued tasks +// so the user can't accidentally orphan an in-flight workload — but +// `?force=1` overrides that, which is needed when a stuck task can't be +// cancelled (e.g. the bash processor has died, the cancel marker isn't +// being picked up, etc.) and the user just wants the row gone. +router.delete('/:id', requireAuth, async (req, res) => { + const { id } = req.params; + if (!isValidTaskId(id)) return res.status(400).json({ error: 'Invalid task id' }); + const force = req.query.force === '1' || req.query.force === 'true'; + try { + const task = await readTask(id); + if (!force && (task.status === 'running' || task.status === 'queued')) { + return res.status(409).json({ error: `Task is ${task.status}; cancel first.` }); + } + await fsp.unlink(path.join(TASKS_DIR, `${id}.json`)).catch(() => {}); + await fsp.unlink(path.join(TASKS_DIR, `${id}.log`)).catch(() => {}); + // Drop any leftover cancel marker so the processor (when it does come + // back up) doesn't try to act on a task file that no longer exists. + await fsp.unlink(path.join(TASKS_DIR, `${id}.cancel`)).catch(() => {}); + logTails.delete(id); + sseBroadcast('task.deleted', { id }); + res.json({ ok: true, forced: force }); + } catch (err) { + if (err.code === 'ENOENT') return res.status(404).json({ error: 'Task not found' }); + res.status(500).json({ error: err.message }); + } +}); + +// Health-check style endpoint: is the bash processor alive? +router.get('/_meta/health', requireAuth, async (req, res) => { + let processorAlive = false; + try { + const pidText = await fsp.readFile(`${PROCESSOR_LOCK}.pid`, 'utf8'); + const pid = parseInt(pidText.trim(), 10); + processorAlive = Number.isFinite(pid) && pid > 0; + // We can't easily verify the pid is alive from inside the container if the + // processor runs on the host — but the existence of the pid file is a + // reasonable proxy. + } catch { /* processor not running */ } + res.json({ + processorAlive, + sseClients: sseClients.size, + tasksDir: TASKS_DIR + }); +}); + +// Init the watcher exactly once when the module is required. +startTasksWatcher(); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/theme.js b/containers/libreportal/backend/routes/theme.js new file mode 100755 index 0000000..94b3a4c --- /dev/null +++ b/containers/libreportal/backend/routes/theme.js @@ -0,0 +1,62 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const config = require('../utils/config.js'); + +const router = express.Router(); + +/* ========================= + GET Current Theme +========================= */ +router.get('/', (req, res) => { + try { + const theme = config.fileConfig.CFG_LIBREPORTAL_THEME || 'dark'; + res.json({ theme }); + } catch (err) { + console.error('Error getting theme:', err); + res.json({ theme: 'dark' }); // fallback to default + } +}); + +/* ========================= + POST Update Theme +========================= */ +router.post('/', (req, res) => { + try { + const { theme } = req.body; + if (!theme || typeof theme !== 'string') { + return res.status(400).json({ error: 'Invalid theme' }); + } + + const configPath = path.join(__dirname, '..', '..', 'libreportal.config'); + + // Read current config + let lines = []; + if (fs.existsSync(configPath)) { + lines = fs.readFileSync(configPath, 'utf8').split('\n'); + } + + // Update or add theme line + const themeLine = `CFG_LIBREPORTAL_THEME=${theme}`; + const themeIndex = lines.findIndex(line => line.startsWith('CFG_LIBREPORTAL_THEME=')); + + if (themeIndex >= 0) { + lines[themeIndex] = themeLine; + } else { + lines.push(themeLine); + } + + // Write updated config + fs.writeFileSync(configPath, lines.join('\n'), 'utf8'); + + // Update in-memory config + config.fileConfig.CFG_LIBREPORTAL_THEME = theme; + + res.json({ theme }); + } catch (err) { + console.error('Error updating theme:', err); + res.status(500).json({ error: 'Failed to update theme' }); + } +}); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/themes.js b/containers/libreportal/backend/routes/themes.js new file mode 100644 index 0000000..dca469a --- /dev/null +++ b/containers/libreportal/backend/routes/themes.js @@ -0,0 +1,71 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); + +const router = express.Router(); + +const THEMES_DIR = path.join(__dirname, '..', '..', 'frontend', 'themes'); + +/* Surface order — themes whose folder name appears here are listed first, + in this order. Anything else is appended alphabetically. Lets us keep + the built-ins (nebula / dark-blue / light) at the top of the dropdown + without hardcoding their existence in the API. */ +const PREFERRED_ORDER = ['nebula', 'dark-blue', 'light']; + +/* ========================= + GET /api/themes/list + + Walks frontend/themes// and returns one entry per directory that + contains a theme.css. Optional meta.json supplies a friendlier display + name. No hardcoded list — built-ins live in folders just like any + custom theme. + + Public — the list of theme names isn't sensitive and the frontend + needs it before login to render the right palette on the login + overlay too. +========================= */ +router.get('/list', (req, res) => { + const themes = []; + try { + if (fs.existsSync(THEMES_DIR)) { + for (const entry of fs.readdirSync(THEMES_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const name = entry.name; + const cssPath = path.join(THEMES_DIR, name, 'theme.css'); + if (!fs.existsSync(cssPath)) continue; + + let displayName = name; + let builtin = false; + const metaPath = path.join(THEMES_DIR, name, 'meta.json'); + if (fs.existsSync(metaPath)) { + try { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (meta && typeof meta.displayName === 'string' && meta.displayName.trim()) { + displayName = meta.displayName.trim(); + } + if (meta && meta.builtin === true) builtin = true; + } catch (_) { + /* malformed meta.json — fall back to folder name */ + } + } + + themes.push({ name, displayName, css: `/themes/${name}/theme.css`, builtin }); + } + } + } catch (err) { + console.error('Error scanning themes directory:', err); + } + + themes.sort((a, b) => { + const ai = PREFERRED_ORDER.indexOf(a.name); + const bi = PREFERRED_ORDER.indexOf(b.name); + if (ai !== -1 && bi !== -1) return ai - bi; + if (ai !== -1) return -1; + if (bi !== -1) return 1; + return a.displayName.localeCompare(b.displayName); + }); + + res.json(themes); +}); + +module.exports = router; diff --git a/containers/libreportal/backend/server.js b/containers/libreportal/backend/server.js new file mode 100755 index 0000000..fea1f96 --- /dev/null +++ b/containers/libreportal/backend/server.js @@ -0,0 +1,15 @@ +const express = require('express'); +const config = require('./utils/config.js'); +const middleware = require('./utils/middleware.js'); +const routes = require('./routes/routes.js'); +const auth = require('./utils/auth.js'); + +(async () => { + const app = express(); + middleware.setup(app); + await auth.initAuth(config.fileConfig); + routes.setup(app); + app.listen(config.PORT, '0.0.0.0', () => { + //console.log(`LibrePortal Web UI running on http://0.0.0.0:${config.PORT}`); + }); +})(); diff --git a/containers/libreportal/backend/utils/auth.js b/containers/libreportal/backend/utils/auth.js new file mode 100755 index 0000000..ffaa436 --- /dev/null +++ b/containers/libreportal/backend/utils/auth.js @@ -0,0 +1,46 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); + +const AUTH_FILE = path.join(__dirname, '..', '..', 'frontend', '.auth.json'); + +let authData = null; + +async function initAuth(fileConfig) { + if (fs.existsSync(AUTH_FILE)) { + authData = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')); + console.log('[Auth] Loaded existing credentials for user:', authData.username); + } else { + const username = fileConfig.CFG_WEBUI_USERNAME || 'admin'; + const password = fileConfig.CFG_WEBUI_PASSWORD || 'changeme'; + console.log('[Auth] Creating auth credentials for user:', username); + const passwordHash = await bcrypt.hash(password, 12); + const jwtSecret = crypto.randomBytes(32).toString('hex'); + authData = { username, passwordHash, jwtSecret }; + fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2), 'utf8'); + } +} + +function generateToken(username) { + return jwt.sign({ sub: username }, authData.jwtSecret, { expiresIn: '30d' }); +} + +function verifyToken(token) { + try { + return jwt.verify(token, authData.jwtSecret); + } catch { + return null; + } +} + +async function verifyPassword(plain, hash) { + return bcrypt.compare(plain, hash); +} + +function getCredentials() { + return authData; +} + +module.exports = { initAuth, generateToken, verifyToken, verifyPassword, getCredentials }; diff --git a/containers/libreportal/backend/utils/config.js b/containers/libreportal/backend/utils/config.js new file mode 100755 index 0000000..43a6f24 --- /dev/null +++ b/containers/libreportal/backend/utils/config.js @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); + +/* ========================= + Parse a bash-style config file + Handles: KEY=value and KEY=value # inline comment +========================= */ +function parseConfigFile(filePath) { + const config = {}; + if (!fs.existsSync(filePath)) return config; + + const lines = fs.readFileSync(filePath, 'utf8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.substring(0, eqIdx).trim(); + let value = trimmed.substring(eqIdx + 1).trim(); + // Strip inline comment (space + #) + const commentIdx = value.search(/\s+#/); + if (commentIdx !== -1) value = value.substring(0, commentIdx).trim(); + if (key && value !== undefined) config[key] = value; + } + return config; +} + +/* ========================= + Parse Port from Config +========================= */ +function parsePortFromConfig(portConfig) { + if (!portConfig) return 1111; + const port = Number(portConfig); + return port >= 1 && port <= 65535 ? port : 1111; +} + +/* ========================= + Export Configuration +========================= */ +const libreportalConfig = parseConfigFile(path.join(__dirname, '..', '..', 'libreportal.config')); +const webuiLoginsConfig = parseConfigFile(path.join(__dirname, '..', '..', 'webui_logins')); +const webuiLogsConfig = parseConfigFile(path.join(__dirname, '..', '..', 'webui_logs')); + +// Merge: later sources override earlier. webui_logins / webui_logs hold +// CFG_WEBUI_* keys generated from /docker/configs/webui/* and bind-mounted +// in via libreportal's compose. +const fileConfig = { ...libreportalConfig, ...webuiLoginsConfig, ...webuiLogsConfig }; + +const PORT = parsePortFromConfig(fileConfig.CFG_LIBREPORTAL_PORT_1) || 1111; +if (!PORT || PORT < 1 || PORT > 65535) { + console.warn('Invalid or missing CFG_LIBREPORTAL_PORT_1 in libreportal.config, using default port 1111'); +} + +const COMMAND_TIMEOUT = Number(fileConfig.CFG_LIBREPORTAL_TIMEOUT || 30000); + +module.exports = { + PORT, + COMMAND_TIMEOUT, + fileConfig, + FRONTEND_PATH: path.join(__dirname, '..', '..', 'frontend') +}; diff --git a/containers/libreportal/backend/utils/fifo.js b/containers/libreportal/backend/utils/fifo.js new file mode 100644 index 0000000..9f2047f --- /dev/null +++ b/containers/libreportal/backend/utils/fifo.js @@ -0,0 +1,22 @@ +// Best-effort wake-up signal for the host-side bash task processor. +// +// The FIFO carries no meaningful payload — it's just a poke to make the +// processor poll sooner than its 3 s idle scan. So `pokeFifo` is +// non-blocking by design: +// - O_NONBLOCK on open means we don't wedge the Node event loop when no +// reader is attached (the previous writeFileSync would hang forever +// in that case, surfacing as "NetworkError failed to fetch" in the UI +// when the request never returned). +// - All errors are swallowed; the processor will pick the task up from +// the on-disk JSON via its idle scan even if the wake-up is lost. + +const fs = require('fs'); + +function pokeFifo(fifoPath, taskId) { + fs.open(fifoPath, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK, (err, fd) => { + if (err) return; + fs.write(fd, `${taskId}\n`, () => fs.close(fd, () => {})); + }); +} + +module.exports = { pokeFifo }; diff --git a/containers/libreportal/backend/utils/mail.js b/containers/libreportal/backend/utils/mail.js new file mode 100755 index 0000000..de7541a --- /dev/null +++ b/containers/libreportal/backend/utils/mail.js @@ -0,0 +1,142 @@ +const net = require('net'); + +/* ========================= + Mail Connection Test +========================= */ +async function testConnection(req, res) { + try { + const { host, port, secure, username, password, from } = req.body; + + // Validate required fields + if (!host || !port || !username || !password) { + return res.status(400).json({ + success: false, + message: 'Missing required fields: host, port, username, password' + }); + } + + const result = await new Promise((resolve, reject) => { + const socket = new net.Socket(); + let response = ''; + let connected = false; + + socket.connect(parseInt(port), host, () => { + connected = true; + //console.log(`Connected to ${host}:${port}`); + + // Wait for SMTP greeting + socket.once('data', (data) => { + response = data.toString(); + //console.log('SMTP greeting:', response.trim()); + + if (response.startsWith('220')) { + // Send EHLO + socket.write('EHLO test.example.com\r\n'); + + // Wait for EHLO response + socket.once('data', (ehloData) => { + const ehloResponse = ehloData.toString(); + //console.log('EHLO response:', ehloResponse.trim()); + + if (ehloResponse.startsWith('250')) { + // Try to authenticate if username/password provided + if (username && password) { + socket.write(`AUTH LOGIN\r\n`); + + socket.once('data', (authChallenge) => { + const authResponse = authChallenge.toString(); + //console.log('AUTH response:', authResponse.trim()); + + if (authResponse.startsWith('334')) { + // Send username (base64 encoded) + const usernameB64 = Buffer.from(username).toString('base64'); + socket.write(usernameB64 + '\r\n'); + + socket.once('data', (userChallenge) => { + const userResponse = userChallenge.toString(); + //console.log('Username response:', userResponse.trim()); + + if (userResponse.startsWith('334')) { + // Send password (base64 encoded) + const passwordB64 = Buffer.from(password).toString('base64'); + socket.write(passwordB64 + '\r\n'); + + socket.once('data', (passResponse) => { + const passResult = passResponse.toString(); + //console.log('Password response:', passResult.trim()); + + if (passResult.startsWith('235')) { + resolve({ + success: true, + message: `Successfully connected and authenticated to ${host}:${port} with ${secure || 'none'} security` + }); + } else { + reject(new Error('Authentication failed: Invalid username or password')); + } + socket.end(); + }); + } else { + reject(new Error('Authentication failed: Invalid username')); + socket.end(); + } + }); + } else { + reject(new Error('Server does not support authentication')); + socket.end(); + } + }); + } else { + resolve({ + success: true, + message: `Successfully connected to ${host}:${port} with ${secure || 'none'} security (no authentication tested)` + }); + socket.end(); + } + } else { + reject(new Error('EHLO command failed')); + socket.end(); + } + }); + } else { + reject(new Error('Server did not send proper SMTP greeting')); + socket.end(); + } + }); + }); + + socket.on('error', (err) => { + if (!connected) { + reject(new Error(`Connection failed: ${err.message}`)); + } else { + reject(new Error(`SMTP error: ${err.message}`)); + } + }); + + socket.setTimeout(10000, () => { + socket.destroy(); + reject(new Error('Connection timeout')); + }); + }); + + res.json(result); + + } catch (error) { + console.error('Mail connection test error:', error); + res.status(500).json({ + success: false, + message: error.message || 'Mail connection test failed', + details: error.stack || 'No stack trace available', + error: error.toString(), + config: { + host: host, + port: port, + secure: secure, + username: username ? '[REDACTED]' : 'not provided', + password: password ? '[REDACTED]' : 'not provided', + from: from + } + }); + } +} + +module.exports = { testConnection }; diff --git a/containers/libreportal/backend/utils/middleware.js b/containers/libreportal/backend/utils/middleware.js new file mode 100755 index 0000000..6dd511a --- /dev/null +++ b/containers/libreportal/backend/utils/middleware.js @@ -0,0 +1,42 @@ +const express = require('express'); +const path = require('path'); +const cookieParser = require('cookie-parser'); +const config = require('./config.js'); +const { verifyToken } = require('./auth.js'); + +function requireAuth(req, res, next) { + const token = req.cookies?.libreportal_token; + if (!token) return res.status(401).json({ error: 'Unauthorized' }); + const payload = verifyToken(token); + if (!payload) return res.status(401).json({ error: 'Token expired or invalid' }); + req.user = payload; + next(); +} + +// Prevent the browser from caching authenticated /data/* responses so they're +// not retained after logout or persisted to disk caches. +function noStore(req, res, next) { + res.setHeader('Cache-Control', 'no-store'); + next(); +} + +function setup(app) { + app.use(express.json()); + app.use(cookieParser()); + + // Block MIME sniffing on every response. + app.use((req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + next(); + }); + + // /data/* requires auth. express.static doesn't generate directory listings, + // so the only way to read anything is to know an exact path. + app.use('/data', requireAuth, noStore, express.static(path.join(config.FRONTEND_PATH, 'data'))); + + // All other static assets (js, css, icons, html partials, index.html) remain public. + // dotfiles='ignore' by default so .auth.json is never served. + app.use(express.static(config.FRONTEND_PATH)); +} + +module.exports = { setup, requireAuth }; diff --git a/containers/libreportal/docker-compose.yml b/containers/libreportal/docker-compose.yml new file mode 100644 index 0000000..1787080 --- /dev/null +++ b/containers/libreportal/docker-compose.yml @@ -0,0 +1,50 @@ + + +networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + external: true + +services: + libreportal-service: #LIBREPORTAL|SERVICE_TAG_1|libreportal-service + container_name: libreportal-service + build: + context: . + image: libreportal-service:latest + user: "USER_DATA" #LIBREPORTAL|USER_TAG|USER_DATA + group_add: + - SOCKET_GID_DATA #LIBREPORTAL|SOCKET_GID_TAG|SOCKET_GID_DATA + ports: + - "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1 + volumes: + - ./frontend:/app/frontend + - ./backend/routes:/app/backend/routes + - ./backend/utils:/app/backend/utils + - ./backend/server.js:/app/backend/server.js + - ./libreportal.config:/app/libreportal.config:ro + - ../../configs/webui/webui_logins:/app/webui_logins:ro + - ../../configs/webui/webui_logs:/app/webui_logs:ro + # >>> crowdsec-host-logs >>> + #- /var/log/crowdsec.log:/host/var/log/crowdsec.log:ro + #- /var/log/crowdsec-firewall-bouncer.log:/host/var/log/crowdsec-firewall-bouncer.log:ro + # <<< crowdsec-host-logs <<< + - SOCKET_DATA #LIBREPORTAL|SOCKET_TAG|SOCKET_DATA + environment: + FRONTEND_PATH: /data/frontend + LIBREPORTAL_CONFIG_PATH: /app/libreportal.config + TZ: TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA + labels: + libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA + libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA + traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA + traefik.http.routers.libreportal-service.entrypoints: web,websecure + traefik.http.routers.libreportal-service.rule: Host(`DOMAINSUBNAME_DATA`) #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA + traefik.http.routers.libreportal-service.tls: true + traefik.http.routers.libreportal-service.tls.certresolver: production + traefik.http.services.libreportal-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1 + traefik.http.routers.libreportal-service.middlewares: MIDDLEWARE_DATA #LIBREPORTAL|MIDDLEWARE_TAG|MIDDLEWARE_DATA + traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + healthcheck: + disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA + networks: + DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA + ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1 diff --git a/containers/libreportal/frontend/css/apps-layout.css b/containers/libreportal/frontend/css/apps-layout.css new file mode 100644 index 0000000..44e1f4f --- /dev/null +++ b/containers/libreportal/frontend/css/apps-layout.css @@ -0,0 +1,93 @@ +/* Unified Apps Layout Styles. Extracted from apps-unified-layout.html + so all CSS lives under css/ — themes drive colors via the variables + defined in themes//theme.css. */ + +/* Sidebar + main fit the viewport exactly (minus the 60px topbar) + and scroll independently — same pattern Tasks uses, so the sidebar + background paints the full column even when the main content is + shorter than the viewport. */ +.apps-layout { + display: flex; + width: 100%; + height: calc(100vh - 60px); +} + +.sidebar-container { + flex-shrink: 0; + width: 220px; + height: 100%; + background: var(--sidebar-bg); + border-right: 1px solid var(--border-color); + overflow-y: auto; + transition: transform 0.3s ease; +} + +.main-content { + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.content-view { + flex: 1; + overflow-y: auto; +} + +#app-detail-view { + display: none; + padding: 22px; +} + +.content-view.active { + display: block; +} + +#apps-view.active { + display: block; +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .apps-layout { + flex-direction: column; + } + + .sidebar-container { + width: 100%; + position: fixed; + top: 60px; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + transform: translateX(-100%); + background: var(--sidebar-bg); + } + + .sidebar-container.mobile-open { + transform: translateX(0); + } + + .main-content { + width: 100%; + } +} + +/* Ensure sidebar stays visible during transitions. Scoped to the + apps layout — on the config page the sidebar is a direct flex + child of .container with its own 220px width from sidebar.css, + and the unscoped width: 100% here was flex-basing the sidebar + to 100% of the container, breaking the config layout. */ +.apps-layout .sidebar { + width: 100%; + height: 100%; + overflow-y: auto; +} + +@media (max-width: 768px) { + #app-detail-view { + padding: 10px; + } +} diff --git a/containers/libreportal/frontend/css/apps.css b/containers/libreportal/frontend/css/apps.css new file mode 100644 index 0000000..ac8c164 --- /dev/null +++ b/containers/libreportal/frontend/css/apps.css @@ -0,0 +1,409 @@ + + +/* App center cards, grid, tags, and detail view. Extracted from style.css. */ + +.apps-section { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin: 22px; + padding: 22px; + background: rgba(var(--text-rgb), 0.025); + border: 1px solid var(--border-subtle); + border-radius: 16px; +} + +/* Override grid styling when showing loading content */ +.apps-section .loading-content { + position: absolute !important; + top: 20px !important; + left: 20px !important; + right: 20px !important; + bottom: 0 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + align-items: center !important; + width: calc(100% - 40px) !important; + height: calc(100% - 20px) !important; + padding: 60px 20px !important; + background: var(--input-bg) !important; + border: 2px solid var(--border-color) !important; + border-radius: 12px !important; + box-sizing: border-box !important; + margin: 0 !important; + min-height: 400px !important; +} + +/* Make apps-section relative for absolute positioning */ +.apps-section { + position: relative !important; +} + +.app-card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: var(--card-shadow); + transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; + min-height: 120px; + width: 100%; + position: relative; +} + +.app-card-top { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.app-card-icon { + width: 70px; + height: 70px; + background: rgba(var(--text-rgb), 0.1); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + border: 1px solid rgba(var(--text-rgb), 0.2); + flex-shrink: 0; +} + +.app-card-icon svg { + width: 100%; + height: 100%; + object-fit: contain; +} + +.app-card-icon img { + width: 100%; + height: 100%; + object-fit: contain; + max-width: 100%; + max-height: 100%; +} + +.app-card-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.app-card-title { + font-size: 16px; + font-weight: 600; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-card-long-description { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.3; + margin-top: 8px; + margin-bottom: 8px; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; /* Standard property */ + -webkit-box-orient: vertical; + overflow: hidden; + font-style: italic; + width: 100%; +} + +.app-card-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.app-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + border: 1px solid; + transition: all 0.2s ease; +} + +/* Category tags - Blue */ +.app-tag.category-tag { + background: rgba(var(--accent-rgb), 0.1); + color: var(--accent); + border-color: rgba(var(--accent-rgb), 0.2); +} + +.app-tag.category-tag:hover { + background: rgba(var(--accent-rgb), 0.2); + transform: translateY(-1px); +} + +/* Description tags - White to match title */ +.app-tag.description-tag { + background: rgba(var(--text-rgb), 0.1); + color: var(--text-primary); + border-color: rgba(var(--text-rgb), 0.2); + font-style: italic; +} + +/* Installed tags - Green. Light pastel green text on a saturated + green pill so the label stays in the green family but actually + reads on dark themes (the default --status-success #28a745 is too + dark at small sizes). Leading ✓ glyph reinforces the state at a + glance. */ +.app-tag.installed-tag { + background: rgba(var(--status-success-rgb), 0.35); + color: #86efac; + border-color: rgba(var(--status-success-rgb), 0.70); + transform: translateY(-2px); +} + +.app-tag.installed-tag::before { + content: '✓'; + margin-right: 5px; + font-weight: 700; + line-height: 1; +} + +/* Not Installed tags - Gray with a leading ✕ glyph for symmetry with + the ✓ on the installed pill. */ +.app-tag.not-installed-tag { + background: rgba(var(--text-rgb), 0.10); + color: var(--text-secondary); + border-color: rgba(var(--text-rgb), 0.30); +} + +.app-tag.not-installed-tag::before { + content: '✕'; + margin-right: 5px; + font-weight: 700; + line-height: 1; +} + +/* Clickable tags (category / installed-status) — jump to that filter view */ +.app-tag.clickable { + cursor: pointer; +} + +.app-tag.clickable:hover { + transform: translateY(-1px); +} + +.app-tag img { + width: 12px; + height: 12px; + margin: 0; +} + +.app-card-actions { + display: flex; + gap: 8px; + align-items: stretch; + flex-direction: row; + margin-top: auto; + overflow: visible; + position: relative; +} + +.app-card-actions button { + flex: 1; +} + +.app-card:hover { + transform: translateY(-3px); + border-color: var(--accent) !important; + box-shadow: var(--card-shadow-hover); +} + +.app-card button { + margin-top: 0; + padding: 12px 16px; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.2s; + font-size: 14px; + text-align: center; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + white-space: nowrap; +} + +.app-icon { + width: 80px; + height: 80px; + border-radius: 12px; + flex-shrink: 0; +} + +.app-icon img { + width: 100%; + height: 100%; + object-fit: contain; + max-width: 100%; + max-height: 100%; +} + +.app-details .app-description { + font-size: 16px; + color: var(--text-secondary, #ccc); + margin-bottom: 8px; +} + +/* .btn-primary / .btn-secondary / .btn-outline / .btn-danger + live in themes.css with the nebula-glass treatment. + .btn-install / .btn-manage / .btn-uninstall ditto. */ + +/* App-card manage / install button styling lives in themes.css. + The previous block was 6 layers of "ultra-specific" selectors + re-asserting solid-colour fallbacks — all dead now that + themes.css owns the glass treatment with !important. */ + +/* Force green with attribute selectors */ +.app-card-actions button[class*="install-btn"] { + background: var(--status-success) !important; + color: #ffffff !important; + border: 1px solid var(--status-success) !important; +} + +.app-card-actions button[class*="install-btn"]:hover { + background: var(--status-success-hover) !important; + border-color: var(--status-success-hover) !important; +} + +.installed-apps .app-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 20px; + transition: all 0.2s ease; + cursor: pointer; +} + +.installed-apps .app-card:hover { + transform: translateY(-2px); + box-shadow: var(--card-shadow-hover); + border-color: var(--primary-color); +} + +.installed-apps .app-card-top { + display: flex; + align-items: center; + gap: 16px; +} + +.installed-apps .app-card-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + flex-shrink: 0; +} + +.installed-apps .app-card-icon img { + width: 32px; + height: 32px; + object-fit: contain; +} + +.installed-apps .app-card-content { + flex: 1; + min-width: 0; +} + +.installed-apps .app-card-title { + font-size: 16px; + font-weight: 600; + color: var(--text-color); + margin: 0 0 4px 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.installed-apps .app-card-description { + font-size: 14px; + color: var(--text-secondary, #ccc); + margin: 0; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; /* Standard property */ + -webkit-box-orient: vertical; + overflow: hidden; +} + +.installed-apps .app-card-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.installed-apps .app-card-actions .manage-btn { + display: flex; + align-items: center; + gap: 6px; + background: var(--accent) !important; + color: var(--text-primary) !important; + border: 1px solid var(--accent) !important; + border-radius: 6px; + font-size: 12px; + font-weight: 600 !important; + cursor: pointer; + transition: all 0.2s; +} + +.installed-apps .app-card-actions .manage-btn:hover { + background: var(--accent-hover) !important; + border-color: var(--accent-hover) !important; + transform: translateY(-1px) !important; +} + +/* Extra specific rules for index page manage button */ +.app-card-actions .btn-loading.manage-btn { + color: transparent !important; +} + +.app-card-actions .btn-loading.manage-btn * { + opacity: 0 !important; + visibility: hidden !important; +} + +@media (max-width: 768px) { + .apps-section { + margin: 10px; + padding: 12px; + gap: 12px; + } + + /* Install screen action buttons stack full-width below the console. */ + .console-actions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; + } + + .console-actions .btn { + width: 100%; + justify-content: center; + } +} diff --git a/containers/libreportal/frontend/css/aurora-background.css b/containers/libreportal/frontend/css/aurora-background.css new file mode 100644 index 0000000..145aa63 --- /dev/null +++ b/containers/libreportal/frontend/css/aurora-background.css @@ -0,0 +1,253 @@ +/* + Shared "aurora" background — cool sparkly-blue swirling water. + Applied to both the login overlay and the loading screen so they share + one visual identity. + + Usage: a container element gets `.aurora-bg`. Two pseudo-element layers + paint: + ::before — slowly rotating conic-gradient swirl + ::after — drifting radial-gradient "blobs" + Plus a separate `.aurora-stars` overlay child for sparkle twinkles + (kept as a real element so its animation can stack on top of pseudo + layers without z-index gymnastics). +*/ + +.aurora-bg { + background: + radial-gradient(ellipse at 20% 30%, var(--gradient-mid) 0%, transparent 55%), + radial-gradient(ellipse at 80% 70%, var(--gradient-to) 0%, transparent 55%), + linear-gradient(135deg, var(--gradient-from) 0%, var(--gradient-from) 40%, var(--gradient-mid) 100%); + overflow: hidden; + isolation: isolate; +} + +/* Pair with `.aurora-bg` only if the host element isn't already + positioned. Login overlay and loading screen are both `position: fixed` + so the pseudo-elements anchor correctly without us overriding their + positioning. */ + +/* Slow conic swirl — the "current" of the water */ +.aurora-bg::before { + content: ''; + position: absolute; + inset: -25%; + background: conic-gradient( + from 0deg at 50% 50%, + rgba(var(--accent-rgb), 0.0) 0deg, + rgba(var(--accent-rgb), 0.18) 60deg, + rgba(var(--accent-rgb), 0.22) 130deg, + rgba(var(--accent-rgb), 0.20) 200deg, + rgba(var(--accent-rgb), 0.18) 280deg, + rgba(var(--accent-rgb), 0.0) 360deg + ); + filter: blur(60px); + animation: auroraSpin 38s linear infinite; + z-index: -2; + pointer-events: none; +} + +/* Drifting glow blobs — the "sparkly" volumes */ +.aurora-bg::after { + content: ''; + position: absolute; + inset: -10%; + background: + radial-gradient(circle at 18% 22%, rgba(var(--accent-rgb), 0.45) 0%, transparent 35%), + radial-gradient(circle at 78% 18%, rgba(var(--accent-rgb), 0.40) 0%, transparent 32%), + radial-gradient(circle at 30% 78%, rgba(var(--accent-rgb), 0.38) 0%, transparent 38%), + radial-gradient(circle at 82% 80%, rgba(var(--accent-rgb), 0.45) 0%, transparent 36%), + radial-gradient(circle at 50% 50%, rgba(var(--accent-rgb), 0.18) 0%, transparent 50%); + filter: blur(40px); + animation: auroraDrift 22s ease-in-out infinite alternate; + z-index: -1; + pointer-events: none; +} + +@keyframes auroraSpin { + to { transform: rotate(360deg); } +} + +@keyframes auroraDrift { + 0% { transform: translate(0, 0) scale(1); opacity: 0.85; } + 50% { transform: translate(-3%, 4%) scale(1.08); opacity: 1; } + 100% { transform: translate(2%, -3%) scale(0.95); opacity: 0.9; } +} + +/* + Sparkles. We render a tiny tiling of radial-gradient dots and + modulate opacity so they twinkle. Two staggered layers feel less + uniform than one. +*/ +.aurora-stars { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; +} + +.aurora-stars::before, +.aurora-stars::after { + content: ''; + position: absolute; + inset: 0; + background-repeat: repeat; +} + +.aurora-stars::before { + background-image: + radial-gradient(1.5px 1.5px at 12px 18px, rgba(var(--text-rgb),0.9), transparent 60%), + radial-gradient(1px 1px at 47px 92px, rgba(var(--accent-rgb),0.85), transparent 60%), + radial-gradient(1.2px 1.2px at 110px 40px, rgba(var(--text-rgb),0.75), transparent 60%), + radial-gradient(1px 1px at 165px 130px, rgba(var(--accent-rgb),0.7), transparent 60%); + background-size: 200px 200px; + animation: auroraTwinkleA 4.5s ease-in-out infinite; +} + +.aurora-stars::after { + background-image: + radial-gradient(1px 1px at 30px 60px, rgba(var(--accent-rgb),0.8), transparent 60%), + radial-gradient(1.4px 1.4px at 88px 22px, rgba(var(--text-rgb),0.7), transparent 60%), + radial-gradient(1px 1px at 140px 100px, rgba(var(--accent-rgb),0.85), transparent 60%), + radial-gradient(1.2px 1.2px at 195px 70px, rgba(var(--text-rgb),0.6), transparent 60%); + background-size: 240px 240px; + background-position: 80px 50px; + animation: auroraTwinkleB 6.5s ease-in-out infinite; +} + +@keyframes auroraTwinkleA { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} + +@keyframes auroraTwinkleB { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +/* Children of an aurora surface should sit above the FX layers */ +.aurora-bg > :not(.aurora-stars) { + position: relative; + z-index: 2; +} + +/* Shared header used by both the loading screen and the login overlay + so the two surfaces have identical branding. */ +.aurora-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.aurora-logo { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; +} + +.aurora-logo img { + width: 48px; + height: 48px; + /* Fade in once the SVG actually loads — avoids the brief broken-image + flash on first paint. The .loaded class is added by the inline onload + handler on the img tag in each surface that uses this header. */ + opacity: 0; + transition: opacity 0.45s ease; +} + +.aurora-logo img.loaded { + opacity: 1; +} + +.aurora-logo h1 { + font-size: 3rem; + font-weight: 700; + margin: 0; + background: linear-gradient(45deg, var(--accent), var(--accent-hover)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.01em; +} + +.aurora-subtitle { + margin: 0.6rem 0 0 0; + font-size: 1.05rem; + font-weight: 300; + color: var(--text-secondary); + font-style: italic; + letter-spacing: 0.01em; +} + +/* Shrink the header on phones — 3rem title + 48px icon swallow the + viewport on small screens. */ +@media (max-width: 480px) { + .aurora-header { + margin-bottom: 1.5rem; + } + + .aurora-logo { + gap: 10px; + } + + .aurora-logo img { + width: 36px; + height: 36px; + } + + .aurora-logo h1 { + font-size: 2.1rem; + } + + .aurora-subtitle { + font-size: 0.9rem; + margin-top: 0.4rem; + padding: 0 12px; + } +} + +/* Respect reduced-motion — drop the animations but keep the gradient */ +@media (prefers-reduced-motion: reduce) { + .aurora-bg::before, + .aurora-bg::after, + .aurora-stars::before, + .aurora-stars::after { + animation: none; + } +} + +/* + Static modifier — pair with `.aurora-bg` to keep the gradient identity + but drop the per-frame work that was making login / loading / setup + sluggish on lower-power systems. We kill the animations only — the + blur filter is kept because, without the rotation, the blur layer + rasterises exactly once at first paint and then just composites each + frame for free. Removing the blur entirely created visible banding + and a dark seam where the conic gradient wraps from 360° back to 0°. +*/ +.aurora-bg.aurora-static::before, +.aurora-bg.aurora-static::after, +.aurora-bg.aurora-static .aurora-stars::before, +.aurora-bg.aurora-static .aurora-stars::after { + animation: none !important; +} + +/* dark-blue and light themes get a flat, solid loading/login surface + — the cyan swirl + glow blobs + stars belong to the Nebula identity. */ +html[data-theme="dark-blue"] .aurora-bg::before, +html[data-theme="dark-blue"] .aurora-bg::after, +html[data-theme="dark-blue"] .aurora-stars::before, +html[data-theme="dark-blue"] .aurora-stars::after, +html[data-theme="light"] .aurora-bg::before, +html[data-theme="light"] .aurora-bg::after, +html[data-theme="light"] .aurora-stars::before, +html[data-theme="light"] .aurora-stars::after { + display: none; +} + +/* Same suppression on the body-level layers Nebula uses. */ +html[data-theme="dark-blue"]::before, +html[data-theme="dark-blue"]::after, +html[data-theme="light"]::before, +html[data-theme="light"]::after { + display: none; +} diff --git a/containers/libreportal/frontend/css/backup.css b/containers/libreportal/frontend/css/backup.css new file mode 100755 index 0000000..bcb5e05 --- /dev/null +++ b/containers/libreportal/frontend/css/backup.css @@ -0,0 +1,1063 @@ +/* Backup Page — restic-engine UI */ + +.backup-layout { + display: flex; + min-height: calc(100vh - var(--topbar-height, 60px)); +} + +.backup-layout .main { + flex: 1; + min-width: 0; + overflow-y: auto; +} + +.backup-page { + color: var(--text-primary); + width: 100%; + padding-bottom: 48px; +} + +/* The whole backup page is one .config-section card containing both the + .page-header and the body. Remove the card's inner padding so the + .page-header sits flush at the top and its border-bottom acts as a + full-width divider; the body gets its own padding. */ +.backup-page-section { + padding: 0; + overflow: hidden; +} + +.backup-page-section > .page-header { + margin-bottom: 0; +} + +.backup-page-body { + padding: 22px; +} + +/* Configuration tab embeds /config's renderConfig, which emits its own + .page-header. The outer backup page already has one, so suppress the + embedded one to avoid the duplicate "Backup" heading. */ +.backup-embedded-config > .page-header { + display: none; +} + +/* SVG icon slot inside the shared .page-header (defined in config.css). */ +.page-header-icon-slot { + width: 36px; + height: 36px; + flex-shrink: 0; + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; +} + +.page-header-icon-slot svg { + width: 32px; + height: 32px; +} + +.backup-engine-badge { + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: var(--text-primary); + padding: 4px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.backup-primary-btn, +.backup-secondary-btn, +.backup-danger-btn, +.backup-refresh-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + border-radius: 8px; + border: none; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: transform 0.12s ease, background 0.12s ease, box-shadow 0.12s ease; +} + +.backup-primary-btn { + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: var(--text-primary); + box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.25); +} + +.backup-primary-btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(var(--accent-rgb), 0.35); +} + +.backup-secondary-btn, +.backup-refresh-btn { + background: rgba(var(--text-rgb), 0.06); + color: var(--text-primary); + border: 1px solid rgba(var(--text-rgb), 0.12); +} + +.backup-secondary-btn:hover, +.backup-refresh-btn:hover { + background: rgba(var(--text-rgb), 0.1); +} + +.backup-danger-btn { + background: linear-gradient(135deg, #dc2626, #b91c1c); + color: #fff; +} + +.backup-danger-btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(220, 38, 38, 0.35); +} + +.backup-tabpanel { + display: none; +} + +.backup-tabpanel.active { + display: block; + animation: backupFadeIn 0.25s ease; +} + +@keyframes backupFadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.backup-summary-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.backup-summary-tile { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 14px; + padding: 18px 20px; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.backup-summary-tile:hover { + transform: translateY(-2px); + border-color: rgba(var(--accent-rgb), 0.35); +} + +.backup-summary-tile-label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); + margin-bottom: 8px; +} + +.backup-summary-tile-value { + font-size: 1.6rem; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.backup-summary-tile-detail { + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + margin-top: 4px; +} + +.backup-cards-row { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; +} + +@media (max-width: 980px) { + .backup-cards-row { + grid-template-columns: 1fr; + } +} + +.backup-card { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 14px; + padding: 18px 22px; +} + +.backup-card-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.backup-card-header h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.backup-card-hint { + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); +} + +.backup-app-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; +} + +.backup-app-tile { + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; + padding: 12px 14px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + transition: all 0.15s ease; +} + +.backup-app-tile:hover { + border-color: rgba(var(--accent-rgb), 0.4); + transform: translateY(-1px); +} + +.backup-app-tile-icon { + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: 8px; + object-fit: cover; + background: rgba(var(--text-rgb), 0.05); +} + +.backup-app-tile-text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; +} + +.backup-app-tile-name { + font-weight: 600; + font-size: 0.95rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.backup-app-tile-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); +} + +.backup-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.backup-status-dot.ok { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); } +.backup-status-dot.warn { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15); } +.backup-status-dot.fail { background: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); } +.backup-status-dot.none { background: rgba(var(--text-rgb), 0.25); } + +.backup-repo-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.backup-repo-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: rgba(var(--text-rgb), 0.05); + border-radius: 10px; + gap: 12px; +} + +.backup-repo-row-name { + font-weight: 600; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 8px; +} + +.backup-repo-row-meta { + font-size: 0.75rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + text-align: right; +} + +.backup-repo-type-pill { + background: rgba(var(--accent-rgb), 0.12); + color: var(--accent); + padding: 2px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.backup-repo-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; +} + +/* Locations list — expandable rows mirroring the Tasks page .task-item shell */ +.backup-location-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.backup-location-row.task-item { + margin-bottom: 0; +} + +.backup-location-header { + padding: 18px 22px; + gap: 14px; + user-select: none; + min-height: 64px; +} + +.backup-location-row-type-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.backup-location-row-type-icon[data-type="sftp"] { color: #818cf8; } +.backup-location-row-type-icon[data-type="rest"], +.backup-location-row-type-icon[data-type="s3"], +.backup-location-row-type-icon[data-type="b2"], +.backup-location-row-type-icon[data-type="gs"], +.backup-location-row-type-icon[data-type="azure"], +.backup-location-row-type-icon[data-type="rclone"] { color: #38bdf8; } + +/* Status pill — mirrors the task-status pill on the Tasks page so the + visual language is consistent. */ +.task-status.backup-loc-status { + padding: 3px 8px; + border-radius: 999px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + border: 1px solid transparent; + line-height: 1.3; +} + +.task-status.backup-loc-status.status-ready { + background: rgba(var(--status-success-rgb), 0.30); + border-color: rgba(var(--status-success-rgb), 0.65); + color: #86efac; +} + +.task-status.backup-loc-status.status-init { + background: rgba(var(--status-warning-rgb), 0.22); + border-color: rgba(var(--status-warning-rgb), 0.60); + color: #fcd34d; +} + +.task-status.backup-loc-status.status-disabled { + background: rgba(var(--text-rgb), 0.08); + border-color: rgba(var(--text-rgb), 0.18); + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); +} + +.backup-location-row-info { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + overflow: hidden; +} + +.backup-location-row-name { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 240px; +} + +.backup-location-row-status-pill { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-primary); +} + +.backup-location-row-stat { + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); + white-space: nowrap; +} + +.backup-location-row-sep { + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.35)); +} + +.backup-pill-mini { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Hide separator + stat tokens on narrow screens to keep the row from + wrapping — the expanded details still show full info. */ +@media (max-width: 720px) { + .backup-location-row-sep, + .backup-location-row-stat { + display: none; + } +} + +.backup-location-chevron { + flex-shrink: 0; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + transition: transform 0.2s ease; +} + +.backup-location-row.expanded .backup-location-chevron { + transform: rotate(180deg); +} + +.backup-location-details { + padding: 16px 20px 20px; +} + +.backup-location-details > .config-category:first-of-type { + margin-top: 0; +} + +.backup-location-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid rgba(var(--text-rgb), 0.06); +} + +.backup-modal-wide .backup-modal-inner, +.backup-modal-inner.backup-modal-wide { + max-width: 640px; +} + +.backup-modal-wide { + /* selector also catches when class is on inner */ +} + +.backup-modal-inner.backup-modal-wide, +#backup-location-modal .backup-modal-inner { + max-width: 640px; + width: min(92vw, 640px); +} + +.backup-repo-card { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 14px; + padding: 18px 22px; +} + +.backup-repo-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.backup-repo-card-title { + font-weight: 600; + font-size: 1.05rem; + display: flex; + align-items: center; + gap: 10px; +} + +.backup-repo-disabled-pill { + background: rgba(var(--text-rgb), 0.1); + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); + padding: 3px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} + +.backup-repo-enabled-pill { + background: rgba(34, 197, 94, 0.15); + color: #16a34a; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} + +.backup-repo-detail { + display: grid; + grid-template-columns: 110px 1fr; + gap: 6px 12px; + font-size: 0.82rem; + margin-bottom: 12px; +} + +.backup-repo-detail-key { + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); +} + +.backup-repo-detail-value { + color: var(--text-primary); + word-break: break-all; +} + +.backup-snapshot-table-wrap { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 14px; + overflow: hidden; +} + +.backup-snapshot-table { + width: 100%; + border-collapse: collapse; +} + +.backup-snapshot-table th, +.backup-snapshot-table td { + padding: 12px 16px; + text-align: left; + font-size: 0.875rem; + border-bottom: 1px solid rgba(var(--text-rgb), 0.06); +} + +.backup-snapshot-table th { + background: rgba(var(--text-rgb), 0.04); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); +} + +.backup-snapshot-table tbody tr:hover { + background: rgba(var(--accent-rgb), 0.04); +} + +.backup-col-actions { + width: 180px; + text-align: right; +} + +.backup-snapshot-id { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.82rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.7)); +} + +.backup-row-action-btn { + background: rgba(var(--text-rgb), 0.06); + border: 1px solid rgba(var(--text-rgb), 0.1); + color: var(--text-primary); + border-radius: 6px; + padding: 5px 10px; + font-size: 0.78rem; + cursor: pointer; + margin-left: 6px; + transition: all 0.15s ease; +} + +.backup-row-action-btn:hover { + background: rgba(var(--accent-rgb), 0.12); + border-color: var(--accent); + color: var(--accent); +} + +.backup-row-action-btn.danger:hover { + background: rgba(239, 68, 68, 0.12); + border-color: #ef4444; + color: #ef4444; +} + +.backup-filters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} + +@media (max-width: 600px) { + .backup-filters { + grid-template-columns: 1fr; + } +} + +.backup-filter-input, +.backup-filter-select { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.1); + border-radius: 8px; + padding: 9px 12px; + color: var(--text-primary); + font-size: 0.875rem; + width: 100%; + box-sizing: border-box; + min-width: 0; +} + +.backup-filter-input:focus, +.backup-filter-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.15); +} + +/* Inline forms inside backup cards */ +.backup-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px 20px; + margin-top: 4px; +} + +.backup-form-row { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.875rem; +} + +.backup-form-row-toggle { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 0; +} + +.backup-form-label { + color: var(--text-secondary, rgba(var(--text-rgb), 0.7)); + font-weight: 500; + font-size: 0.82rem; +} + +.backup-form-readonly { + font-family: ui-monospace, SFMono-Regular, monospace; + color: var(--text-primary); + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; + padding: 9px 12px; + font-size: 0.85rem; +} + +.backup-form-section-title { + margin-top: 22px; + margin-bottom: 10px; + font-weight: 600; + font-size: 0.85rem; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.backup-form-section-title .backup-card-hint { + text-transform: none; + letter-spacing: normal; + margin-left: 8px; + font-weight: 400; +} + +.backup-retention-block { + grid-template-columns: 1fr; +} + +.backup-retention-preset-block { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 6px; + padding-bottom: 14px; + margin-bottom: 6px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); +} + +.backup-retention-hint { + margin-top: -4px; + font-style: italic; +} + +.backup-retention-advanced[hidden] { + display: none; +} + +.backup-retention-advanced { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed rgba(var(--text-rgb), 0.08); +} + +.backup-form-footer { + display: flex; + justify-content: flex-end; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid rgba(var(--text-rgb), 0.06); +} + +/* iOS-style toggle */ +.backup-toggle { + position: relative; + width: 38px; + height: 22px; + flex-shrink: 0; +} + +.backup-toggle input { + opacity: 0; + width: 100%; + height: 100%; + cursor: pointer; + position: absolute; + inset: 0; + margin: 0; + z-index: 1; +} + +.backup-toggle-slider { + position: absolute; + inset: 0; + background: rgba(var(--text-rgb), 0.15); + border-radius: 999px; + transition: background 0.18s ease; +} + +.backup-toggle-slider::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: transform 0.18s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); +} + +.backup-toggle input:checked + .backup-toggle-slider { + background: var(--accent); +} + +.backup-toggle input:checked + .backup-toggle-slider::after { + transform: translateX(16px); +} + +/* Repo card extras */ +.backup-repo-stats { + display: flex; + gap: 18px; + flex-wrap: wrap; + padding: 10px 14px; + background: rgba(var(--text-rgb), 0.04); + border-radius: 10px; + margin-bottom: 16px; + font-size: 0.82rem; +} + +.backup-repo-stat-label { + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + margin-right: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.7rem; +} + +.backup-warning-banner { + margin-top: 18px; + padding: 14px 16px; + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.25); + border-radius: 10px; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 16px; +} + +.backup-warning-banner-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.backup-warning-banner [data-action="export-passwords"] { + flex-shrink: 0; + white-space: nowrap; +} + +.backup-warning-banner [data-action="export-passwords"][data-busy="1"] { + opacity: 0.6; + cursor: progress; +} + +.backup-warning-banner strong { + color: #f59e0b; +} + +.backup-warning-banner code { + background: rgba(var(--text-rgb), 0.06); + padding: 4px 8px; + border-radius: 6px; + font-size: 0.8rem; + color: var(--text-primary); + display: inline-block; + margin-top: 4px; +} + +.backup-modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(6px); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.backup-modal.open { + display: flex; +} + +/* Slightly translucent — backdrop blur still shows through, but enough + alpha to keep the modal legible on busy gradients. */ +.backup-modal-inner { + background: color-mix(in srgb, var(--surface-bg-solid, #1a1d24) 78%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(var(--text-rgb), 0.14); + border-radius: 14px; + width: 90%; + max-width: 460px; + overflow: hidden; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); +} + +.backup-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); +} + +.backup-modal-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.backup-modal-close { + background: none; + border: none; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + font-size: 1.4rem; + cursor: pointer; + line-height: 1; + padding: 0 6px; +} + +.backup-modal-body { + padding: 18px 20px; + font-size: 0.9rem; + color: var(--text-primary); + max-height: 60vh; + overflow-y: auto; +} + +.backup-modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 20px; + border-top: 1px solid rgba(var(--text-rgb), 0.08); +} + +.backup-empty-state { + padding: 40px 20px; + text-align: center; + color: var(--text-secondary, rgba(var(--text-rgb), 0.55)); + font-size: 0.9rem; +} + +.backup-engine-input-row { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + gap: 10px; + width: 100%; +} + +.backup-engine-input-row > *:not(.backup-engine-details-btn) { + flex: 1 1 0%; + width: auto; + min-width: 0; + max-width: none; +} + +.backup-engine-input-row > .custom-select { + display: block; +} + +.backup-engine-details-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + flex: 0 0 auto; + padding: 0 16px; + min-height: 44px; + white-space: nowrap; + line-height: 1; +} + +/* Engine name pill next to the type pill on each location row. */ +.backup-engine-pill { + background: rgba(var(--accent-rgb), 0.16); + color: var(--accent); + padding: 2px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.backup-engine-pill[data-engine="borg"] { + background: rgba(245, 158, 11, 0.18); + color: #f59e0b; +} + +.backup-engine-pill[data-engine="kopia"] { + background: rgba(99, 102, 241, 0.18); + color: #818cf8; +} + +/* Engine details modal. */ +.backup-engine-modal-head { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; +} + +.backup-engine-logo { + width: 36px; + height: 36px; + flex-shrink: 0; + color: var(--accent); +} + +.backup-engine-modal-head h4 { + margin: 0 0 4px 0; + font-size: 1.1rem; + font-weight: 600; +} + +.backup-engine-props { + width: 100%; + border-collapse: collapse; + margin-bottom: 18px; +} + +.backup-engine-props th, +.backup-engine-props td { + padding: 8px 10px; + text-align: left; + font-size: 0.85rem; + border-bottom: 1px solid rgba(var(--text-rgb), 0.06); +} + +.backup-engine-props th { + width: 35%; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); + font-weight: 500; +} + +.backup-engine-features { + margin: 6px 0 18px; + padding-left: 22px; + font-size: 0.88rem; + line-height: 1.55; +} + +.backup-engine-features li { + margin-bottom: 4px; +} + +.backup-engine-docs-link { + color: var(--accent); + text-decoration: none; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.85rem; +} + +.backup-engine-docs-link:hover { + text-decoration: underline; +} + +/* Per-app backup status badge (used on app detail page) */ +.backup-app-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(var(--text-rgb), 0.05); + border-radius: 999px; + font-size: 0.78rem; + color: var(--text-secondary, rgba(var(--text-rgb), 0.65)); +} diff --git a/containers/libreportal/frontend/css/config.css b/containers/libreportal/frontend/css/config.css new file mode 100644 index 0000000..7da081e --- /dev/null +++ b/containers/libreportal/frontend/css/config.css @@ -0,0 +1,601 @@ + + +/* Config page forms, domain blocks, danger zone, toggles, warning banners. Extracted from style.css. */ + +/* Simple Configuration Tabs - Clean Design */ +.config-actions { + display: flex; + gap: 12px; + padding: 20px 20px 20px 0; + background: transparent; + margin-top: 0; + flex-wrap: wrap; +} + +.config-actions .action-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + text-decoration: none; +} + +.config-actions .action-btn svg { + width: 16px; + height: 16px; +} + +.config-actions .action-btn.primary { + background: var(--primary-color); + color: var(--text-primary); + border: 1px solid var(--primary-color); +} + +.config-actions .action-btn.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + transform: translateY(-1px); +} + +.config-actions .action-btn.secondary { + background: rgba(var(--text-rgb), 0.1); + border: 1px solid rgba(var(--text-rgb), 0.2); + color: var(--text-primary); +} + +.config-actions .action-btn.secondary:hover { + background: rgba(var(--text-rgb), 0.2); + transform: translateY(-1px); +} + +.config-actions .action-btn:not(.primary):not(.secondary) { + background: var(--card-bg); + border: 1px solid var(--border-color); + color: var(--text-primary, #fff); +} + +.config-actions .action-btn:not(.primary):not(.secondary):hover { + background: var(--hover-bg); + transform: translateY(-1px); +} + +.config-title { + padding: 20px; + background: transparent; + border-bottom: 1px solid var(--border-color); + margin-bottom: 0; +} + +.config-title h3 { + margin: 0 0 8px 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.config-title p { + margin: 0; + color: var(--text-secondary, #ccc); + font-size: 13px; +} + +/* Page-level header used on /config and /backup so both surfaces start + with the same prominent H1 + description above their content. */ +.page-header { + display: flex; + align-items: center; + gap: 16px; + padding: 22px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); + margin-bottom: 22px; + flex-wrap: wrap; + width: 100%; + box-sizing: border-box; +} + +.page-header-icon { + width: 36px; + height: 36px; + flex-shrink: 0; + color: var(--accent); +} + +.page-header-title { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex: 1; +} + +.page-header-title h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.page-header-title p { + margin: 0; + color: var(--text-secondary, rgba(var(--text-rgb), 0.6)); + font-size: 0.9rem; +} + +.page-header-title p:empty { + display: none; +} + +.page-header-actions { + display: flex; + gap: 10px; + flex-shrink: 0; + align-items: center; +} + +.config-container { + border-radius: 12px; + padding: 0; + margin-bottom: 20px; + overflow: hidden; +} + +/* "Unsaved config changes" bar — sits in flow between an app's config content + and its action buttons, shown by JS while any field differs from its saved + value. */ +.config-dirty-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 8px 0 16px; + padding: 12px 16px; + background: var(--card-bg, #1c1c24); + border: 1px solid var(--border-color, #333); + border-left: 3px solid var(--status-warning); + border-radius: 10px; + animation: configDirtyIn 0.2s ease; +} + +.config-dirty-msg { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + font-weight: 600; + color: var(--status-warning); +} + +.config-dirty-msg svg { flex-shrink: 0; } + +.config-dirty-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.advanced-warning-banner .warning-content h4 { + margin: 0 0 4px 0; + color: var(--status-warning); + font-size: 14px; + font-weight: 600; +} + +.advanced-warning-banner .warning-content p { + margin: 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.4; +} + +/* Traefik / domains "not installed" notice — glass card tinted with + the theme warning colour. Matches the .system-card recipe. */ +.traefik-warning-banner { + background: rgba(var(--status-warning-rgb), 0.10); + border: 1px solid rgba(var(--status-warning-rgb), 0.30); + border-left: 4px solid var(--status-warning); + border-radius: 12px; + padding: 14px 18px; + margin-bottom: 18px; + color: var(--text-primary); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.06); +} + +.traefik-warning-content { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.traefik-warning-icon { + flex-shrink: 0; + font-size: 18px; + line-height: 1.4; + color: var(--status-warning); +} + +.traefik-warning-text { + flex: 1; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.5; +} + +.traefik-warning-text strong { + color: var(--status-warning); + font-weight: 600; + margin-right: 2px; +} + +/* Configuration Form */ +.config-form { + padding: 0; + margin: 0; +} + +.config-form h3 { + font-size: 20px; + font-weight: 600; + margin-bottom: 2px; +} + +.config-group { +} + +.config-group h4 { + font-size: 16px; + font-weight: 600; + margin-bottom: 15px; + padding-bottom: 8px; + border-bottom: 2px solid; +} + +.config-fields { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + align-items: start; + grid-auto-flow: dense; + grid-auto-rows: minmax(min-content, max-content); +} + +/* Responsive column counts — colocated here (and not in style.css) + because the base 3-column rule above loads later than style.css, + so the unscoped base would otherwise win the cascade against the + style.css media queries that try to step down to 2 / 1 columns. */ +@media (max-width: 1280px) { + .config-fields { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 800px) { + .config-fields { + grid-template-columns: 1fr; + gap: 12px; + } +} + +.config-field { + display: flex; + flex-direction: column; + gap: 8px; + min-height: fit-content; +} + +.master-toggle, + +.master-toggle .field-group, +.git-master-toggle .field-group { + margin: 0; + width: 100%; +} + +.master-toggle input[type="checkbox"], +.git-master-toggle input[type="checkbox"] { + display: none; +} + +.config-field label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.config-field input, +.config-field select { + padding: 10px 12px; + border: 1px solid; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.2s; +} + +.config-field input:focus, +.config-field select:focus { + outline: none; + border-color: var(--accent); +} + +/* Enhanced Config Form Styles */ +.config-group { +} + +.config-group h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary, #fff); + margin: 0 0 8px 0; + padding-bottom: 8px; + border-bottom: 2px solid var(--primary-color, var(--accent)); +} + +.config-actions { + display: flex; + gap: 12px; + padding-top: 24px; + margin-top: 0px; +} + +.git-master-toggle .master-toggle { + display: flex; + align-items: center; + padding: 8px 0; + background: transparent; + border: none; + border-radius: 0; + cursor: pointer; + transition: none; +} + +.git-master-toggle .master-toggle:hover { + background: rgba(var(--text-rgb), 0.05); + transform: none; +} + +/* System Config Sections - Override domain-specific CSS for non-domain configs */ +.config-category:not(.app-config) .domains-header { + display: block; + margin-bottom: 16px; +} + +.config-category:not(.app-config) .domains-header h3 { + display: block; + width: auto; + justify-content: flex-start; +} + +/* Each subcategory on the global config page renders as its own glass + card, matching the .app-card recipe used on the App Center grid. + The outer .config-section becomes a transparent layout wrapper so + the categories aren't double-nested in two card chromes. */ +.config-category:not(.app-config) { + background: var(--card-bg); + border: 1px solid var(--card-border, var(--border-color)); + border-radius: 12px; + padding: 20px; + box-shadow: var(--card-shadow); + margin-bottom: 16px; +} + +.config-category:not(.app-config) .spacer-lg { + display: none; +} + +/* Advanced subcategory badge — applied to subcategories rendered + inside #advanced-sections. The old "🛠️ Advanced Configuration" + divider above the group is gone; each subcategory's own h3 now + carries an inline red "ADVANCED" tag so it self-identifies. */ +.is-advanced .domains-header h3, +.is-advanced .config-category h3 { + display: block; + width: 100%; + margin: 0; +} + +.is-advanced .domains-header h3::after, +.is-advanced .config-category h3::after { + content: 'Advanced'; + display: inline-block; + vertical-align: middle; + margin-left: 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 3px 9px; + border-radius: 4px; + background: rgba(var(--status-danger-rgb), 0.18); + border: 1px solid rgba(var(--status-danger-rgb), 0.55); + color: var(--status-danger); + line-height: 1; + position: relative; + top: -1px; +} + +/* App Config Sections - Complete separation from domain/system styling */ +.app-config { + /* App config container styling */ +} + +.app-config .panel-fields { + display: flex; + flex-direction: column; + gap: 16px; +} + +.domain-input { + background: var(--card-bg); + border: 2px solid var(--border-color); + border-radius: 8px; + padding: 12px 16px; + font-size: 16px; + font-weight: 500; + transition: all 0.3s ease; +} + +.domain-input:focus { + border-color: var(--primary-color, var(--accent)); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.1); + outline: none; +} + +.domain-building-blocks { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + align-items: start; + grid-auto-flow: dense; + grid-auto-rows: minmax(min-content, max-content); +} + +.domain-building-block { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + display: flex; + flex-direction: column; + min-height: fit-content; + gap: 12px; +} + +.domain-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.domain-actions { + padding: 10px 0; + display: flex; + justify-content: flex-start; + align-items: flex-start; +} + +.domain-input.flash { + animation: flash 0.5s ease-in-out 2; +} + +/* Wrapper around the whole config-page content — matches the visual + "card" the App Center uses for its grid, so the two pages feel + consistent (subtle glass surface + soft border + rounded corners + floating on the cosmic gradient). */ +.config-section { + margin: 22px; + padding: 22px; + background: rgba(var(--text-rgb), 0.025); + border: 1px solid var(--border-subtle); + border-radius: 16px; +} + +/* Inside an app-detail tab-pane the .config-section's card chrome is + redundant — the tab-pane is itself a card and the sibling tabs + (Services / Tools / Backups / …) drop their content directly into + the pane without an extra wrapper. Strip the inner card so the + Config tab matches the rest. */ +.tab-pane .config-section { + margin: 0; + padding: 0; + background: transparent; + border: none; +} + +/* ===== Mobile (≤768px) — global Config page ===== */ +@media (max-width: 768px) { + .config-section { + margin: 10px; + padding: 12px; + /* flex-shrink: 0 keeps the section at its content's natural height + inside .main's flex column — without it, the section's overflow + (combined with .main's flex-shrink) collapsed the section down to + the .main viewport height and the vertical scrollbar never + appeared. overflow-x: hidden still clips wide grandchildren. */ + overflow-x: hidden; + flex-shrink: 0; + } + + .config-category:not(.app-config) { + padding: 22px; + } + + .config-section, + .config-form, + .config-category, + .config-group, + .config-fields, + .config-field, + .field-group, + .domains-wrapper, + .domains-header, + .password-input-group { + min-width: 0; + max-width: 100%; + } + + .field-group, + .config-field { + width: 100%; + } + + .config-field input, + .config-field select, + .config-field textarea, + .field-group input, + .field-group select, + .field-group textarea, + .form-input, + .form-select, + .form-textarea, + .form-control { + min-width: 0; + max-width: 100%; + width: 100%; + } + + .config-form { + margin: 0; + padding: 0; + } + + .config-title { + padding: 14px; + } + + .config-title h3 { + font-size: 16px; + } + + .config-container { + margin-bottom: 12px; + } + + .config-fields { + grid-template-columns: 1fr; + gap: 12px; + } + + .config-actions { + padding: 14px 0; + gap: 8px; + } + + .config-actions .action-btn { + flex: 1 1 100%; + justify-content: center; + padding: 12px 16px; + } + + .config-dirty-bar { + flex-direction: column; + align-items: stretch; + gap: 10px; + } +} diff --git a/containers/libreportal/frontend/css/dashboard.css b/containers/libreportal/frontend/css/dashboard.css new file mode 100644 index 0000000..e28362a --- /dev/null +++ b/containers/libreportal/frontend/css/dashboard.css @@ -0,0 +1,62 @@ +/* Dashboard page styling. Extracted from dashboard-content.html so all + CSS lives under css/. Currently only mobile-responsive overrides. */ + +@media (max-width: 768px) { + .section-header { + display: flex; + flex-direction: column; + gap: 15px; + } + + .header-row { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } + + .header-left { + display: flex; + align-items: center; + flex: 1; + } + + .header-left h2 { + margin: 0; + font-size: 1.2rem; + } + + .install-btn { + padding: 8px 12px; + font-size: 0.9rem; + white-space: nowrap; + margin-left: auto; + flex-shrink: 0; + } + + .filter-controls { + display: flex; + flex-direction: column; + gap: 10px; + } + + .search-input, + .category-filter { + width: 100%; + margin-bottom: 10px; + } + + /* Remove the bottom flex-stretch on the dashboard page only. + Scoped to .dashboard-main (added in dashboard-content.html) so + this never leaks onto Config / Tasks / Apps, where their inner + .main needs flex: 1 to fill the column next to the sidebar. */ + .dashboard-main { + flex: none !important; + margin-bottom: 0 !important; + } + + .dashboard-content { + margin-bottom: 0; + padding-bottom: 0; + } +} diff --git a/containers/libreportal/frontend/css/forms.css b/containers/libreportal/frontend/css/forms.css new file mode 100644 index 0000000..b5625e2 --- /dev/null +++ b/containers/libreportal/frontend/css/forms.css @@ -0,0 +1,1042 @@ + + +/* Form fields, inputs, checkboxes/tickboxes, input groups. Extracted from style.css. */ + +/* Form Fields */ +.form-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.form-label { + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; + color: var(--text-primary, #fff); + font-size: 13px; +} + +.required { + color: var(--danger-color); + font-weight: bold; +} + +.help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + background: var(--primary-color); + color: var(--text-primary); + border-radius: 50%; + font-size: 9px; + font-weight: bold; + cursor: help; + position: relative; +} + +.help-icon:hover::after { + content: attr(title); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: var(--tooltip-bg); + color: var(--tooltip-text); + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + z-index: 1000; + margin-bottom: 6px; + min-width: 180px; + text-align: center; + font-weight: normal; +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary, #fff); + font-size: 13px; + transition: all 0.2s ease; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1); +} + +.form-textarea { + resize: vertical; + min-height: 80px; + font-family: inherit; +} + +.form-help { + color: var(--text-secondary, #ccc); + font-size: 11px; + font-style: italic; + margin-top: 4px; +} + +.checkbox-label.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.checkbox-label.disabled .checkbox-custom { + background: var(--hover-bg); + border-color: var(--border-color); + opacity: 0.5; +} + +.checkbox-label.disabled .checkbox-text { + color: var(--text-secondary, #ccc); +} + +.form-label.disabled { + color: var(--text-secondary, #ccc); + opacity: 0.6; +} + +.form-input.disabled { + background: var(--hover-bg); + border-color: var(--border-color); + opacity: 0.6; + cursor: not-allowed; +} + +/* Input Group for Number + Unit */ +.input-group { + display: flex; + align-items: stretch; +} + +.input-group .form-control { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: none; +} + +.input-group .form-control:focus { + border-right: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 0.2rem rgba(var(--accent-rgb), 0.25); +} + +.input-group-text { + display: flex; + align-items: center; + padding: 10px 12px; + font-size: 14px; + font-weight: 500; + line-height: 1.5; + color: var(--text-color); + text-align: center; + white-space: nowrap; + background-color: var(--input-bg); + border: 1px solid var(--border-color); + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + min-width: 50px; + justify-content: center; +} + +.master-toggle .checkbox-label, +.git-master-toggle .checkbox-label { + display: flex; + align-items: center; + font-weight: 600; + font-size: 16px; + cursor: pointer; + width: 100%; +} + +/* .master-toggle and .git-master-toggle are section-enable controls + (e.g. backup remote 1, mail config). They inherit the default toggle + visual from .checkbox-custom — no overrides needed here. The + .git-master-toggle block lower in this file remains for legacy + layout (font-weight/sizing on the surrounding label). */ + +.section-content.disabled .field-group { + opacity: 0.6; +} + +/* Themed in a .custom-select div — we have to constrain the + wrapper (which is what's visually rendered) AND the native select + (which is what's there when JS hasn't enhanced it yet, or when + custom-select is opted out via data-no-enhance). */ +.git-master-toggle select.form-control, +.git-master-toggle .custom-select { + width: auto !important; + max-width: 33%; +} + +.git-master-toggle .checkbox-text { + font-weight: 600; + font-size: 16px; + margin-left: 8px; +} + +.git-master-toggle .checkbox-label { + display: flex; + align-items: center; + cursor: pointer; + padding: 0; + border-radius: 0; + transition: none; + background: transparent; +} + +.git-master-toggle .checkbox-label:hover { + background: transparent; +} + +/* Toggle Switch Design - Complete override */ +.git-master-toggle .checkbox-label input[type="checkbox"] { + display: none !important; + opacity: 0 !important; + position: absolute !important; +} + +.git-master-toggle .checkbox-custom { + position: relative; + width: 48px; + height: 24px; + background: var(--border-strong); + border-radius: 24px; + margin-right: 12px; + transition: all 0.3s ease; + cursor: pointer; + border: none !important; +} + +.git-master-toggle .checkbox-custom::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: all 0.3s ease; +} + +.git-master-toggle .checkbox-label input[type="checkbox"]:checked + .checkbox-custom { + background: var(--primary-color) !important; + border-color: var(--primary-color) !important; +} + +.git-master-toggle .checkbox-label input[type="checkbox"]:checked + .checkbox-custom::before { + transform: translateX(24px); +} + +.git-master-toggle .checkbox-label input[type="checkbox"]:hover + .checkbox-custom { +} + +.git-master-toggle .checkbox-text { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + user-select: none; +} + +/* Config page checkboxes */ +.checkbox-label { + display: flex; + align-items: center; + cursor: pointer; + padding: 10px 16px; + min-height: 44px; + border-radius: 10px; + transition: background 0.18s ease, border-color 0.18s ease; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); +} + +/* In a multi-column config row the toggle cell only contains the + toggle (no label/help text above it like the input cells do), so + it sits at the top of the row while the inputs underneath their + labels run lower. align-self: end drops the toggle to the bottom + of the grid row so its row aligns with the inputs' row, not the + inputs' labels. */ +.config-fields > .checkbox-field { + align-self: end; +} + +.checkbox-label:hover { + background: rgba(var(--text-rgb), 0.07); + border-color: rgba(var(--accent-rgb), 0.35); +} + +.checkbox-label.disabled { + opacity: 0.6; + cursor: not-allowed; + background: transparent; + border-color: var(--border-color); +} + +.checkbox-label.disabled .checkbox-custom { + background: var(--hover-bg); + border-color: var(--border-color); +} + +.checkbox-label input[type="checkbox"] { + display: none !important; + opacity: 0 !important; + position: absolute !important; + cursor: pointer; +} + +/* ------------------------------------------------------------------ + Default checkbox visual = TOGGLE SWITCH. + Most app/config options are binary data settings (Enable X, Auto-Y) + for which a sliding switch reads as "on/off". + + The TICKBOX variant (square with check) lives further down, scoped + to .toggle-section and .advanced-toggle-field — those host the + "Show Advanced / Show Unused" UI preferences where a tickbox better + signals "this affects what you see, not the data". + ------------------------------------------------------------------ */ +.checkbox-custom { + position: relative; + width: 48px; + height: 24px; + flex-shrink: 0; + margin-right: 12px; + background: rgba(var(--text-rgb), 0.20); + border: none; + border-radius: 24px; + cursor: pointer; + transition: background 0.18s ease, box-shadow 0.18s ease; +} + +.checkbox-custom::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: var(--text-primary); + border-radius: 50%; + transition: transform 0.18s ease; +} + +.checkbox-label input[type="checkbox"]:hover + .checkbox-custom { + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.10); +} + +.checkbox-label input[type="checkbox"]:checked + .checkbox-custom { + background: var(--accent); +} + +.checkbox-label input[type="checkbox"]:checked + .checkbox-custom::before { + transform: translateX(24px); +} + +/* ------------------------------------------------------------------ + TICKBOX variant — used for "this changes what you see" preferences + like Show Advanced Options / Show Unused Options / Show advanced + settings. Scoped by parent class so the toggle stays the default + everywhere else. + ------------------------------------------------------------------ */ +.toggle-section .checkbox-custom, +.advanced-toggle-field .checkbox-custom { + width: 20px; + height: 20px; + margin-right: 10px; + background: rgba(var(--text-rgb), 0.05); + border: 1.5px solid rgba(var(--text-rgb), 0.40); + border-radius: 4px; + box-shadow: none; +} + +.toggle-section .checkbox-custom::before, +.advanced-toggle-field .checkbox-custom::before { + content: none; +} + +.toggle-section .checkbox-custom::after, +.advanced-toggle-field .checkbox-custom::after { + content: ''; + position: absolute; + top: 2px; + left: 6px; + width: 5px; + height: 10px; + border: solid var(--text-on-accent, #ffffff); + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.15s ease; +} + +.toggle-section .checkbox-label input[type="checkbox"]:hover + .checkbox-custom, +.advanced-toggle-field .checkbox-label input[type="checkbox"]:hover + .checkbox-custom { + border-color: var(--accent); + box-shadow: none; +} + +.toggle-section .checkbox-label input[type="checkbox"]:checked + .checkbox-custom, +.advanced-toggle-field .checkbox-label input[type="checkbox"]:checked + .checkbox-custom { + background: var(--accent); + border-color: var(--accent); +} + +.toggle-section .checkbox-label input[type="checkbox"]:checked + .checkbox-custom::after, +.advanced-toggle-field .checkbox-label input[type="checkbox"]:checked + .checkbox-custom::after { + opacity: 1; +} + +.checkbox-text { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + user-select: none; +} + +.group-fields .form-field { + margin-bottom: 20px; +} + +.group-fields .form-field:last-child { + margin-bottom: 0; +} + +.app-config .form-field { + margin-bottom: 16px; +} + +.app-config .form-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--text-color); + font-size: 14px; +} + +/* App-config inputs share the .form-control recipe used by the standard + config page — translucent theme-aware border + glass fill — so app + config fields stop reading as a near-black slab against the nebula + gradient. */ +.app-config .form-input, +.app-config .form-select, +.app-config .config-input, +.config-input { + width: 100%; + padding: 10px 12px; + border: 1px solid rgba(var(--text-rgb), 0.20); + border-radius: 8px; + background: rgba(var(--text-rgb), 0.05); + color: var(--text-primary, #fff); + font-size: 14px; + font-family: inherit; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.app-config .form-input:focus, +.app-config .form-select:focus, +.app-config .config-input:focus, +.config-input:focus { + outline: none; + border-color: var(--accent); + background: rgba(var(--text-rgb), 0.08); +} + +.app-config .form-input::placeholder, +.app-config .config-input::placeholder, +.config-input::placeholder { + color: var(--text-secondary, #ccc); + opacity: 0.7; +} + +.app-config .form-help { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + +/* App Config Checkboxes - EXACT copy of global toggle switch styling */ +.app-config .checkbox-label { + display: flex; + align-items: center; + cursor: pointer; + padding: 4px; + border-radius: 6px; + transition: all 0.2s ease; + background: #0000; + border: 1px solid transparent; +} + +.app-config .checkbox-label:hover { + /* background: rgba(var(--text-rgb),0.05); */ +} + +.app-config .checkbox-label.disabled { + opacity: 0.6; + cursor: not-allowed; + background: transparent; + border-color: var(--border-color); +} + +.app-config .checkbox-label.disabled .checkbox-custom { + background: var(--hover-bg); + border-color: var(--border-color); +} + +.app-config .checkbox-label.disabled .checkbox-text { + color: var(--text-secondary, #ccc); +} + +.app-config .checkbox-label:hover .checkbox-text { + color: var(--text-primary); +} + +.app-config .checkbox-text { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + user-select: none; +} + +.app-config .checkbox-label input[type="checkbox"] { + display: none !important; + opacity: 0 !important; + position: absolute !important; + cursor: pointer; +} + +.app-config .checkbox-custom { + position: relative; + width: 48px; + height: 24px; + background: var(--border-strong); + border-radius: 24px; + margin-right: 12px; + transition: all 0.3s ease; + cursor: pointer; + border: none; +} + +.app-config .checkbox-custom::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: all 0.3s ease; +} + +.app-config .checkbox-label input[type="checkbox"]:hover + .checkbox-custom { +} + +.app-config .checkbox-label input[type="checkbox"]:checked + .checkbox-custom { + background: var(--primary-color); + border-color: var(--primary-color); +} + +.app-config .checkbox-label input[type="checkbox"]:checked + .checkbox-custom::before { + transform: translateX(24px); +} + +/* Field Group Styling for Domain Blocks */ +.domain-building-block .field-group { + margin: 0; + width: 100%; + display: flex; + flex-direction: column; +} + +.domain-building-block .field-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--text-color); + font-size: 14px; +} + +.domain-building-block .form-control { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--input-bg); + color: var(--text-color); + font-size: 14px; + transition: border-color 0.3s ease; + box-sizing: border-box; +} + +.domain-building-block .form-control:focus { + outline: none; + border-color: var(--primary-color, var(--accent)); + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.25); +} + +/* Toggle-specific spacing */ +.checkbox-label.master-toggle { + padding: 20px; +} + +/* Enhanced field styling */ +.form-field { + position: relative; +} + +.form-label { + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; + margin-bottom: 6px; +} + +.required { + color: var(--error-color); + font-weight: 600; +} + +.help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + background: var(--primary-color); + color: var(--text-primary); + border-radius: 50%; + font-size: 10px; + font-weight: bold; + cursor: help; + margin-left: 4px; +} + +.form-help { + display: block; + margin-top: 4px; + font-size: 11px; + color: var(--text-secondary); + font-style: italic; +} + +/* "Show Advanced / Show Unused" UI-preference wrapper. Glass card that + reads on every theme — replaces an older recipe that hardcoded + var(--surface-bg-solid) (solid dark navy on nebula) plus a near-white + #f9f9f9 hover, which made the label invisible on dark themes. */ +.toggle-section .checkbox-label { + display: flex; + align-items: flex-start; + cursor: pointer; + width: 100%; + padding: 14px 16px; + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 10px; + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.04); + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.toggle-section .checkbox-label:hover { + background: rgba(var(--text-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.40); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.06); +} + +/* .toggle-section .checkbox-custom intentionally NOT redefined here. + The shared tickbox styling earlier in this file already scopes the + square-check visual to .toggle-section, so adding another rule here + would just override it. */ + +.toggle-section .checkbox-text { + font-weight: 600; + color: var(--text-primary); + font-size: 16px; + margin-bottom: 4px; +} + +.toggle-section .checkbox-description { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; +} + +/* Red-flash highlight for required-but-empty fields when the user clicks + Install. Cleared on the next input/change. Native title attribute on the + element provides the "Required" tooltip on hover. */ +.field-required-error { + border: 1.5px solid var(--status-danger) !important; + box-shadow: 0 0 0 3px rgba(var(--status-danger-rgb), 0.20); + animation: requiredFlash 0.6s ease-in-out 2; + background: rgba(var(--status-danger-rgb), 0.06) !important; +} + +/* ============================================================ + Custom popups are drawn by the OS and ignore CSS for the + popup chrome. This component visually replaces the native select + while keeping it in the DOM for form submission / change events. + ============================================================ */ +.custom-select { + position: relative; + width: 100%; + display: inline-block; +} + +/* The native with themed + up/down chevrons that read consistently across browsers/themes. + ============================================================ */ + +/* Hide native browser spin buttons. */ +.custom-number-input::-webkit-outer-spin-button, +.custom-number-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.custom-number-input { + -moz-appearance: textfield; +} + +.custom-number { + position: relative; + display: inline-flex; + width: 100%; + align-items: stretch; +} + +/* The input itself stretches to fill, leaving room for the controls. */ +.custom-number .custom-number-input { + flex: 1; + min-width: 0; + padding-right: 36px; +} + +.custom-number.is-disabled { + opacity: 0.6; +} + +.custom-number-controls { + position: absolute; + top: 4px; + right: 4px; + bottom: 4px; + display: flex; + flex-direction: column; + width: 24px; + border-radius: 6px; + overflow: hidden; + background: rgba(var(--text-rgb), 0.05); +} + +.custom-number-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: rgba(var(--text-rgb), 0.65); + cursor: pointer; + padding: 0; + margin: 0; + transition: background 0.12s ease, color 0.12s ease; +} + +.custom-number-btn:hover:not(:disabled) { + background: rgba(var(--accent-rgb), 0.20); + color: var(--accent); +} + +.custom-number-btn:active:not(:disabled) { + background: rgba(var(--accent-rgb), 0.35); +} + +.custom-number-btn:disabled, +.custom-number-btn.is-disabled { + opacity: 0.30; + cursor: not-allowed; +} + +/* Subtle divider between up/down. */ +.custom-number-up { + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); +} diff --git a/containers/libreportal/frontend/css/ip-whitelist.css b/containers/libreportal/frontend/css/ip-whitelist.css new file mode 100755 index 0000000..c1d2beb --- /dev/null +++ b/containers/libreportal/frontend/css/ip-whitelist.css @@ -0,0 +1,248 @@ + + +/* Whitelist Building Blocks Container - Scoped to config section */ +.config-category .whitelist-building-blocks { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + grid-auto-rows: minmax(min-content, max-content); +} + +/* Responsive Grid Layout */ +@media (max-width: 1600px) { + .config-category .whitelist-building-blocks { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 800px) { + .config-category .whitelist-building-blocks { + grid-template-columns: 1fr; + gap: 16px; + } +} + +/* Individual Whitelist Building Block - Scoped */ +.config-category .whitelist-building-block { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + display: flex; + flex-direction: column; + min-height: fit-content; + gap: 12px; +} + +.config-category .whitelist-building-block:hover { + transform: translateY(-2px); +} + +/* Whitelist Header Layout - Scoped */ +.config-category .whitelist-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +/* Delete Whitelist Button - Scoped to match domain manager */ +.config-category .delete-whitelist-btn { + background: var(--status-danger); + color: var(--text-primary); + border: none; + border-radius: 4px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + flex-shrink: 0; + font-size: 14px; + font-weight: bold; + line-height: 1; +} + +.config-category .delete-whitelist-btn:hover { + background: var(--status-danger-hover); + transform: scale(1.05); +} + +.config-category .delete-whitelist-btn.disabled { + background: var(--text-muted); + color: var(--text-muted); + cursor: not-allowed; + transform: none; +} + +.config-category .delete-whitelist-btn.disabled:hover { + background: var(--text-muted); + transform: none; +} + +/* Delete Icon - Scoped */ +.config-category .delete-icon { + font-size: 14px; + font-weight: bold; + line-height: 1; +} + +/* Whitelist Input Fields - Scoped */ +.config-category .whitelist-input { + flex: 1; + min-width: 0; +} + +/* Whitelist Empty State - Scoped */ +.config-category .whitelist-empty-state { + grid-column: 1 / -1; + text-align: center; + padding: 40px 20px; + background: var(--card-bg); + border: 2px dashed var(--border-color); + border-radius: 8px; + color: var(--text-muted); +} + +.config-category .whitelist-empty-state p { + margin-bottom: 20px; + font-size: 16px; + line-height: 1.5; +} + +/* Whitelist Actions Container - Scoped */ +.config-category .whitelist-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-start; + padding: 10px 0; +} + +/* Add Whitelist Entry Button - Scoped */ +.config-category .whitelist-actions .btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.config-category .whitelist-actions .btn-primary { + background: var(--primary-color, var(--accent)); + color: var(--text-primary); +} + +.config-category .whitelist-actions .btn-primary:hover { + background: var(--primary-hover, var(--accent-hover)); + transform: translateY(-1px); +} + +.config-category .whitelist-actions .btn-secondary { + background: var(--secondary-color, var(--text-muted)); + color: var(--text-primary); + cursor: not-allowed; +} + +.config-category .whitelist-actions .btn-secondary:hover { + background: var(--secondary-hover, var(--text-muted)); + transform: none; +} + +/* Add Icon - Scoped */ +.config-category .add-icon { + font-size: 16px; + font-weight: bold; + line-height: 1; +} + +/* Field Group Styling for Whitelist - Scoped */ +.config-category .whitelist-building-block .field-group { + margin: 0; + width: 100%; + display: flex; + flex-direction: column; +} + +.config-category .whitelist-building-block .field-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--text-color); + font-size: 14px; +} + +.config-category .whitelist-building-block .form-control { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--input-bg); + color: var(--text-color); + font-size: 14px; + transition: border-color 0.3s ease; + box-sizing: border-box; +} + +.config-category .whitelist-building-block .form-control:focus { + outline: none; + border-color: var(--primary-color, var(--accent)); + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.25); +} + +.config-category .whitelist-building-block .form-control.error { + border-color: var(--status-danger); +} + +.config-category .whitelist-building-block .field-description { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + +/* Flash Animation for Validation - Scoped */ +@keyframes whitelist-flash { + 0%, 100% { + background-color: transparent; + border-color: var(--border-color); + } + 50% { + background-color: rgba(var(--status-danger-rgb), 0.1); + border-color: var(--status-danger); + } +} + +.config-category .whitelist-building-block.flash { + animation: whitelist-flash 0.5s ease-in-out 2; +} + +.config-category .whitelist-input.flash { + animation: whitelist-flash 0.5s ease-in-out 2; +} + +/* Responsive Adjustments - Scoped */ +@media (max-width: 600px) { + .config-category .whitelist-header { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .config-category .delete-whitelist-btn { + align-self: flex-end; + margin-top: 8px; + } + + .config-category .whitelist-actions .btn { + width: 100%; + justify-content: center; + } +} diff --git a/containers/libreportal/frontend/css/loading-screen.css b/containers/libreportal/frontend/css/loading-screen.css new file mode 100755 index 0000000..1cb5ab6 --- /dev/null +++ b/containers/libreportal/frontend/css/loading-screen.css @@ -0,0 +1,541 @@ +/* Loading Screen Styles */ +.loading-screen { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + max-height: 100vh; + overflow-y: auto; + background: linear-gradient(135deg, var(--gradient-from) 0%, var(--gradient-mid) 50%, var(--gradient-to) 100%); + color: var(--text-primary); + z-index: 9999; + display: flex; + align-items: flex-start; + justify-content: center; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + opacity: 1; + transition: opacity 0.3s ease-out; + padding: 2rem 0; + box-sizing: border-box; +} + +.loading-screen.hiding { + opacity: 0; + pointer-events: none; +} + +.loading-screen.success { + animation: successGlow 0.8s ease-out forwards; +} + +@keyframes successGlow { + 0% { + box-shadow: inset 0 0 0 rgba(var(--accent-rgb), 0); + } + 100% { + } +} + +.success-message { + text-align: center; + padding: 1rem; + margin-top: 1rem; + opacity: 0; + transform: translateY(20px); + transition: all 0.3s ease-out; +} + +.loading-screen.success .success-message { + opacity: 1; + transform: translateY(0); +} + +.success-message h2 { + margin: 0; + font-size: 2rem; + background: linear-gradient(45deg, var(--accent), var(--accent-hover)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.success-message p { + margin: 0.5rem 0 0 0; + color: var(--accent); + font-size: 1.1rem; +} + +.loading-container { + max-width: 600px; + width: 90%; + padding: 2rem; +} + +.loading-subtitle { + margin-top: 1rem; +} + +#loading-status-text { + font-size: 1.2rem; + color: var(--text-secondary); + font-weight: 400; +} + +/* Progress Bar */ +.loading-progress { + margin-bottom: 3rem; +} + +.progress-bar-container { + background: rgba(var(--text-rgb), 0.1); + border-radius: 8px; + padding: 2px; + backdrop-filter: blur(10px); + border: 1px solid rgba(var(--text-rgb), 0.1); +} + +.progress-bar { + height: 8px; + background: rgba(var(--text-rgb), 0.05); + border-radius: 20px; + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: 20px; + width: 0%; + transition: width 0.3s ease-out; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, + transparent, + rgba(var(--text-rgb), 0.3), + transparent + ); + animation: shimmer 2s infinite; +} + +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.progress-text { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1rem; + padding: 0 0.5rem; + font-size: 0.9rem; +} + +#progress-percentage { + font-weight: 600; + color: var(--accent); +} + +#progress-details { + color: var(--text-secondary); +} + +/* System Status Cards */ +.loading-systems { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 2rem; + max-height: 268px; /* Fixed height */ + overflow-y: auto; /* Scrollbar when content overflows */ + padding-right: 0.5rem; /* Space for scrollbar */ +} + +/* Custom scrollbar styling */ +.loading-systems::-webkit-scrollbar { + width: 6px; +} + +.loading-systems::-webkit-scrollbar-track { + background: rgba(var(--text-rgb), 0.05); + border-radius: 3px; +} + +.loading-systems::-webkit-scrollbar-thumb { + background: rgba(var(--accent-rgb), 0.3); + border-radius: 3px; +} + +.loading-systems::-webkit-scrollbar-thumb:hover { + background: rgba(var(--accent-rgb), 0.5); +} + +.system-card { + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.1); + border-radius: 12px; + padding: 1rem; + display: flex; + align-items: center; + gap: 1rem; + backdrop-filter: blur(10px); + transition: all 0.3s ease; +} + +.system-card:hover { + background: rgba(var(--text-rgb), 0.08); + transform: translateY(-2px); +} + +.system-icon { + font-size: 1.5rem; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(var(--text-rgb), 0.1); + border-radius: 8px; +} + +.system-info { + flex: 1; + text-align: left; +} + +.system-name { + font-weight: 600; + font-size: 1rem; + margin-bottom: 0.25rem; +} + +.system-status { + font-size: 0.85rem; + color: var(--text-secondary); + transition: color 0.3s ease; +} + +.system-indicator { + font-size: 1.2rem; +} + +/* System Card States */ +.system-card.checking { + border-color: var(--status-warning); + background: rgba(var(--status-warning-rgb), 0.1); +} + +.system-card.checking .system-status { + color: var(--status-warning); +} + +.system-card.retrying { + border-color: var(--status-warning); + background: rgba(var(--status-warning-rgb), 0.1); + animation: pulse-retry 2s infinite; +} + +.system-card.retrying .system-status { + color: var(--status-warning); +} + +.system-card.waiting { + border-color: var(--status-danger); + background: rgba(var(--status-danger-rgb), 0.1); + animation: pulse-wait 3s infinite; +} + +.system-card.waiting .system-status { + color: var(--status-danger); +} + +.system-card.passed { + border-color: #86efac; + background: rgba(134, 239, 172, 0.10); +} + +.system-card.passed .system-status { + color: #86efac; +} + +.system-card.failed { + border-color: var(--status-danger); + background: rgba(var(--status-danger-rgb), 0.1); +} + +.system-card.failed .system-status { + color: var(--status-danger); +} + +.system-card.skipped { + border-color: var(--text-muted); + background: rgba(var(--text-rgb), 0.1); +} + +.system-card.skipped .system-status { + color: var(--text-muted); +} + +/* Retry and waiting animations */ +@keyframes pulse-retry { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } +} + +@keyframes pulse-wait { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +/* Command styling for tooltips and messages */ +.command-box { + background: var(--code-bg); + color: var(--text-secondary); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 0.9em; + border: 1px solid var(--border-strong); + white-space: nowrap; +} + +/* Actions */ +.loading-actions { + margin-bottom: 2rem; + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + min-width: 120px; + justify-content: center; +} + +.btn-primary { + background: linear-gradient(45deg, var(--accent), var(--accent-hover)); + color: var(--text-primary); +} + +.btn-primary:hover { + transform: translateY(-2px); +} + +.btn-secondary { + background: rgba(var(--text-rgb), 0.1); + color: var(--text-primary); + border: 1px solid rgba(var(--text-rgb), 0.2); +} + +.btn-secondary:hover { + background: rgba(var(--text-rgb), 0.15); + transform: translateY(-2px); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; +} + +.btn-icon { + font-size: 1.1rem; +} + +/* Error Details */ +.error-details { + background: rgba(var(--status-danger-rgb), 0.1); + border: 1px solid rgba(var(--status-danger-rgb), 0.3); + border-radius: 12px; + padding: 1.5rem; + margin: 0 auto 2rem auto; /* Push down from system cards, space before actions */ + max-width: 500px; + text-align: left; + animation: slideDown 0.3s ease-out; +} + +.error-details h4 { + margin: 0 0 1rem 0; + color: var(--status-danger); + font-size: 1.1rem; +} + +.error-details ul { + margin: 0; + padding-left: 1.5rem; + color: var(--status-danger); +} + +.error-details li { + margin-bottom: 0.5rem; + line-height: 1.4; +} + +.error-details small { + color: var(--status-danger); + font-size: 0.85rem; +} + +.error-details p { + margin: 1rem 0 0 0; + color: var(--text-secondary); + font-style: italic; +} + +/* Footer */ +.loading-footer { + margin-top: auto; + padding-top: 2rem; +} + +.loading-tips { + background: rgba(var(--text-rgb), 0.05); + border-radius: 8px; + padding: 1rem; + border: 1px solid rgba(var(--text-rgb), 0.1); +} + +.loading-tips p { + margin: 0; + font-size: 0.9rem; + color: var(--text-secondary); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .loading-container { + width: 95%; + padding: 1rem; + } + + .loading-logo h1 { + font-size: 2.5rem; + } + + .loading-actions { + flex-direction: column; + align-items: center; + } + + .btn { + width: 100%; + max-width: 250px; + } +} + +@media (max-width: 480px) { + .loading-logo h1 { + font-size: 2rem; + } + + .loading-logo p { + font-size: 1rem; + } + + .loading-subtitle, + #loading-status-text { + font-size: 1rem; + } + + .system-card { + padding: 0.75rem; + } + + .system-icon { + width: 35px; + height: 35px; + font-size: 1.2rem; + } +} + +/* Custom scrollbar for error list */ +.error-list-container::-webkit-scrollbar { + width: 10px !important; +} + +.error-list-container::-webkit-scrollbar-track { + background: rgba(var(--text-rgb), 0.1) !important; + border-radius: 4px !important; +} + +.error-list-container::-webkit-scrollbar-thumb { + background: rgba(var(--status-danger-rgb), 0.8) !important; + border-radius: 4px !important; + border: 1px solid rgba(var(--status-danger-rgb), 0.4) !important; +} + +.error-list-container::-webkit-scrollbar-thumb:hover { + background: rgba(var(--status-danger-rgb), 1.0) !important; +} + +/* Full page scrollbar for loading screen */ +.loading-screen::-webkit-scrollbar { + width: 12px; +} + +.loading-screen::-webkit-scrollbar-track { + background: rgba(var(--text-rgb), 0.05); + border-radius: 6px; +} + +.loading-screen::-webkit-scrollbar-thumb { + background: rgba(var(--text-rgb), 0.2); + border-radius: 6px; + border: 2px solid transparent; + background-clip: content-box; +} + +.loading-screen::-webkit-scrollbar-thumb:hover { + background: rgba(var(--text-rgb), 0.3); + background-clip: content-box; +} + +/* Error list container styling */ +.error-list-container { + max-height: 200px !important; + overflow-y: auto !important; + border: 1px solid var(--status-danger) !important; + border-radius: 4px !important; + padding: 10px !important; + margin: 10px 0 !important; + background: rgba(var(--status-danger-rgb), 0.1) !important; +} + +.error-list-container ul { + margin: 0 !important; + padding-left: 20px !important; +} + +.error-list-container li { + margin-bottom: 8px !important; +} diff --git a/containers/libreportal/frontend/css/login.css b/containers/libreportal/frontend/css/login.css new file mode 100755 index 0000000..1af0f5b --- /dev/null +++ b/containers/libreportal/frontend/css/login.css @@ -0,0 +1,199 @@ +.login-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + animation: loginOverlayIn 0.2s ease; +} + +.login-content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 420px; + margin: 1rem; +} + +.login-overlay .login-card { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--text-rgb), 0.12); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.login-overlay .login-label { + color: var(--text-secondary); +} + +.login-overlay .login-input { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--text-rgb), 0.12); + color: var(--text-primary); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.login-overlay .login-input:focus { + background: rgba(var(--text-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.18); +} + +.login-overlay .login-input::placeholder { + color: var(--text-muted); +} + +.login-overlay.hiding { + animation: loginOverlayOut 0.25s ease forwards; +} + +@keyframes loginOverlayIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes loginOverlayOut { + from { opacity: 1; } + to { opacity: 0; } +} + +.login-card { + width: 100%; + max-width: 380px; + background: var(--card-bg, #1e1e2e); + border: 1px solid var(--border-color, #333); + border-radius: 14px; + padding: 2rem; + animation: loginCardIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes loginCardIn { + from { transform: translateY(16px) scale(0.97); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.login-form { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.login-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.login-label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary, #888); + letter-spacing: 0.02em; +} + +.login-input { + width: 100%; + padding: 0.6rem 0.875rem; + background: var(--input-bg, #2a2a3e); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + color: var(--text-primary, var(--text-secondary)); + font-size: 0.9rem; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + box-sizing: border-box; +} + +.login-input:focus { + border-color: var(--accent-color, var(--accent)); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.15); +} + +.login-input::placeholder { + color: var(--text-muted, #555); +} + +.login-error { + display: none; + align-items: center; + gap: 0.6rem; + margin-top: 0.25rem; + padding: 0.7rem 0.85rem; + font-size: 0.82rem; + font-weight: 600; + line-height: 1.35; + color: var(--status-danger); + background: rgba(var(--status-danger-rgb), 0.12); + border: 1px solid rgba(var(--status-danger-rgb), 0.35); + border-left: 3px solid var(--status-danger); + border-radius: 10px; +} + +.login-error.visible { + display: flex; + animation: loginErrorIn 0.25s ease; +} + +@keyframes loginErrorIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.login-error-icon { + flex-shrink: 0; + color: var(--status-danger); +} + +.login-error-text { + flex: 1; +} + +.login-btn { + margin-top: 0.5rem; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: var(--text-on-accent); + border: none; + border-radius: 10px; + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.15s ease; +} + +.login-btn:hover:not(:disabled) { + transform: translateY(-2px); + filter: brightness(1.05); +} + +.login-btn:active:not(:disabled) { + transform: translateY(0); +} + +.login-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.login-btn-spinner { + display: none; + width: 14px; + height: 14px; + border: 2px solid rgba(var(--text-rgb),0.3); + border-top-color: var(--text-primary); + border-radius: 50%; + animation: loginSpin 0.6s linear infinite; + vertical-align: middle; + margin-right: 6px; +} + +.login-btn.loading .login-btn-spinner { display: inline-block; } +.login-btn.loading .login-btn-label { display: none; } + +@keyframes loginSpin { + to { transform: rotate(360deg); } +} diff --git a/containers/libreportal/frontend/css/modal.css b/containers/libreportal/frontend/css/modal.css new file mode 100755 index 0000000..4f73c3d --- /dev/null +++ b/containers/libreportal/frontend/css/modal.css @@ -0,0 +1,602 @@ +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(var(--bg-rgb), 0.92); +} + +/* Reusable toggle switch — replaces stock checkboxes inside modals. + Pure CSS, no JS, accessible (the underlying input still toggles). */ +.eo-toggle { + display: inline-flex; + align-items: center; + gap: 12px; + cursor: pointer; + user-select: none; +} +.eo-toggle input[type="checkbox"] { + position: absolute; + opacity: 0; + pointer-events: none; +} +.eo-toggle .eo-toggle-track { + position: relative; + width: 40px; + height: 22px; + background: rgba(var(--text-rgb), 0.18); + border-radius: 22px; + transition: background-color 0.12s linear; + flex-shrink: 0; + will-change: background-color; +} +.eo-toggle .eo-toggle-track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + background: var(--text-primary); + border-radius: 50%; + transition: transform 0.12s ease-out; + will-change: transform; +} +.eo-toggle input[type="checkbox"]:checked + .eo-toggle-track { + background: var(--status-success); +} +.eo-toggle input[type="checkbox"]:checked + .eo-toggle-track::after { + transform: translateX(18px); +} +.eo-toggle input[type="checkbox"]:focus-visible + .eo-toggle-track { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.eo-toggle .eo-toggle-text { + display: flex; + flex-direction: column; + gap: 2px; +} +.eo-toggle .eo-toggle-text-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} +.eo-toggle .eo-toggle-text-help { + font-size: 12px; + color: rgba(var(--text-rgb), 0.6); +} + +/* Card-framed variant — wraps the toggle in a subtle panel so it stands + apart inside a busy modal body. Use `.eo-toggle.eo-toggle-card`. */ +.eo-toggle-card { + display: flex; + width: 100%; + box-sizing: border-box; + padding: 12px 14px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 6px; +} +.eo-toggle-card:hover { + background: rgba(var(--text-rgb), 0.07); +} + +.modal-content { + background-color: var(--card-bg); + margin: 5% auto; + padding: 0; + border-radius: 12px; + width: 90%; + max-width: 900px; + max-height: 85vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Reusable empty/info-state card. Use inside any modal body or panel + when there's nothing to render and we need to explain why. Variants: + .info (cyan), .warning (amber, default), .danger (red). */ +.eo-empty-state { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-radius: 10px; + background: rgba(var(--status-warning-rgb), 0.08); + border: 1px solid rgba(var(--status-warning-rgb), 0.30); + color: var(--status-warning); + margin: 4px 0; +} +.eo-empty-state.info { + background: rgba(var(--accent-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.30); + color: var(--accent); +} +.eo-empty-state.danger { + background: rgba(var(--status-danger-rgb), 0.08); + border-color: rgba(var(--status-danger-rgb), 0.35); + color: #fecaca; +} +.eo-empty-state-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: rgba(var(--text-rgb), 0.06); + color: currentColor; +} +.eo-empty-state-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.eo-empty-state-title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} +.eo-empty-state-text { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: rgba(var(--text-rgb), 0.78); +} +.eo-empty-state-text strong { + color: var(--text-primary); + font-weight: 600; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + background-color: var(--card-bg); +} + +.modal-header h2 { + margin: 0; + color: var(--text-color); + font-size: 20px; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + font-size: 28px; + color: var(--text-color); + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: background-color 0.2s; +} + +.modal-close:hover { + background-color: rgba(var(--text-rgb), 0.1); +} + +.modal-body { + padding: 0; + overflow: hidden; + flex: 1; +} + +#readme-iframe { + width: 100%; + height: 80vh; + border: none; + background: white; + border-radius: 0 0 0 0; +} + +#readme-content { + color: var(--text-color); + line-height: 1.6; +} + +#readme-content h1, +#readme-content h2, +#readme-content h3 { + color: var(--text-color); + margin-top: 24px; + margin-bottom: 16px; +} + +#readme-content h1:first-child { + margin-top: 0; +} + +#readme-content p { + margin-bottom: 16px; +} + +#readme-content code { + background-color: rgba(var(--text-rgb), 0.1); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 0.9em; +} + +#readme-content pre { + background-color: rgba(var(--text-rgb), 0.05); + padding: 16px; + border-radius: 8px; + overflow-x: auto; + margin-bottom: 16px; + border: 1px solid var(--border-color); +} + +#readme-content pre code { + background: none; + padding: 0; +} + +#readme-content ul, +#readme-content ol { + margin-bottom: 16px; + padding-left: 24px; +} + +#readme-content blockquote { + border-left: 4px solid var(--primary-color); + padding-left: 16px; + margin: 16px 0; + color: var(--text-muted); + font-style: italic; +} + +#readme-content a { + color: var(--primary-color); + text-decoration: none; +} + +#readme-content a:hover { + text-decoration: underline; +} + +.loading { + text-align: center; + color: var(--text-color); + padding: 40px; + font-style: italic; +} + +.error { + color: var(--status-danger); + text-align: center; + padding: 40px; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .modal-content { + width: 95%; + margin: 2% auto; + max-height: 95vh; + } + + .modal-header { + padding: 16px 20px; + } + + .modal-body { + padding: 20px; + } + + .modal-header h2 { + font-size: 18px; + } +} + +/* ============================================================ + EO Modal — unified modal system. Use openEoModal() in JS. + See eo-modal.js for API + composable section primitives. + ============================================================ */ + +.eo-modal { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(var(--bg-rgb), 0.92); + padding: 16px; +} +.eo-modal-content { + display: flex; + flex-direction: column; + width: 100%; + max-width: 640px; + max-height: 88vh; + background: var(--card-bg, var(--code-bg)); + border: 1px solid var(--border-color, var(--border-strong)); + border-radius: 12px; + overflow: hidden; +} +.eo-modal[data-size="sm"] .eo-modal-content { max-width: 460px; } +.eo-modal[data-size="lg"] .eo-modal-content { max-width: 820px; } + +.eo-modal-header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 22px; + border-bottom: 1px solid var(--border-color, var(--border-strong)); +} +.eo-modal-header-info { display: flex; gap: 14px; align-items: center; flex: 1; min-width: 0; } +.eo-modal-icon { + width: 56px; + height: 56px; + border-radius: 10px; + object-fit: contain; + background: rgba(var(--text-rgb), 0.04); + padding: 4px; + flex-shrink: 0; +} +.eo-modal[data-size="sm"] .eo-modal-icon { width: 36px; height: 36px; } +.eo-modal-eyebrow { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--accent); + margin-bottom: 2px; +} +.eo-modal-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--text-primary); line-height: 1.2; } +.eo-modal-desc { margin: 4px 0 0 0; font-size: 13px; color: rgba(var(--text-rgb), 0.65); line-height: 1.4; } +.eo-modal-close { + background: none; + border: none; + color: rgba(var(--text-rgb), 0.7); + font-size: 26px; + line-height: 1; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.12s, color 0.12s; +} +.eo-modal-close:hover { background: rgba(var(--text-rgb), 0.10); color: var(--text-primary); } + +.eo-modal-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 18px 22px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.eo-modal-footer { + display: flex; + gap: 10px; + padding: 14px 22px 18px; + border-top: 1px solid var(--border-color, var(--border-strong)); +} +.eo-modal-footer .btn { flex: 1 1 0; } + +/* ----- Composable body primitives ----- */ +.eo-modal-section { display: flex; flex-direction: column; gap: 6px; } +.eo-modal-section-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(var(--text-rgb), 0.55); + font-weight: 600; +} +.eo-modal-section-text { font-size: 13px; color: rgba(var(--text-rgb), 0.80); line-height: 1.5; margin: 0; } + +.eo-modal-badge-row { display: flex; flex-wrap: wrap; gap: 6px; } +.eo-modal-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.10); + color: rgba(var(--text-rgb), 0.85); +} +.eo-modal-badge.success { background: rgba(var(--status-success-rgb),0.10); border-color: rgba(var(--status-success-rgb),0.30); color: var(--status-success); } +.eo-modal-badge.info { background: rgba(var(--accent-rgb),0.10); border-color: rgba(var(--accent-rgb),0.30); color: var(--accent); } +.eo-modal-badge.purple { background: rgba(var(--accent-rgb),0.10); border-color: rgba(var(--accent-rgb),0.30); color: #d8b4fe; } +.eo-modal-badge.warning { background: rgba(var(--status-warning-rgb),0.10); border-color: rgba(var(--status-warning-rgb),0.30); color: var(--status-warning); } +.eo-modal-badge.danger { background: rgba(var(--status-danger-rgb),0.10); border-color: rgba(var(--status-danger-rgb),0.30); color: var(--status-danger); } + +.eo-modal-url-list { display: flex; flex-direction: column; gap: 4px; } +.eo-modal-url-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; + text-decoration: none; + transition: background 0.12s, border-color 0.12s; + min-width: 0; +} +.eo-modal-url-row:hover { background: rgba(var(--accent-rgb), 0.08); border-color: rgba(var(--accent-rgb), 0.30); } +.eo-modal-url-label { font-size: 14px; font-weight: 500; color: var(--text-primary); flex-shrink: 0; } +.eo-modal-url-href { + font-size: 11px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + color: rgba(var(--text-rgb), 0.50); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; + text-align: right; +} +.eo-modal-url-row svg { color: rgba(var(--text-rgb), 0.50); flex-shrink: 0; } + +.eo-modal-cred { + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; + padding: 10px 12px; +} +.eo-modal-cred + .eo-modal-cred { margin-top: 6px; } +.eo-modal-cred-title { font-size: 12px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; } +.eo-modal-cred-row { display: flex; align-items: center; gap: 10px; padding: 4px 0; } +.eo-modal-cred-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(var(--text-rgb), 0.50); + width: 38px; + flex-shrink: 0; +} +.eo-modal-cred-value { + flex: 1; + min-width: 0; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 13px; + color: #e0f2fe; + padding: 4px 8px; + background: rgba(var(--bg-rgb), 0.25); + border-radius: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.eo-modal-cred-toggle { + font-size: 11px; + padding: 4px 10px; + background: transparent; + color: var(--accent); + border: 1px solid rgba(var(--accent-rgb), 0.40); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} +.eo-modal-cred-toggle:hover { background: rgba(var(--accent-rgb), 0.10); } + +.eo-modal-cred-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + color: rgba(var(--text-rgb), 0.50); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; + transition: color 0.12s, border-color 0.12s, background 0.12s; +} +.eo-modal-cred-copy:hover { + color: var(--accent); + border-color: rgba(var(--accent-rgb), 0.45); + background: rgba(var(--accent-rgb), 0.08); +} +.eo-modal-cred-copy.copied { + color: var(--status-success); + border-color: rgba(var(--status-success-rgb), 0.50); + background: rgba(var(--status-success-rgb), 0.10); +} + +.eo-modal-empty { text-align: center; color: rgba(var(--text-rgb), 0.55); font-size: 13px; padding: 12px 0; } + +/* Body content shouldn't push the modal wider than its container. */ +.eo-modal-body :where(input, textarea, select) { max-width: 100%; box-sizing: border-box; } +.eo-modal-body :where(p, h1, h2, h3, h4) { overflow-wrap: anywhere; } + +/* Keep close X reachable in the corner even when the header stacks. */ +.eo-modal-header { position: relative; } +.eo-modal-close { min-width: 44px; min-height: 44px; } + +/* Tablet + smaller phones */ +@media (max-width: 560px) { + .eo-modal { padding: 8px; align-items: flex-end; } + .eo-modal-content { max-height: 92vh; border-radius: 14px 14px 8px 8px; } + .eo-modal-header { padding: 14px 56px 14px 16px; } + .eo-modal-icon { width: 44px; height: 44px; } + .eo-modal-title { font-size: 18px; } + .eo-modal-close { position: absolute; top: 8px; right: 8px; } + .eo-modal-body { padding: 14px 16px; gap: 12px; } + .eo-modal-footer { padding: 12px 16px 16px; } + .eo-modal-url-row { flex-direction: column; align-items: flex-start; gap: 4px; } + .eo-modal-url-href { text-align: left; width: 100%; } + .eo-modal-cred-row { flex-wrap: wrap; } + .eo-modal-cred-value { width: 100%; } +} + +/* Very narrow phones — stack the footer buttons so labels don't squash. */ +@media (max-width: 380px) { + .eo-modal-footer { flex-direction: column-reverse; } + .eo-modal-footer .btn { width: 100%; } + .eo-modal-eyebrow { font-size: 10px; } + .eo-modal-title { font-size: 17px; } + .eo-modal-icon { width: 38px; height: 38px; } +} + +/* Uninstall-modal toggles — text in the middle, decorative coloured icon on the right. */ +.eo-toggle.eo-toggle-card.uninstall-extra { gap: 12px; align-items: center; } +.eo-toggle.eo-toggle-card.uninstall-extra .eo-toggle-text { flex: 1; min-width: 0; } +.eo-toggle.eo-toggle-card.uninstall-extra .uninstall-extra-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid transparent; + transition: filter 0.15s ease, transform 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} +.eo-toggle.eo-toggle-card.uninstall-extra .uninstall-extra-icon.image { background: rgba(var(--accent-rgb), 0.12); border-color: rgba(var(--accent-rgb), 0.28); color: var(--accent); } +.eo-toggle.eo-toggle-card.uninstall-extra .uninstall-extra-icon.tasks { background: rgba(var(--accent-rgb), 0.12); border-color: rgba(var(--accent-rgb), 0.30); color: var(--accent); } +.eo-toggle.eo-toggle-card.uninstall-extra:hover .uninstall-extra-icon { filter: brightness(1.2); transform: scale(1.04); } +.eo-toggle.eo-toggle-card.uninstall-extra input[type="checkbox"]:checked ~ .uninstall-extra-icon.image { background: rgba(var(--status-success-rgb), 0.18); border-color: rgba(var(--status-success-rgb), 0.45); color: var(--status-success); } +.eo-toggle.eo-toggle-card.uninstall-extra input[type="checkbox"]:checked ~ .uninstall-extra-icon.tasks { background: rgba(var(--status-success-rgb), 0.18); border-color: rgba(var(--status-success-rgb), 0.45); color: var(--status-success); } + +/* End icon in the eo-modal header — sits on the right side of the + header (before the close X). Use for tool emoji / accent badges + that don't belong in the title text. */ +.eo-modal-end-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 9px; + font-size: 20px; + line-height: 1; + background: rgba(var(--accent-rgb), 0.12); + border: 1px solid rgba(var(--accent-rgb), 0.28); + color: var(--accent); +} diff --git a/containers/libreportal/frontend/css/port-manager.css b/containers/libreportal/frontend/css/port-manager.css new file mode 100755 index 0000000..f9bf205 --- /dev/null +++ b/containers/libreportal/frontend/css/port-manager.css @@ -0,0 +1,590 @@ +/* Port Manager Component Styles */ + +/* Basic/Advanced toggle — when the .show-advanced class isn't on the + .port-manager root, fields tagged .port-field-advanced collapse out so + the basic view stays simple. */ +.port-manager:not(.show-advanced) .port-field-advanced { display: none; } + +.port-manager-header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.port-manager-advanced-toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 13px; + color: var(--text-secondary, #a0a0a0); + cursor: pointer; + user-select: none; +} + +.port-manager-advanced-toggle input { margin: 0; } + +/* Ensure help icons are always visible in port manager */ +.port-manager .help-icon { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 14px !important; + height: 14px !important; + background: var(--primary-color) !important; + color: white !important; + border-radius: 50% !important; + font-size: 9px !important; + font-weight: bold !important; + cursor: help !important; + position: relative !important; + opacity: 1 !important; + visibility: visible !important; +} + +/* Custom speech bubble tooltip for port manager */ +.port-manager .help-icon:hover::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--card-bg, #2a2a2a); + color: var(--text-primary, #fff); + padding: 8px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + text-transform: none; /* Ensure normal case */ + white-space: nowrap; + max-width: 200px; + white-space: normal; + z-index: 99999; + border: 2px solid var(--primary-color, var(--accent)); +} + +/* Speech bubble triangle pointing down */ +.port-manager .help-icon:hover::before { + content: ''; + position: absolute; + bottom: calc(100% + 1px); + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--card-bg, #2a2a2a); + z-index: 100000; +} + +/* Enhanced tooltips for main config help icons */ +.help-icon:hover::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--card-bg, #2a2a2a); + color: var(--text-primary, #fff); + padding: 8px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + text-transform: none; /* Ensure normal case */ + white-space: nowrap; + max-width: 200px; + white-space: normal; + z-index: 99999; + border: 2px solid var(--primary-color, var(--accent)); +} + +/* Speech bubble triangle for main config */ +.help-icon:hover::before { + content: ''; + position: absolute; + bottom: calc(100% + 1px); + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--card-bg, #2a2a2a); + z-index: 100000; +} + +/* Auto-match indicator styling */ +.auto-match-indicator { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 14px !important; + height: 14px !important; + background: var(--status-success) !important; + color: white !important; + border-radius: 50% !important; + font-size: 9px !important; + font-weight: bold !important; + cursor: help !important; + position: relative !important; + margin-left: 4px !important; + opacity: 0 !important; + visibility: hidden !important; + transition: all 0.2s ease !important; +} + +.auto-match-indicator[style*="display: inline-flex"] { + opacity: 1 !important; + visibility: visible !important; +} + +/* Auto-match indicator tooltip */ +.auto-match-indicator:hover::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--status-success); + color: var(--text-primary); + padding: 6px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + text-transform: none; + white-space: nowrap; + min-width: 160px; + text-align: center; + z-index: 99999; + border: 2px solid var(--accent); +} + +/* Auto-match indicator triangle */ +.auto-match-indicator:hover::before { + content: ''; + position: absolute; + bottom: calc(100% + 1px); + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--status-success); + z-index: 100000; +} + +/* Ensure form fields allow tooltips to overflow */ +.form-field { + overflow: visible !important; +} + +.panel-fields { + overflow: visible !important; +} + +/* Hide form field labels for port manager containers */ +.form-field[id^="PORT_"] .form-label { + display: none; +} + +.form-field[id^="PORT_"] .form-help { + display: none; +} + +/* Hide the entire port field containers except the first one */ +.form-field[id^="PORT_"]:not(:first-of-type) { + display: none !important; +} + +/* Override panel-fields layout for port managers specifically */ +div.panel-fields > div.form-field[id^="PORT_"] { + grid-column: 1 / -1 !important; + width: 100% !important; +} + +/* Target the new full-width class */ +.panel-fields .form-field:has(.port-manager-full-width) { + grid-column: 1 / -1 !important; + width: 100% !important; +} + +/* Alternative targeting */ +.panel-fields .form-field[id^="PORT_"] { + grid-column: 1 / -1 !important; /* Make form fields with PORT_ IDs span full width */ +} + +.panel-fields .port-manager-container { + width: 100%; /* Ensure full width */ +} + +.port-manager { + margin: 0px 0; +} + +.port-manager-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 2px solid var(--primary-color, var(--accent)); +} + +.port-manager-header h4 { + margin: 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.add-port-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 14px; + background: var(--primary-color, var(--accent)); + color: var(--text-primary); + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.add-port-btn:hover { + background: var(--primary-color-hover, var(--accent)); + transform: translateY(-1px); +} + +.add-icon { + font-size: 16px; + font-weight: bold; +} + +.port-manager-list { + display: flex; + flex-direction: column; + gap: 16px; + overflow: visible; /* Allow tooltips to escape */ +} + +/* Port card border colors based on access type */ +.port-card[data-access="public"] { + border-color: var(--status-success); /* Green for public */ + box-shadow: 0 0 0 1px var(--status-success); +} + +.port-card[data-access="private"] { + border-color: var(--status-warning); /* Orange for private */ + box-shadow: 0 0 0 1px var(--status-warning); +} + +.port-card[data-access="disabled"] { + border-color: var(--status-danger); /* Red for disabled */ + box-shadow: 0 0 0 1px var(--status-danger); +} + +/* Hover effects maintain the access color theme */ +.port-card[data-access="public"]:hover { + border-color: var(--accent); +} + +.port-card[data-access="private"]:hover { + border-color: var(--status-warning); +} + +.port-card[data-access="disabled"]:hover { + border-color: var(--status-danger); +} + +/* Default fallback */ +.port-card { + background: var(--card-bg, #2a2a2a); + border: 1px solid var(--border-color, #444); + border-radius: 8px; + overflow: visible; /* Changed from hidden to allow tooltips to escape */ + transition: all 0.2s ease; + margin-bottom: 16px; +} + +.port-card:hover { + border-color: var(--primary-color, var(--accent)); +} + +.port-card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: rgba(var(--text-rgb), 0.05); + border-bottom: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 8px 8px 0 0; +} + +.port-card-title { + font-weight: 600; + color: var(--text-secondary, #ccc); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.remove-port-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--status-danger); + color: var(--text-primary); + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + font-weight: bold; +} + +.remove-port-btn:hover { + background: var(--status-danger-hover); + transform: scale(1.1); +} + +.remove-icon { + line-height: 1; +} + +.port-card-body { + padding: 16px; + overflow: visible; /* Allow tooltips to escape */ +} + +.port-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.port-row:last-child { + margin-bottom: 0; +} + +.port-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.port-field label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #ccc); + text-transform: uppercase; + letter-spacing: 0.5px; + display: flex; + align-items: center; + gap: 4px; +} + +.port-field input, +.port-field select { + padding: 8px 12px; + background: var(--input-bg, #3a3a3a); + border: 1px solid var(--border-color, #555); + border-radius: 4px; + color: var(--text-primary, #fff); + font-size: 14px; + transition: all 0.2s ease; +} + +.port-field input:focus, +.port-field select:focus { + outline: none; + border-color: var(--primary-color, var(--accent)); + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.2); +} + +.port-field input[type="checkbox"] { + width: auto; + margin: 0; + transform: scale(1.2); +} + +.port-manager-hidden { + display: none; +} + +/* Modal Styles */ +.port-manager-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(var(--bg-rgb), 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.port-manager-modal { + background: var(--card-bg, #2a2a2a); + border: 1px solid var(--border-color, #444); + border-radius: 8px; + max-width: 400px; + width: 90%; + max-height: 90vh; + overflow: auto; +} + +.port-manager-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color, #444); +} + +.port-manager-modal-header h3 { + margin: 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.port-manager-modal-close { + background: none; + border: none; + color: var(--text-secondary, #ccc); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s ease; +} + +.port-manager-modal-close:hover { + background: var(--hover-bg, #444); + color: var(--text-primary, #fff); +} + +.port-manager-modal-body { + padding: 20px; +} + +.port-manager-modal-body p { + margin: 0; + color: var(--text-primary, #fff); + line-height: 1.5; +} + +.port-manager-modal-footer { + display: flex; + gap: 12px; + justify-content: flex-end; + padding: 16px 20px; + border-top: 1px solid var(--border-color, #444); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .port-manager-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .port-row { + grid-template-columns: 1fr; + } + + .port-manager-modal { + width: 95%; + margin: 20px; + } + + .port-manager-modal-footer { + flex-direction: column; + } + + .port-manager-modal-footer button { + width: 100%; + } +} + +/* Button Styles (reuse existing) */ +.btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.btn-primary { + background: var(--primary-color, var(--accent)); + color: var(--text-primary); +} + +.btn-primary:hover { + background: var(--primary-color-hover, var(--accent)); + transform: translateY(-1px); +} + +.btn-secondary { + background: var(--secondary-color, var(--text-muted)); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--secondary-color-hover, var(--text-muted)); +} + +.btn-danger { + background: var(--status-danger); + color: var(--text-primary); +} + +.btn-danger:hover { + background: var(--status-danger-hover); +} + +.btn-sm { + padding: 4px 8px; + font-size: 12px; +} + +.btn-xs { + padding: 2px 6px; + font-size: 11px; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .port-manager-modal-overlay { + background: rgba(var(--bg-rgb), 0.8); + } +} + +/* Manual dark mode class support */ +body.dark .port-manager-modal-overlay, +[data-theme="dark-blue"], +[data-theme="nebula"] .port-manager-modal-overlay, +.dark .port-manager-modal-overlay { + background: rgba(var(--bg-rgb), 0.8); +} diff --git a/containers/libreportal/frontend/css/routing.css b/containers/libreportal/frontend/css/routing.css new file mode 100644 index 0000000..67637ca --- /dev/null +++ b/containers/libreportal/frontend/css/routing.css @@ -0,0 +1,134 @@ +/* Traefik Routing panel — surfaced only on the Traefik app's detail page. */ + +.routing-list { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1rem 1.25rem 2rem; +} + +.routing-title-block h3 { margin: 0 0 0.25rem; } +.routing-title-block p { + margin: 0; + color: var(--text-secondary, #a0a0a0); + font-size: 13px; +} +.routing-title-block code { + background: rgba(255, 255, 255, 0.06); + padding: 1px 5px; + border-radius: 3px; + font-size: 12px; +} + +.routing-section { border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); padding-top: 0.75rem; } +.routing-section-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; } +.routing-section-head h4 { margin: 0; font-size: 14px; } +.routing-count { + display: inline-flex; align-items: center; justify-content: center; + min-width: 1.5em; padding: 0 0.45em; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary, #ccc); + font-size: 11px; font-weight: 600; + margin-left: 0.4rem; +} +.routing-section-hint { color: var(--text-secondary, #a0a0a0); font-size: 12px; } + +.routing-show-advanced { + display: inline-flex; align-items: center; gap: 0.4rem; + font-size: 12px; color: var(--text-secondary, #a0a0a0); + cursor: pointer; user-select: none; +} +.routing-show-advanced input { margin: 0; } + +.routing-table { display: flex; flex-direction: column; gap: 0.5rem; } +.routing-advanced-table { display: none; } +.routing-advanced-table.routing-advanced-open { display: flex; } + +.routing-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + background: var(--surface-color, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.06)); + border-radius: 6px; +} +.routing-icon { width: 28px; height: 28px; object-fit: contain; flex-shrink: 0; } +.routing-meta { flex: 1; min-width: 0; } +.routing-title { + display: flex; align-items: center; gap: 0.5rem; + font-size: 13px; color: var(--text-primary, #fff); + flex-wrap: wrap; +} +.routing-app { font-weight: 600; } +.routing-port-name { color: var(--text-secondary, #ccc); } +.routing-port-num { color: var(--text-secondary, #a0a0a0); font-family: monospace; font-size: 12px; } +.routing-url { + font-family: monospace; font-size: 11px; + color: var(--text-secondary, #888); + margin-top: 2px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.routing-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.routing-badge-webui { background: rgba(108, 99, 255, 0.18); color: #b5b0ff; } +.routing-badge-public { background: rgba(255, 159, 64, 0.18); color: #ffce8a; } + +.routing-toggle { + position: relative; + display: inline-block; + width: 38px; + height: 22px; + cursor: pointer; + flex-shrink: 0; +} +.routing-toggle input { opacity: 0; width: 0; height: 0; } +.routing-toggle-track { + position: absolute; inset: 0; + background: rgba(255, 255, 255, 0.12); + border-radius: 999px; + transition: background 120ms ease; +} +.routing-toggle-track::before { + content: ''; + position: absolute; left: 3px; top: 3px; + width: 16px; height: 16px; + background: #fff; + border-radius: 50%; + transition: transform 120ms ease; +} +.routing-toggle input:checked + .routing-toggle-track { + background: var(--accent-color, #6c63ff); +} +.routing-toggle input:checked + .routing-toggle-track::before { + transform: translateX(16px); +} + +.routing-apply-bar { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--surface-color, rgba(20, 20, 28, 0.95)); + border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); + border-radius: 6px; + margin-top: 0.5rem; +} +.routing-apply-hint { color: var(--text-secondary, #a0a0a0); font-size: 13px; } + +.routing-empty { + text-align: center; color: var(--text-secondary, #888); + padding: 1rem; font-size: 13px; font-style: italic; +} diff --git a/containers/libreportal/frontend/css/service-buttons.css b/containers/libreportal/frontend/css/service-buttons.css new file mode 100644 index 0000000..4fab42c --- /dev/null +++ b/containers/libreportal/frontend/css/service-buttons.css @@ -0,0 +1,198 @@ + + +/* Service URL trigger buttons and popovers (visible on app cards). Extracted from style.css. */ + +.service-buttons-container { + display: flex; + flex-direction: row; + gap: 8px; + flex-wrap: wrap; + margin-top: 16px; +} + +.service-button { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: rgba(var(--accent-rgb), 0.10); + border: 1px solid rgba(var(--accent-rgb), 0.30); + border-radius: 6px; + color: var(--text-primary); + text-decoration: none; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease; + cursor: pointer; +} + +.service-button:hover { + background: rgba(var(--accent-rgb), 0.20); + border-color: rgba(var(--accent-rgb), 0.55); + transform: translateY(-1px); +} + +.service-button .service-icon { + font-size: 18px; + flex-shrink: 0; +} + +.service-button .service-text { + flex: 1; + font-size: 14px; + font-weight: 500; +} + +.service-button .service-external-icon { + font-size: 14px; + opacity: 0.6; + transition: opacity 0.2s ease; +} + +.service-button:hover .service-external-icon { + opacity: 1; +} + +.service-buttons-container .no-buttons { + color: rgba(var(--text-rgb), 0.5); + font-size: 14px; + text-align: center; + padding: 20px; +} + +/* Service Trigger on App Cards */ +.service-trigger { + position: relative; + flex-shrink: 0; + order: -1; /* left of manage button */ +} + +.service-trigger-icon { + width: 100%; + height: 100%; + min-width: 38px; + background: linear-gradient(135deg, var(--status-success), #1e7e34); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 1px solid rgba(var(--status-success-rgb), 0.5); + transition: all 0.2s ease; + color: #ffffff; + padding: 0 10px; + gap: 5px; + font-size: 12px; + font-weight: 600; +} + +.service-trigger:hover .service-trigger-icon, +.service-trigger.open .service-trigger-icon { + background: linear-gradient(135deg, var(--status-success-hover), #155724); + border-color: rgba(var(--status-success-rgb), 0.8); +} + +.service-trigger-popup { + display: none; + position: absolute; + bottom: calc(100% + 10px); + left: 0; + background: rgba(12, 12, 18, 0.97); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 10px; + padding: 8px; + min-width: 210px; + z-index: 1000; + backdrop-filter: blur(12px); +} + +.service-trigger.open .service-trigger-popup { + display: block; +} + +.service-trigger-popup::after { + content: ''; + position: absolute; + bottom: -6px; + left: 14px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid rgba(12, 12, 18, 0.97); +} + +.service-trigger-popup .service-button { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 11px; + border-radius: 7px; + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.07); + color: var(--text-primary); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: all 0.15s ease; + margin-bottom: 4px; + white-space: nowrap; +} + +.service-trigger-popup .service-button:last-child { + margin-bottom: 0; +} + +.service-trigger-popup .service-button:hover { + background: rgba(var(--status-success-rgb), 0.18); + border-color: rgba(var(--status-success-rgb), 0.35); +} + +.service-trigger-popup .service-button svg { + flex-shrink: 0; + opacity: 0.65; +} + +.service-trigger-popup .service-button:hover { + background: rgba(var(--accent-rgb), 0.18); + border-color: rgba(var(--accent-rgb), 0.40); +} + +.service-trigger-popup .service-button svg { + flex-shrink: 0; + opacity: 0.65; +} + +/* Per-port lock badge on a service URL button — surfaces only on the + specific URLs whose port has login_required=true. */ +.service-button.protected { + border-color: rgba(245, 158, 11, 0.35); +} + +.service-lock-icon { + display: inline-flex; + align-items: center; + color: #fbbf24; + margin-left: 4px; + cursor: help; +} + +.service-lock-icon svg { + stroke: currentColor; +} + cyan + gradient accent so it reads as the "info / status" entry-point + distinct from the URL-open buttons that follow. */ +.service-button.service-button-welcome { + font: inherit; + cursor: pointer; + background: rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.40); + color: var(--text-primary); +} + +.service-button.service-button-welcome:hover { + background: rgba(var(--accent-rgb), 0.25); + border-color: rgba(var(--accent-rgb), 0.65); + transform: translateY(-1px); +} + +.service-button.service-button-welcome .service-icon { font-size: 14px; line-height: 1; } diff --git a/containers/libreportal/frontend/css/services.css b/containers/libreportal/frontend/css/services.css new file mode 100644 index 0000000..c1e6fab --- /dev/null +++ b/containers/libreportal/frontend/css/services.css @@ -0,0 +1,199 @@ +/* + Services tab — rows reuse the .task-item / .task-header / .task-info / + .task-actions / .task-details / .log-container pattern from the + task list so the two surfaces look identical. The only service-only + bits are the status dot inside the status pill, the port chips, and + a streaming-state hint on the log container. +*/ + +.services-section { + padding: 0; +} + +/* Mirrors .config-title for visual parity across tabs. */ +.services-title { + padding: 20px; + background: transparent; + border-bottom: 1px solid var(--border-color); + margin-bottom: 0; +} + +.services-title h3 { + margin: 0 0 8px 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.services-title p { + margin: 0; + color: var(--text-secondary, #ccc); + font-size: 13px; +} + +.services-list { + display: flex; + flex-direction: column; +} + +/* Recessed dark panel wrapping the service rows — mirrors the + .tasks-container the Tasks tab uses on the app detail page so the + two tabs share one visual idiom. rgba(bg, 0.2) reads as a sunken + pocket inside the tab-pane's glass surface. */ +.services-rows { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 16px; + margin: 16px; + background: rgba(var(--bg-rgb), 0.2); + border-radius: 8px; +} + +/* ------------------------------------------------------------------ */ +/* Loading + empty + error states */ +/* ------------------------------------------------------------------ */ +.services-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 2rem; + color: var(--text-secondary, var(--text-muted)); +} + +.services-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(var(--text-rgb), 0.15); + border-top-color: var(--accent-color, var(--accent)); + border-radius: 50%; + animation: services-spin 0.7s linear infinite; +} + +@keyframes services-spin { to { transform: rotate(360deg); } } + +.services-empty { + text-align: center; + padding: 2.5rem 1rem; + color: var(--text-secondary, var(--text-muted)); +} + +.services-empty-icon { + font-size: 2rem; + display: block; + margin-bottom: 0.5rem; +} + +.services-empty p { + margin: 0.25rem 0; +} + +.services-empty-hint { + font-size: 0.85rem; + opacity: 0.75; +} + +/* ------------------------------------------------------------------ */ +/* Status dot inside the .task-status pill */ +/* ------------------------------------------------------------------ */ +.service-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; + background: var(--text-muted); +} + +@keyframes service-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +/* ------------------------------------------------------------------ */ +/* Port + IP chips inside .task-info (alongside status / time) */ +/* ------------------------------------------------------------------ */ +.service-port { + display: inline-flex; + align-items: center; + gap: 2px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + background: rgba(var(--accent-rgb), 0.08); + border: 1px solid rgba(var(--accent-rgb), 0.25); + color: var(--text-secondary); + padding: 2px 7px; + border-radius: 4px; + white-space: nowrap; +} + +.service-port-arrow { + opacity: 0.5; + margin: 0 1px; +} + +.service-port-proto { + margin-left: 4px; + font-size: 0.65rem; + opacity: 0.6; + text-transform: uppercase; +} + +.service-ip { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 4px; + padding: 1px 6px; + font-size: 0.72rem; + color: var(--text-secondary, var(--text-muted)); +} + +/* The Open button — flagged with .open on top of .task-btn so the + shared task-row hover styles still apply but a slightly different + accent makes it distinguishable from Restart. */ +.task-btn.open { + color: var(--text-secondary); +} +.task-btn.open:hover { + background: rgba(var(--accent-rgb), 0.15); +} + +.service-app-icon { + width: 30px; + height: 30px; + flex-shrink: 0; +} + +/* ------------------------------------------------------------------ */ +/* Streaming state hint on the log container */ +/* ------------------------------------------------------------------ */ +.service-log-output[data-stream="connecting"]::before { + content: 'Connecting…'; + color: var(--status-warning); + display: block; + margin-bottom: 0.25rem; +} + +.service-log-output[data-stream="disconnected"]::before { + content: '⚠ disconnected — retrying…'; + color: var(--status-warning); + display: block; + margin-bottom: 0.25rem; +} + +.service-log-output[data-stream="closed"]::after { + content: '— stream closed —'; + color: var(--text-muted); + display: block; + margin-top: 0.25rem; +} + +/* Spinner-on-restart while the request is in flight, mirroring the + subtle “task is doing something” visual cue used by the task list. */ +.task-btn.is-running { + opacity: 0.6; + cursor: wait; +} diff --git a/containers/libreportal/frontend/css/setup-wizard.css b/containers/libreportal/frontend/css/setup-wizard.css new file mode 100755 index 0000000..7bb9770 --- /dev/null +++ b/containers/libreportal/frontend/css/setup-wizard.css @@ -0,0 +1,1054 @@ +/* ────────────────────────────────────────────────────────────────────── + Setup Wizard — multi-step slide-right + Reuses the shared .aurora-bg + .aurora-stars from aurora-background.css + so it shares the loading screen's visual identity. The wizard itself is + a translucent shell over that background, with a horizontal track of + step panels that slides as the user advances. + ────────────────────────────────────────────────────────────────────── */ + +body.setup-wizard-open { + overflow: hidden; +} + +.setup-wizard { + position: fixed; + inset: 0; + z-index: 9999; + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + display: flex; + align-items: center; + justify-content: center; + overflow-y: auto; + padding: 2rem 1.5rem; + opacity: 0; + animation: setupFadeIn 0.6s ease forwards; + box-sizing: border-box; +} + +.setup-wizard.hiding { + animation: setupFadeOut 0.5s ease forwards; +} + +.setup-wizard.setup-launched .setup-card { + transform: scale(0.96); + opacity: 0.5; + filter: blur(2px); + transition: all 0.5s ease; +} + +@keyframes setupFadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes setupFadeOut { from { opacity: 1; } to { opacity: 0; } } + +/* Vertical stack: logo header on top, card below — same shape as + `.login-content` in the login overlay so the two surfaces share a + visual identity. The aurora-header / aurora-logo / aurora-subtitle + classes themselves are inherited from aurora-background.css so the + logo treatment is byte-identical to login + loading. */ +.setup-content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 620px; + margin: 1rem; +} + +/* Header inherits .aurora-header / .aurora-logo sizing from + aurora-background.css so it matches the loading screen byte-for-byte — + single source of truth. The only wizard-specific tweak is a slightly + tighter bottom margin since a card sits below it. */ +.setup-content .aurora-header { + margin-bottom: 1.5rem; +} + +/* Card — translucent panel matching .login-card / loading screen style */ +.setup-card { + width: 100%; + background: rgba(var(--text-rgb), 0.06); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 14px; + padding: 24px 28px 20px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + animation: setupCardRise 0.4s cubic-bezier(0.16, 1, 0.3, 1) 0.05s both; + display: flex; + flex-direction: column; + gap: 20px; +} + +@keyframes setupCardRise { + from { transform: translateY(16px) scale(0.97); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +/* Progress bar — same look as loading screen's progress section */ +.setup-progress { + display: flex; + flex-direction: column; + gap: 8px; +} + +.setup-progress-bar { + background: rgba(var(--text-rgb), 0.10); + border-radius: 8px; + padding: 2px; + border: 1px solid rgba(var(--text-rgb), 0.08); + height: 12px; + box-sizing: border-box; + overflow: hidden; +} + +.setup-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: 6px; + width: 0%; + transition: width 0.45s cubic-bezier(0.16, 1, 0.3, 1); + position: relative; + overflow: hidden; +} + +.setup-progress-fill::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(var(--text-rgb),0.3), transparent); + animation: setupShimmer 2s infinite; +} + +@keyframes setupShimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.setup-progress-text { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: rgba(var(--text-rgb), 0.78); + font-family: 'SF Mono', Menlo, monospace; + letter-spacing: 0.5px; +} + +.setup-progress-text > #sw-progress-step { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.setup-progress-sep { + color: rgba(var(--text-rgb), 0.20); + margin: 0 2px; +} + +.setup-progress-icon { + display: inline-flex; + align-items: center; + color: var(--accent); +} + +.setup-progress-name { + color: var(--text-primary); + font-weight: 600; + letter-spacing: 0.3px; +} + +/* Step transitions — fade in / fade out. The previous slide-track approach + fought browser flex-basis math whenever step content height varied + (e.g. step 2 reveals the domain field when Public is toggled). Fade is + simpler: only the active step is in the layout, the card naturally + heights to its content, and the swap feels like the wizard "settles + into the next thought" rather than swinging horizontally. */ +.setup-track-wrap { + position: relative; + width: 100%; +} + +.setup-track { + display: block; + width: 100%; +} + +.setup-step { + display: none; + flex-direction: column; + gap: 16px; +} + +.setup-step.active { + display: flex; + animation: stepFadeIn 0.32s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes stepFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.setup-form { + display: flex; + flex-direction: column; + gap: 18px; +} + +/* Multi-domain editor — list of removable rows + an "Add domain" button. */ +.setup-domain-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.setup-domain-row { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Empty status pill collapses so blank-domain rows don't claim vertical space */ +.setup-domain-row .setup-dns-status:empty { + display: none; +} + +.setup-domain-row .setup-input-row { + align-items: stretch; +} + +.setup-domain-remove { + flex: 0 0 auto; + width: 36px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 8px; + color: rgba(var(--text-rgb), 0.65); + font-size: 18px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.setup-domain-remove:hover { + background: rgba(var(--status-danger-rgb), 0.18); + border-color: rgba(var(--status-danger-rgb), 0.45); + color: var(--status-danger); +} + +.setup-domain-add { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(var(--accent-rgb), 0.10); + border: 1px dashed rgba(var(--accent-rgb), 0.40); + border-radius: 10px; + padding: 8px 14px; + color: var(--accent); + font-size: 13px; + font-weight: 600; + cursor: pointer; + margin-top: 8px; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} + +.setup-domain-add:hover { + background: rgba(var(--accent-rgb), 0.20); + border-color: rgba(var(--accent-rgb), 0.65); + border-style: solid; + transform: translateY(-1px); +} + +.setup-domain-add span { + font-size: 16px; + line-height: 1; +} + +.setup-step-note { + font-size: 12px; + color: rgba(var(--text-rgb), 0.65); + margin: 12px 0 0; + font-style: italic; +} + +.setup-section-hint { + font-size: 12px; + color: rgba(var(--text-rgb), 0.62); + margin: 0 0 10px; +} + +/* Field styling — matches the login form's compact subtle look: + small lowercase labels, translucent inputs with cyan focus glow. */ +.setup-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.setup-field label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); + letter-spacing: 0.02em; + text-transform: none; + margin: 0; +} + +.setup-field input[type=text], +.setup-field input[type=email], +.setup-field select { + width: 100%; + background: rgba(var(--text-rgb), 0.06); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 8px; + padding: 0.6rem 0.875rem; + color: var(--text-primary); + font-size: 0.95rem; + font-family: inherit; + box-sizing: border-box; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.setup-field input[type=text]:focus, +.setup-field input[type=email]:focus, +.setup-field select:focus { + outline: none; + background: rgba(var(--text-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.18); +} + +.setup-field input::placeholder { + color: var(--text-secondary); +} + +/* Live validation — green border + glow when valid, red when invalid. + The error message is held on data-error and surfaced via a tooltip + that floats ABOVE the input on focus/hover (like the ? badge does). + Uses a bright mint #86efac (134,239,172) so the border reads against + the dark wizard backdrop — the theme's #28a745 is too muddy here. */ +.setup-field input.is-valid, +.setup-field select.is-valid { + border-color: #86efac; + box-shadow: 0 0 0 3px rgba(134, 239, 172, 0.22); +} + +.setup-field input.is-invalid, +.setup-field select.is-invalid { + border-color: rgba(var(--status-danger-rgb), 0.85); + box-shadow: 0 0 0 3px rgba(var(--status-danger-rgb), 0.22); +} + +/* Mirror the valid/invalid state onto the custom-select button when the + native — the browser's + native chevron occupies the right edge, so right:14px collides with it. */ +.setup-input-row:has(select.is-valid)::after, +.setup-input-row:has(select.is-invalid)::after { + right: 32px; +} + +.setup-input-row:has(select.is-valid) .setup-input-with-icon, +.setup-input-row:has(select.is-invalid) .setup-input-with-icon { + padding-right: 3.5rem !important; +} + +/* Error message floating above the input — same visual language as the + ? tooltip but anchored to the input row. We read the message from + data-error which the JS mirrors from the input onto the row (pseudo + elements can only read attrs from their own host element). */ +.setup-input-row[data-error]::before { + content: attr(data-error); + position: absolute; + bottom: calc(100% + 8px); + left: 14px; + background: rgba(var(--bg-rgb), 0.45); + color: var(--status-danger); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0; + padding: 7px 10px; + border-radius: 8px; + border: 1px solid rgba(var(--status-danger-rgb), 0.45); + white-space: nowrap; + pointer-events: none; + opacity: 0; + z-index: 11; + transition: opacity 0.18s ease, transform 0.18s ease; + transform: translateY(4px); +} + +.setup-input-row[data-error]:hover::before, +.setup-input-row[data-error]:focus-within::before { + opacity: 1; + transform: translateY(0); +} + +/* Input with leading icon — icon sits absolutely positioned over the + left padding zone of the input. Reroll button (when present) sits to + the right of the input via the row's flex layout. */ +.setup-input-row { + position: relative; + display: flex; + gap: 8px; + align-items: stretch; +} + +.setup-field-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); + pointer-events: none; + z-index: 1; + transition: color 0.2s ease, filter 0.2s ease; +} + +.setup-input-row .setup-input-with-icon:focus ~ .setup-field-icon, +.setup-input-row:focus-within .setup-field-icon { + color: var(--accent); +} + +.setup-input-with-icon { + flex: 1; + padding-left: 2.25rem !important; +} + +.setup-field-icon svg { display: block; } + +.setup-field-icon-emoji { + font-size: 16px; + line-height: 1; +} + +.setup-input-row:focus-within .setup-field-icon-emoji { +} + +#sw-name.setup-input-with-icon { + font-family: 'SF Mono', Menlo, monospace; + letter-spacing: 0.3px; + color: var(--accent); + padding-right: 7.75rem !important; +} + +.setup-input-row:has(#sw-name.is-valid) #sw-name, +.setup-input-row:has(#sw-name.is-invalid) #sw-name { + padding-right: 7.75rem !important; +} + +.setup-input-row:has(#sw-name)::after { + display: none; +} + +/* Tooltip "?" badge after the label. Hover or keyboard-focus reveals + a small floating tip with the description text. */ +.setup-tooltip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + background: rgba(var(--accent-rgb), 0.15); + color: var(--accent); + font-size: 0.65rem; + font-weight: 700; + margin-left: 6px; + cursor: help; + position: relative; + user-select: none; + vertical-align: middle; + border: 1px solid rgba(var(--accent-rgb), 0.35); + transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} + +.setup-tooltip:hover, +.setup-tooltip:focus { + outline: none; + background: rgba(var(--accent-rgb), 0.30); + color: var(--text-primary); +} + +.setup-tooltip::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + background: rgba(var(--bg-rgb), 0.45); + color: var(--text-primary); + font-size: 0.72rem; + font-weight: 400; + letter-spacing: 0; + text-transform: none; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(var(--accent-rgb), 0.40); + width: max-content; + max-width: 240px; + white-space: normal; + text-align: left; + line-height: 1.35; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease, transform 0.15s ease; + z-index: 10; +} + +.setup-tooltip::before { + content: ''; + position: absolute; + bottom: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: rgba(var(--accent-rgb), 0.55); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.setup-tooltip:hover::after, +.setup-tooltip:focus::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.setup-tooltip:hover::before, +.setup-tooltip:focus::before { + opacity: 1; +} + +.setup-name-pulse { + animation: setupNamePulse 0.6s ease; +} + +@keyframes setupNamePulse { + 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55); } + 60% { box-shadow: 0 0 0 12px rgba(var(--accent-rgb), 0); } + 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); } +} + +.setup-manifest { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + height: calc(100% - 12px); + z-index: 2; + background: rgba(var(--accent-rgb), 0.12); + color: var(--accent); + border: 1px solid rgba(var(--accent-rgb), 0.32); + border-radius: 8px; + padding: 0 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease, box-shadow 0.18s ease, color 0.18s ease; + white-space: nowrap; +} + +.setup-manifest .setup-manifest-icon { + color: var(--accent); + transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1), color 0.2s ease, filter 0.2s ease; +} + +.setup-manifest:hover { + background: rgba(var(--accent-rgb), 0.22); + border-color: rgba(var(--accent-rgb), 0.55); + color: var(--text-primary); + transform: translateY(calc(-50% - 1px)); +} + +.setup-manifest:hover .setup-manifest-icon { + color: var(--accent); +} + +/* Click animation: full-spin icon + cosmic burst halo around the button */ +.setup-manifest.manifesting { + animation: manifestBurst 0.7s ease; +} + +.setup-manifest.manifesting .setup-manifest-icon { + transform: rotate(360deg); +} + +@keyframes manifestBurst { + 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.75), 0 0 0 0 rgba(var(--accent-rgb), 0.55); } + 60% { box-shadow: 0 0 0 14px rgba(var(--accent-rgb), 0), 0 0 0 28px rgba(var(--accent-rgb), 0); } + 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0), 0 0 0 0 rgba(var(--accent-rgb), 0); } +} + +/* DNS check status */ +.setup-dns-status { + font-size: 12px; + margin-top: 8px; + padding: 6px 10px; + border-radius: 6px; + font-family: 'SF Mono', Menlo, monospace; + min-height: 14px; +} + +.setup-dns-status.checking { + background: rgba(var(--text-rgb), 0.05); + color: rgba(var(--text-rgb), 0.6); +} + +.setup-dns-status.ok { + background: rgba(var(--status-success-rgb), 0.12); + color: var(--status-success); + border: 1px solid rgba(var(--status-success-rgb), 0.3); +} + +.setup-dns-status.warn { + background: rgba(var(--status-warning-rgb), 0.10); + color: var(--status-warning); + border: 1px solid rgba(var(--status-warning-rgb), 0.3); +} + +/* App selection sections */ +.setup-section { + border-top: 1px solid rgba(var(--text-rgb), 0.06); + padding-top: 14px; +} +.setup-section:first-child { border-top: none; padding-top: 0; } + +.setup-section-title { + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(var(--accent-rgb), 1); + margin-bottom: 10px; +} + +.setup-app { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 14px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 12px; + cursor: pointer; + margin-bottom: 8px; + transition: all 0.15s ease; +} + +.setup-app:hover { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--accent-rgb), 0.25); +} + +.setup-app input[type=checkbox] { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + flex-shrink: 0; + cursor: pointer; + border-radius: 6px; + background: rgba(var(--text-rgb), 0.04); + border: 1.5px solid rgba(var(--text-rgb), 0.18); + box-shadow: inset 0 0 0 1px rgba(var(--text-rgb), 0.02); + position: relative; + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.12s ease; +} + +.setup-app:hover input[type=checkbox] { + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.08); +} + +.setup-app input[type=checkbox]:focus-visible { + outline: none; + border-color: rgba(var(--accent-rgb), 0.85); + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.20); +} + +.setup-app input[type=checkbox]:checked { + background: linear-gradient(135deg, var(--accent), var(--accent)); + border-color: rgba(var(--accent-rgb), 0.9); + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.35); +} + +.setup-app input[type=checkbox]:checked::after { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: center; + background-size: 14px 14px; + animation: setupCheckPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes setupCheckPop { + 0% { transform: scale(0.4); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +.setup-app:has(input:checked) { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.45); +} + +.setup-app-icon-wrap { + width: 36px; + height: 36px; + border-radius: 9px; + background: rgba(var(--text-rgb), 0.06); + border: 1px solid rgba(var(--text-rgb), 0.08); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; +} + +.setup-app-icon-wrap .setup-app-icon { + width: 24px; + height: 24px; + object-fit: contain; +} + +.setup-app:has(input:checked) .setup-app-icon-wrap { + background: rgba(var(--accent-rgb), 0.20); + border-color: rgba(var(--accent-rgb), 0.45); +} + +.setup-app-info { flex: 1; min-width: 0; } +.setup-app-name { font-size: 14px; font-weight: 600; color: var(--text-primary); } +.setup-app-desc { font-size: 12px; color: rgba(var(--text-rgb), 0.82); margin-top: 2px; } + +/* Parent tile + sub-option as one merged card. Parent loses its bottom + radius; sub-option is a flush drawer below with only the bottom corners + rounded. Shared horizontal bounds so the two pieces read as one. */ +.setup-app-group { margin-bottom: 8px; } + +.setup-app-group .setup-app { + margin-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom-color: rgba(var(--text-rgb), 0.04); +} + +.setup-app-suboption { + display: flex; + align-items: center; + gap: 10px; + /* Left padding lines the sub-checkbox up under the parent's checkbox + (parent: 14px padding + ~3px to centre the smaller 14px box). */ + padding: 7px 14px 8px 17px; + margin: 0; + background: rgba(var(--text-rgb), 0.035); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-top: none; + border-radius: 0 0 12px 12px; + font-size: 12px; + color: rgba(var(--text-rgb), 0.82); + cursor: pointer; + transition: all 0.15s ease; +} + +/* When the parent is selected, the drawer picks up the same blue tint so + the merged card reads as one selected unit. */ +.setup-app-group:has(.setup-app input[type=checkbox]:checked) .setup-app { + border-bottom-color: rgba(var(--accent-rgb), 0.30); +} +.setup-app-group:has(.setup-app input[type=checkbox]:checked) .setup-app-suboption { + background: rgba(var(--accent-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.40); + border-top: none; +} + +.setup-app-suboption:hover { + background: rgba(var(--accent-rgb), 0.12); +} +.setup-app-suboption.disabled { + opacity: 0.35; + pointer-events: none; +} +.setup-app-suboption input[type=checkbox] { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + flex-shrink: 0; + cursor: pointer; + border-radius: 4px; + background: rgba(var(--text-rgb), 0.04); + border: 1.4px solid rgba(var(--text-rgb), 0.20); + position: relative; + transition: background 0.15s ease, border-color 0.15s ease; +} +.setup-app-suboption:hover input[type=checkbox] { + border-color: rgba(var(--accent-rgb), 0.55); +} +.setup-app-suboption input[type=checkbox]:checked { + background: linear-gradient(135deg, var(--accent), var(--accent)); + border-color: rgba(var(--accent-rgb), 0.9); +} +.setup-app-suboption input[type=checkbox]:checked::after { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: center; + background-size: 10px 10px; +} +.setup-app-suboption-label { font-weight: 500; } + +/* Navigation */ +.setup-nav { + display: flex; + gap: 10px; + margin-top: 6px; +} + +.setup-btn-back, +.setup-btn-next { + background: rgba(var(--text-rgb), 0.06); + color: var(--text-primary); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 12px; + padding: 14px 18px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.setup-btn-back { flex: 0 0 auto; } +.setup-btn-next { flex: 1; } + +.setup-btn-back:hover:not(:disabled), +.setup-btn-next:hover:not(:disabled) { + background: rgba(var(--accent-rgb), 0.14); + border-color: rgba(var(--accent-rgb), 0.40); + transform: translateY(-1px); +} + +.setup-btn-back:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.setup-launch { + flex: 1; + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + border: none; + border-radius: 12px; + padding: 14px 18px; + color: var(--text-primary); + font-size: 15px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + transition: all 0.2s ease; +} + +.setup-launch:hover:not(:disabled) { + transform: translateY(-2px); + filter: brightness(1.05); +} + +.setup-launch:active:not(:disabled) { transform: translateY(0); } +.setup-launch:disabled { opacity: 0.6; cursor: not-allowed; } + +.setup-launch-arrow { transition: transform 0.2s ease; } +.setup-launch:hover:not(:disabled) .setup-launch-arrow { transform: translateX(4px); } + +/* Error */ +.setup-error { + background: rgba(var(--status-danger-rgb), 0.10); + border: 1px solid rgba(var(--status-danger-rgb), 0.3); + color: var(--status-danger); + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; +} + +/* Top-nav disabled state — applied while setup isn't complete. */ +.topbar-nav.setup-needed .nav-item { + opacity: 0.35; + pointer-events: none; + filter: grayscale(60%); +} + +/* Setup-in-progress banner — pinned to top of viewport while the wizard's + tasks are still running (any page). Auto-removed when finalize completes. */ +.setup-progress-banner { + position: fixed; + top: 14px; + left: 50%; + transform: translateX(-50%); + z-index: 9000; + background: rgba(var(--bg-rgb), 0.45); + border: 1px solid rgba(var(--accent-rgb), 0.40); + border-radius: 12px; + padding: 10px 16px; + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + min-width: 320px; + max-width: min(520px, 92vw); + animation: setupBannerIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.setup-progress-banner.leaving { + animation: setupBannerOut 0.35s ease both; +} + +.setup-progress-banner.failed { + border-color: rgba(var(--status-danger-rgb), 0.5); +} + +.setup-progress-banner-inner { + display: grid; + grid-template-columns: 18px 1fr; + grid-template-rows: auto auto; + column-gap: 12px; + align-items: center; +} + +.setup-progress-banner-icon { + grid-row: 1 / span 2; + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; +} + +.setup-progress-banner.failed .setup-progress-banner-icon { + color: var(--status-danger); +} + +.setup-progress-banner-text { + grid-column: 2; + grid-row: 1; + letter-spacing: 0.2px; +} + +.setup-progress-banner-text strong { + font-weight: 600; + color: var(--text-primary); +} + +.setup-progress-banner-count { + color: rgba(var(--text-rgb), 0.78); + font-family: 'SF Mono', Menlo, monospace; + font-size: 12px; + margin-left: 4px; +} + +.setup-progress-banner-bar { + grid-column: 2; + grid-row: 2; + height: 4px; + background: rgba(var(--text-rgb), 0.08); + border-radius: 999px; + overflow: hidden; + margin-top: 6px; +} + +.setup-progress-banner-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: 999px; + transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} + +.setup-progress-banner.failed .setup-progress-banner-fill { + background: linear-gradient(90deg, var(--status-danger), var(--status-danger-hover)); +} + +@keyframes setupBannerIn { + from { opacity: 0; transform: translate(-50%, -16px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +@keyframes setupBannerOut { + from { opacity: 1; transform: translate(-50%, 0); } + to { opacity: 0; transform: translate(-50%, -16px); } +} + +@media (max-width: 600px) { + .setup-shell { padding: 22px 18px 18px; border-radius: 14px; } + .setup-logo h1 { font-size: 20px; } + .setup-input-row { flex-direction: column; } + .setup-input-row .setup-field-icon { top: 22px; transform: none; } + .setup-input-row .setup-input-with-icon { padding-left: 2.25rem !important; } + .setup-reroll { padding: 10px; } + .setup-nav { flex-direction: column; } + .setup-btn-back { order: 2; } + .setup-btn-next, .setup-launch { order: 1; } +} diff --git a/containers/libreportal/frontend/css/sidebar.css b/containers/libreportal/frontend/css/sidebar.css new file mode 100644 index 0000000..d511981 --- /dev/null +++ b/containers/libreportal/frontend/css/sidebar.css @@ -0,0 +1,155 @@ + + +/* Sidebar layout and category navigation items. Extracted from style.css. */ + +/* Sidebar — full column height so its glass background paints the + entire side of the viewport even when the items don't fill it. + Internal scroll keeps the list usable without the page scrolling. */ +.sidebar { + width: 220px; + height: 100%; + display: flex; + flex-direction: column; + background: var(--sidebar-bg); + color: var(--sidebar-text); + border-right: 1px solid var(--sidebar-border); + position: relative; + z-index: 100; + transition: transform 0.3s ease; + backdrop-filter: blur(12px) saturate(140%); + -webkit-backdrop-filter: blur(12px) saturate(140%); + overflow-y: auto; +} + +/* Tasks sidebar only: the first sidebar-category sits flush against the top + without any top padding. Apps and config sidebars don't need this — their + first items already have spacing baked in. */ +.tasks-layout .sidebar { + padding-top: 20px; +} + +/* Apps sidebar search bar — glass input pinned to the top of the + sidebar above the category list. Toggles the clear (×) button + visibility via .has-value. */ +.apps-search { + position: relative; + padding: 14px 20px; + border-bottom: 1px solid var(--sidebar-border); +} + +.apps-search-icon { + position: absolute; + left: 32px; + top: 50%; + transform: translateY(-50%); + color: rgba(var(--text-rgb), 0.55); + pointer-events: none; +} + +.apps-search-input { + width: 100%; + padding: 9px 34px 9px 36px; + background: rgba(var(--text-rgb), 0.06); + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.apps-search-input::placeholder { + color: rgba(var(--text-rgb), 0.5); +} + +.apps-search-input:focus { + outline: none; + background: rgba(var(--text-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.16); +} + +/* Strip the native search-cancel (we render our own × button). */ +.apps-search-input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; +} + +.apps-search-clear { + position: absolute; + right: 28px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + border: none; + background: rgba(var(--text-rgb), 0.10); + color: rgba(var(--text-rgb), 0.7); + border-radius: 50%; + font-size: 18px; + line-height: 1; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + transition: background 0.18s ease, color 0.18s ease; +} + +.apps-search-clear:hover { + background: rgba(var(--status-danger-rgb), 0.20); + color: var(--status-danger); +} + +.apps-search.has-value .apps-search-clear { + display: flex; +} + +.sidebar h2 { + padding: 20px; + text-align: center; + font-size: 20px; + font-weight: 600; + border-bottom: 1px solid var(--sidebar-border); +} + +.category { + padding: 15px 20px; + cursor: pointer; + color: var(--text-secondary); + border-bottom: 1px solid var(--sidebar-border); + display: flex; + align-items: center; + gap: 10px; + transition: background 0.2s, color 0.2s; +} + +.category:hover, +.category.active { + background: var(--surface-hover); + color: var(--text-primary); +} + +.category img, +.category .category-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +/* Mobile: take the sidebar out of flex flow entirely so the main + content fills the full viewport. The drawer carries its contents + when the burger is open. Lives here (and not in style.css) because + sidebar.css loads after style.css — without this co-location the + base .sidebar { position: relative } below wins the cascade and the + sidebar keeps its 220px column at mobile widths. */ +@media (max-width: 768px) { + .sidebar { + position: fixed; + top: 60px; + left: 0; + height: calc(100vh - 60px); + transform: translateX(-100%); + border-right: none; + z-index: 100; + } +} diff --git a/containers/libreportal/frontend/css/style.css b/containers/libreportal/frontend/css/style.css new file mode 100755 index 0000000..7a4ee06 --- /dev/null +++ b/containers/libreportal/frontend/css/style.css @@ -0,0 +1,3816 @@ + + +/* Reset */ +* { box-sizing: border-box; margin: 0; padding: 0; } + +/* ---------------------------------------------------------------------- + Global themed scrollbars. + + - Firefox uses scrollbar-color (and scrollbar-width: thin). + - WebKit/Blink uses the ::-webkit-scrollbar pseudo elements. + + The thumb's transparent border + background-clip: padding-box gives + the thumb breathing room (the visible thumb is thinner than the + 8px channel) so it feels less heavy. Hover swaps the thumb to the + theme accent. Track is transparent so the scrollbar floats over + whatever surface it's on, matching the cosmic glass elsewhere. + ---------------------------------------------------------------------- */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(var(--text-rgb), 0.20) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: rgba(var(--text-rgb), 0.20); + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 8px; + transition: background-color 0.18s ease; +} + +*::-webkit-scrollbar-thumb:hover { + background: rgba(var(--accent-rgb), 0.55); + background-clip: padding-box; +} + +*::-webkit-scrollbar-thumb:active { + background: rgba(var(--accent-rgb), 0.75); + background-clip: padding-box; +} + +*::-webkit-scrollbar-corner { + background: transparent; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + display: flex; + flex-direction: column; + min-height: 100vh; + padding-top: 60px; + background: var(--surface-bg); + background-attachment: fixed; + color: var(--text-primary); +} + +/* Nebula body — same recipe as .aurora-bg.aurora-static used on the + loading + login screens, so the chrome and main content share one + atmosphere. Three layers cover the viewport: the base radial + + linear gradient on html, a static cyan-blob plume on ::before + (mirrors aurora-bg::after), and the star-particle pattern on + ::after (mirrors .aurora-stars::before). No animation. */ +html[data-theme="nebula"] { + background: + radial-gradient(ellipse at 20% 30%, var(--gradient-mid) 0%, transparent 55%), + radial-gradient(ellipse at 80% 70%, var(--gradient-to) 0%, transparent 55%), + linear-gradient(135deg, var(--gradient-from) 0%, var(--gradient-from) 40%, var(--gradient-mid) 100%); + background-attachment: fixed; +} + +html[data-theme="nebula"] body { + background: transparent; +} + +html[data-theme="nebula"]::before { + content: ''; + position: fixed; + inset: -10%; + z-index: -2; + background: + /* Warm cosmic accents — magenta + violet bloom for nebula richness */ + radial-gradient(circle at 12% 88%, rgba(180, 90, 220, 0.32) 0%, transparent 42%), + radial-gradient(circle at 88% 12%, rgba(255, 120, 180, 0.22) 0%, transparent 38%), + /* Cyan accent plumes (theme accent colour) */ + radial-gradient(circle at 18% 22%, rgba(var(--accent-rgb), 0.42) 0%, transparent 45%), + radial-gradient(circle at 78% 18%, rgba(var(--accent-rgb), 0.34) 0%, transparent 42%), + radial-gradient(circle at 30% 78%, rgba(var(--accent-rgb), 0.30) 0%, transparent 48%), + radial-gradient(circle at 82% 80%, rgba(var(--accent-rgb), 0.40) 0%, transparent 46%), + radial-gradient(circle at 50% 50%, rgba(var(--accent-rgb), 0.14) 0%, transparent 60%); + pointer-events: none; +} + +html[data-theme="nebula"]::after { + content: ''; + position: fixed; + inset: 0; + z-index: -1; + background-image: + radial-gradient(1.5px 1.5px at 12px 18px, rgba(var(--text-rgb), 0.9), transparent 60%), + radial-gradient(1px 1px at 47px 92px, rgba(var(--accent-rgb), 0.85), transparent 60%), + radial-gradient(1.2px 1.2px at 110px 40px, rgba(var(--text-rgb), 0.75), transparent 60%), + radial-gradient(1px 1px at 165px 130px, rgba(var(--accent-rgb), 0.70), transparent 60%); + background-size: 200px 200px; + pointer-events: none; +} + +.mobile-menu-toggle { + display: none; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 8px; + border-radius: 6px; + transition: background 0.2s; +} + +.mobile-menu-toggle:hover { + background: rgba(var(--text-rgb), 0.1); +} + +.theme-selector { + padding: 6px 12px; + border: 1px solid; + border-radius: 4px; + font-size: 14px; + cursor: pointer; +} + +/* Layout — config page (and any other page using .container/.main) uses + a viewport-locked flex row so the sidebar paints its background the + full column height and the main pane scrolls independently. Same + pattern as .tasks-layout and .apps-layout. */ +.container { + display: flex; + width: 100%; + height: calc(100vh - 60px); + overflow: hidden; +} + +.mobile-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(var(--bg-rgb), 0.5); + z-index: 99; + opacity: 0; + transition: opacity 0.3s ease; +} + +.mobile-overlay.active { + display: block; + opacity: 1; +} + +/* Main content — fills the remaining width inside .container/.apps-layout + and scrolls internally so the sidebar can stay locked at viewport + height. */ +.main { + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + padding: 0px; + overflow-y: auto; +} + +.advanced-field.is-hidden { + display: none; +} + +.mullvad-generate-field .mullvad-generate-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.mullvad-generate-field .mullvad-generate-btn { margin: 0; } +.mullvad-generate-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 4px 10px; + border-radius: 12px; + border: 1px solid rgba(var(--text-rgb), 0.15); + background: rgba(var(--text-rgb), 0.05); + color: rgba(var(--text-rgb), 0.6); +} +.mullvad-generate-status.is-configured { + background: rgba(var(--status-success-rgb), 0.12); + border-color: rgba(var(--status-success-rgb), 0.35); + color: var(--status-success); +} +.mullvad-generate-status .mullvad-generate-tick { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 3px; + border: 1px solid currentColor; + font-size: 11px; + line-height: 1; +} +.mullvad-generate-status.is-configured .mullvad-generate-tick { + background: var(--status-success); + border-color: var(--status-success); + color: #0b3d1c; +} + +.gluetun-countries-field { + display: flex; + align-items: center; + gap: 12px; +} +.gluetun-countries-display { + flex: 1; + min-width: 0; + display: flex; + flex-wrap: nowrap; + gap: 6px; + height: 32px; + padding: 6px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--card-bg); + overflow: hidden; + white-space: nowrap; + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); + mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); +} +.gluetun-country-chip { flex-shrink: 0; } +.gluetun-country-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + border-radius: 12px; + background: rgba(52, 152, 219, 0.15); + border: 1px solid rgba(52, 152, 219, 0.3); + color: var(--text-color); + font-size: 12px; +} +.gluetun-flag { + font-size: 14px; + line-height: 1; + font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; +} +.gluetun-country-empty { + color: var(--text-secondary, #888); + font-style: italic; + font-size: 12px; +} +.gluetun-countries-edit { flex-shrink: 0; } +.gluetun-modal .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); +} +.gluetun-modal .modal-close { + background: none; + border: none; + font-size: 24px; + color: var(--text-color); + cursor: pointer; + padding: 0; +} +.gluetun-modal .modal-body { + padding: 20px; + overflow-y: auto; +} +.gluetun-modal .modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid var(--border-color); +} +.gluetun-provider-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: rgba(56, 189, 248, 0.10); + border: 1px solid rgba(56, 189, 248, 0.30); + border-radius: 10px; + margin-bottom: 14px; +} +.gluetun-provider-icon-wrap { + width: 44px; + height: 44px; + border-radius: 9px; + background: var(--surface-bg-solid); + border: 1px solid rgba(var(--text-rgb), 0.10); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; +} +.gluetun-provider-icon { width: 32px; height: 32px; object-fit: contain; } +.gluetun-provider-text { flex: 1; min-width: 0; } +.gluetun-provider-label { margin: 0; font-size: 12px; color: rgba(var(--text-rgb), 0.60); text-transform: uppercase; letter-spacing: 0.5px; } +.gluetun-provider-name { margin: 2px 0 0 0; font-size: 16px; font-weight: 600; color: var(--text-primary); text-transform: capitalize; } + +.gluetun-search-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 12px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; + margin-bottom: 12px; +} +.gluetun-search-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 8px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.gluetun-search-row:focus-within { + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.12); +} +.gluetun-search-icon { color: rgba(var(--text-rgb), 0.55); flex-shrink: 0; } +.gluetun-country-search { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text-primary); + font-size: 14px; + padding: 2px 0; +} +.gluetun-search-actions { + display: flex; + gap: 8px; +} +.gluetun-search-actions .btn { flex: 1 1 0; } + +.gluetun-modal .modal-footer { + display: flex; + gap: 12px; +} +.gluetun-modal .modal-footer .btn { flex: 1 1 0; } + +.gluetun-country-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 6px 14px; + max-height: 45vh; + overflow-y: auto; + padding: 4px 2px; +} +.gluetun-country-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} +.gluetun-country-item:hover { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(56, 189, 248, 0.25); +} +.gluetun-country-item:has(input:checked) { + background: rgba(56, 189, 248, 0.10); + border-color: rgba(56, 189, 248, 0.45); +} + +.gluetun-country-item input[type=checkbox] { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + flex-shrink: 0; + cursor: pointer; + border-radius: 5px; + background: rgba(var(--text-rgb), 0.04); + border: 1.5px solid rgba(var(--text-rgb), 0.18); + box-shadow: inset 0 0 0 1px rgba(var(--text-rgb), 0.02); + position: relative; + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.12s ease; + margin: 0; +} +.gluetun-country-item:hover input[type=checkbox] { + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.08); +} +.gluetun-country-item input[type=checkbox]:focus-visible { + outline: none; + border-color: rgba(56, 189, 248, 0.85); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.20); +} +.gluetun-country-item input[type=checkbox]:checked { + background: linear-gradient(135deg, #38bdf8, #818cf8); + border-color: rgba(56, 189, 248, 0.9); + box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.35); +} +.gluetun-country-item input[type=checkbox]:checked::after { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: center; + background-size: 13px 13px; + animation: gluetunCheckPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); +} +@keyframes gluetunCheckPop { + 0% { transform: scale(0.4); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +.gluetun-country-name { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 15px; + color: var(--text-primary); + font-weight: 500; +} +.gluetun-country-name .gluetun-flag { + font-size: 18px; + line-height: 1; +} +.gluetun-country-empty-msg { + grid-column: 1 / -1; + color: var(--text-secondary, #888); + font-style: italic; +} + +/* Output console */ +.console { + background: var(--console-bg); + color: var(--console-text); + border: 1px solid var(--border); + border-radius: 12px; + padding: 5px; /* Further reduced to 5px */ + height: 125px; /* Reduced from 250px to half */ + overflow-y: auto; + white-space: pre-wrap; + font-size: 14px; + font-family: 'Courier New', monospace; + margin: 0; + position: relative; /* Ensure proper positioning */ + top: 0; /* Force to top */ +} + +.console-section { + margin-top: 20px; +} + +/* Remove gaps in console output */ +.log-entry { + margin: 0; + padding: 2px 8px; + border-radius: 4px; + display: block; + line-height: 1.4; +} + +.log-entry:first-child { + padding-top: 0; + margin-top: 0; + border-top: none; +} + +.log-entry:last-child { + padding-bottom: 0; +} + +.log-timestamp { + color: rgba(var(--text-rgb), 0.5); + font-size: 10px; + margin-right: 8px; + display: inline; +} + +.tabs-wrapper { + display: block; + width: 100%; +} + +.tabs-list { + display: flex; + background: var(--hover-bg); + border-bottom: 1px solid var(--border-color); + padding: 0; + margin: 0; + width: 100%; + overflow-x: auto; + scrollbar-color: rgba(var(--text-rgb), 0.4) rgba(var(--text-rgb), 0.08); +} + +/* Tabs inside .tabs-wrapper or .tab-navigation share the row evenly so + the bar fills its container instead of leaving empty space on the right. + Children also get centered so labels (and any leading icon/emoji) sit + in the middle of each tab. */ +.tabs-wrapper .tabs-list .tab-button, +.tab-navigation .tab-button { + flex: 1 1 0; + min-width: 0; + text-align: center; + white-space: nowrap; + justify-content: center; +} + +/* Task Status Indicator */ +.task-status-indicator { + position: fixed; + top: 20px; + right: 20px; + background: rgba(33, 150, 243, 0.95); + color: var(--text-primary); + padding: 12px 16px; + border-radius: 8px; + border: 1px solid rgba(76, 175, 80, 0.3); + z-index: 1000; + font-size: 14px; + font-weight: 500; + backdrop-filter: blur(10px); + animation: slideIn 0.3s ease-out; +} + +.task-status-content { + display: flex; + align-items: center; + gap: 8px; +} + +.spinner-small { + width: 16px; + height: 16px; + border: 2px solid rgba(var(--text-rgb), 0.3); + border-top: 2px solid #4CAF50; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Button States */ +.task-running { + opacity: 0.7; + cursor: not-allowed !important; + position: relative; + overflow: hidden; +} + +.task-running::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(76, 175, 80, 0.1)); + animation: loadingShimmer 1.5s infinite; +} + +@keyframes loadingShimmer { + 0% { left: -100%; } + 50% { left: 100%; } + 100% { left: 100%; } +} + +/* App Header Enhancement */ +.app-header { + position: relative; +} + +.task-highlighted { + background: linear-gradient(135deg, rgba(76, 175, 80, 0.2), rgba(33, 150, 243, 0.2)); + border: 2px solid #4CAF50; + border-radius: 8px; + transition: all 0.3s ease; +} + +.task-highlighted:hover { + background: linear-gradient(135deg, rgba(76, 175, 80, 0.3), rgba(33, 150, 243, 0.3)); +} + +/* Clean scrollbar from scratch - higher specificity */ +.tabs-wrapper .tabs-list::-webkit-scrollbar { + height: 12px !important; +} + +.tabs-wrapper .tabs-list::-webkit-scrollbar-track { + background: rgba(var(--text-rgb), 0.08) !important; + border-radius: 9px !important; +} + +.tabs-wrapper .tabs-list::-webkit-scrollbar-thumb { + background: rgba(var(--text-rgb), 0.4) !important; + border-radius: 9px !important; + border: none !important; +} + +.tabs-wrapper .tabs-list::-webkit-scrollbar-thumb:hover { + background: rgba(var(--text-rgb), 0.5) !important; +} + +/* Dynamic scrollbar enhancement for when tabs-list exists - higher specificity */ +.tabs-wrapper .tabs-list[data-scrollable="true"]::-webkit-scrollbar { + height: 16px !important; +} + +.tabs-wrapper .tabs-list[data-scrollable="true"]::-webkit-scrollbar-track { + background: rgba(var(--text-rgb), 0.1) !important; + border-radius: 10px !important; + margin: 15px 0 8px 0 !important; +} + +.tabs-wrapper .tabs-list[data-scrollable="true"]::-webkit-scrollbar-thumb { + background: rgba(var(--text-rgb), 0.5) !important; + border-radius: 10px !important; + border: none !important; +} + +.tabs-wrapper .tabs-list[data-scrollable="true"]::-webkit-scrollbar-thumb:hover { + background: rgba(var(--text-rgb), 0.6) !important; +} + +.tabs-wrapper .tabs-list[data-scrollable="true"]::-webkit-scrollbar-thumb:hover::-webkit-scrollbar { + height: 11px !important; /* 16px * 2/3 = ~11px */ +} + +.tab-emoji { + font-size: 14px; + /* Coerce the OS to render these as text-presentation glyphs rather + than colour-emoji bitmaps (⚙ instead of ⚙️ etc.). Result is a + monochrome glyph we can theme with `color`, which reads way + better against the dark cosmic gradient than the platform's + greyish emoji bitmaps did. */ + font-variant-emoji: text; + color: var(--accent); + line-height: 1; +} + +/* Active tab uses text-primary so the icon doesn't disappear behind + the accent-tinted pill background. */ +.tab-button.active .tab-emoji, +.tab-button.nav-active .tab-emoji, +.main-tab-button.active .tab-emoji, +.main-tab-button.nav-active .tab-emoji { + color: var(--text-primary); +} + +.tab-name { + font-weight: 500; +} + +.tabs-content { + display: block; + width: 100%; + background: var(--card-bg); + padding: 30px 10px 20px 10px; + border-radius: 0px 0px 12px 12px; +} + +.tab-panel { + display: none; + padding: 5px 24px 5px 24px; + min-height: auto; + animation: fadeIn 0.2s ease; +} + +.tab-panel.active { + display: block; +} + +.panel-header { + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); + display: none; /* Hide duplicate headers */ +} + +.panel-header h4 { + margin: 0 0 6px 0; + color: var(--text-primary, #fff); + font-size: 16px; + font-weight: 600; +} + +.panel-header p { + margin: 0; + color: var(--text-secondary, #ccc); + font-size: 13px; +} + +.panel-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 16px; +} + +@keyframes configDirtyIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.dep-required-card { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 14px; + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.30); + border-radius: 10px; + margin-bottom: 10px; +} +.dep-required-icon { + width: 40px; + height: 40px; + border-radius: 8px; + object-fit: contain; + background: rgba(var(--text-rgb), 0.04); + padding: 4px; + flex-shrink: 0; +} +.dep-required-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.dep-required-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} +.dep-required-reason { + font-size: 12px; + color: rgba(var(--text-rgb), 0.70); + line-height: 1.4; +} +.dep-required-action { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +@media (max-width: 560px) { + .dep-required-card { + flex-direction: column; + align-items: stretch; + text-align: center; + } + .dep-required-icon { align-self: center; } + .dep-required-action { justify-content: center; } +} + +.nav-button { + margin-left: auto; + padding: 4px 8px; + background: var(--primary-color); + color: var(--text-primary); + border: none; + border-radius: 4px; + font-size: 10px; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.2s ease; +} + +.nav-button:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +.nav-button svg { + flex-shrink: 0; +} + +.nav-button.install-button { + background: var(--status-success); +} + +.nav-button.install-button:hover { + background: var(--status-success-hover); + transform: translateY(-1px); +} + +/* Confirmation Dialog - Simple Working Version */ +.confirmation-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(var(--bg-rgb), 0.7); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: 99999; + display: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.confirmation-overlay.active { + display: block; + opacity: 1; +} + +.confirmation-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--surface-bg-solid); + background: var(--bg-primary, #1a1a1a); + border: 2px solid var(--border-strong); + border: 2px solid var(--border-color, #444); + border-radius: 8px; + max-width: 400px; + width: 90%; + z-index: 100000; + opacity: 1; + transition: all 0.3s ease; + display: none; +} + +.confirmation-overlay.active .confirmation-dialog { + display: block; +} + +.confirmation-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid var(--border-strong); + border-bottom: 1px solid var(--border-color, #444); +} + +.confirmation-header h3 { + margin: 0; + color: var(--text-primary); + color: var(--text-primary, #fff); + font-size: 16px; + font-weight: 600; +} + +.confirmation-close { + background: none; + border: none; + color: var(--text-secondary); + color: var(--text-secondary, #ccc); + font-size: 20px; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.confirmation-close:hover { + color: var(--text-primary); + color: var(--text-primary, #fff); +} + +.confirmation-body { + padding: 20px; +} + +.confirmation-content { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 15px; +} + +.confirmation-icon { + font-size: 20px; + flex-shrink: 0; + margin-top: 2px; +} + +.confirmation-text { + color: var(--text-primary); + color: var(--text-primary, #fff); + line-height: 1.4; + flex: 1; +} + +.confirmation-checkbox { + padding-top: 15px; + border-top: 1px solid var(--border-strong); + border-top: 1px solid var(--border-color, #444); + display: flex; + justify-content: space-between; + align-items: center; +} + +.confirmation-checkbox label { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + color: var(--text-primary, #fff); + cursor: pointer; + font-size: 14px; + order: 1; +} + +.confirmation-checkbox input { + width: 16px; + height: 16px; +} + +.confirmation-footer { + display: flex; + gap: 10px; + justify-content: flex-end; + padding: 15px 20px; +} + +.confirmation-btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s ease; +} + +.confirmation-btn-cancel { + background: var(--text-muted); + color: var(--text-primary); +} + +.confirmation-btn-cancel:hover { + background: var(--text-secondary); +} + +.confirmation-btn-ticked { + background: var(--status-success) !important; + color: #ffffff !important; +} + +.confirmation-btn-ticked:hover { + background: var(--status-success-hover) !important; +} + +.confirmation-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tab-panel#panel-advanced .panel-header { + background: rgba(255, 107, 53, 0.1); + border-left: 4px solid var(--status-warning); + border-bottom: 1px solid var(--border-color); +} + +.tab-panel#panel-advanced .panel-header h4 { + color: var(--status-warning); + display: flex; + align-items: center; + gap: 8px; +} + +.tab-panel#panel-advanced .panel-header p { + color: #d84315; + font-style: italic; + font-size: 12px; + margin: 4px 0 0 0; +} + +.no-fields { + text-align: center; + padding: 32px; + color: var(--text-secondary, #ccc); + font-style: italic; + background: var(--hover-bg); + border-radius: 6px; + border: 1px dashed var(--border-color); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .tab-button { + flex: 1; + min-width: 60px; + justify-content: center; + font-size: 11px; + padding: 8px 12px; + } + + .main-tab-button { + flex: 1; + min-width: 60px; + justify-content: center; + font-size: 11px; + padding: 8px 12px; + } + + .tab-emoji { + font-size: 12px; + } + + .tab-name { + display: none; + } + + .config-title { + padding: 16px; + } + + .tab-panel { + padding: 24px 16px 16px 16px; + min-height: auto; + } + + .panel-fields { + grid-template-columns: 1fr; + gap: 12px; + } + + .form-field { + gap: 4px; + } + + .form-input, + .form-select, + .form-textarea { + padding: 8px; + font-size: 16px; /* Prevent zoom on iOS */ + } +} + +.timeout { + font-weight: bold; +} + +/* App Configuration Page */ +.app-header { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + margin-bottom: 30px; +} + +.app-info { + display: flex; + align-items: center; + gap: 20px; +} + +.app-details h1 { + font-size: 28px; + font-weight: 700; + margin-bottom: 8px; + color: var(--text-color); +} + +.app-details h2 { + font-size: 24px; + font-weight: 600; + color: var(--text-color); + margin-bottom: 8px; +} + +.app-details .app-long-description { + font-size: 14px; + color: var(--text-secondary, #ccc); + line-height: 1.4; +} + +/* Responsive config field rules moved to config.css (where the base + .config-fields { repeat(3, 1fr) } lives — config.css loads after + style.css so keeping these here got overridden by the unscoped + base rule). */ + +/* Section Toggle Functionality */ +.hidden { + display: none !important; +} + +.section-content { + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.section-content.disabled { + opacity: 0.5; + pointer-events: none; +} + +/* App Header with Actions */ +.app-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 30px; + padding: 20px; + background: rgba(var(--text-rgb), 0.05); + border-radius: 12px; + border: 1px solid rgba(var(--text-rgb), 0.1); +} + +.app-info { + display: flex; + align-items: center; + flex: 1; +} + +.backup-btn, .uninstall-btn { + padding: 8px 16px; + border: 1px solid rgba(var(--text-rgb), 0.3); + background: transparent; + color: rgba(var(--text-rgb), 0.9); + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.backup-btn:hover, .uninstall-btn:hover { + background: rgba(var(--text-rgb), 0.1); + transform: translateY(-1px); +} + +.password-field { + position: relative; + display: flex; + align-items: center; +} + +/* Old notification styles removed - using newer notification system */ + +/* REMOVED OLD CHECKBOX STYLES - REPLACED WITH TOGGLE SWITCH */ + +/* Info tooltip badge. Markup is ℹ️. + The ℹ️ emoji is drawn by the OS as a multi-color bitmap that doesn't + match our theme. We clip the host (overflow:hidden + text-indent to + push it off-screen) so the emoji is invisible no matter how the + platform fonts behave, then draw a clean italic 'i' via ::after + absolutely positioned inside the cyan circle. */ +.tooltip { + display: inline-block; + width: 16px; + height: 16px; + background: var(--primary-color, var(--accent)); + border-radius: 50%; + cursor: help; + position: relative; + margin-left: 4px; + flex-shrink: 0; + overflow: hidden; + text-indent: 100%; + white-space: nowrap; + font-size: 0; + color: transparent; + vertical-align: middle; + /* Sits below where it looks aligned with adjacent label text. */ + margin-top: -2px; + transition: background 0.18s ease, transform 0.18s ease; +} + +.tooltip::after { + content: 'i'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: Georgia, 'Times New Roman', serif; + font-style: italic; + font-weight: 700; + font-size: 11px; + line-height: 1; + color: var(--text-on-accent, #ffffff); + text-indent: 0; + /* Drops the 'i' inside the circle so it doesn't sit too high. + Tuned with the host's -2px margin-top above. */ + padding-bottom: 0; +} + +.tooltip:hover { + background: var(--accent-hover, var(--primary-color-hover, #4169e1)); + transform: scale(1.08); +} + +.tooltip::before { + content: attr(title); + position: absolute; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + background: rgba(var(--bg-rgb), 0.9); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + white-space: normal; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; + max-width: 300px; + word-wrap: break-word; + min-width: 200px; +} + +.tooltip:hover::before { + opacity: 1; + visibility: visible; + z-index: 99999; +} + +/* REMOVED OLD CHECKBOX STYLES - REPLACED WITH TOGGLE SWITCH */ + +/* REMOVED OLD CHECKBOX STYLES - REPLACED WITH TOGGLE SWITCH */ + +#hidden-options-content { + padding: 20px; +} + +#hidden-options-content h3 { + color: var(--text-secondary, #ccc); + font-size: 16px; + margin-bottom: 16px; + font-style: italic; +} + +.install-btn { + background: var(--status-success); + color: #ffffff; + border: 1px solid var(--status-success); + padding: 14px 28px; + border-radius: 12px; + font-weight: 600; +} + +.install-btn:hover { + background: var(--status-success-hover); + transform: translateY(-2px); +} + +/* Base Button Styles */ +.btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +.log-entry { + font-family: 'Courier New', monospace; + font-size: 12px; + line-height: 1.4; + margin-bottom: 5px; + padding: 5px; + border-radius: 4px; + background: rgba(var(--bg-rgb), 0.2); + color: rgba(var(--text-rgb), 0.8); + word-break: break-all; +} + +.log-entry.error { + background: rgba(var(--status-danger-rgb), 0.2); + color: var(--status-danger); +} + +.log-entry.success { + background: rgba(var(--status-success-rgb), 0.2); + color: #51cf66; +} + +.log-entry.warning { + background: rgba(var(--status-warning-rgb), 0.2); + color: #ffd43b; +} + +.log-entry.info { + background: rgba(var(--accent-rgb), 0.2); + color: #74c0fc; +} + +/* Error state */ +.error { + padding: 20px; + border-radius: 8px; + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + margin: 20px; +} + +.tab-content { +} + +.tab-pane { + display: none; + animation: fadeIn 0.3s ease-in-out; +} + +.tab-pane.active { + display: block; +} + +.tab-pane h4 { + margin: 0 0 20px 0; + color: rgba(var(--text-rgb), 0.9); + font-size: 16px; + font-weight: 600; + padding-bottom: 10px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.1); +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .apps-section { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + } +} + +@media (max-width: 768px) { + /* Mobile menu toggle */ + .mobile-menu-toggle { + display: block; + } + + /* Sidebar mobile styles */ + .sidebar { + position: fixed; + top: 60px; + left: 0; + height: calc(100vh - 60px); + transform: translateX(-100%); + z-index: 100; + } + + .sidebar.mobile-open { + transform: translateX(0); + } + + /* App Configuration Page Styles */ + .app-info { + display: flex; + align-items: flex-start; + gap: 24px; + padding: 32px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + transition: all 0.2s ease; + } + + .app-info:hover { + transform: translateY(-2px); + box-shadow: var(--card-shadow-hover); + border-color: var(--primary-color); + } + + .app-icon { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + flex-shrink: 0; + transition: all 0.2s ease; + } + + .app-icon img { + width: 48px; + height: 48px; + object-fit: contain; + transition: transform 0.2s ease; + } + + .app-icon:hover img { + transform: scale(1.1); + } + + .app-details { + flex: 1; + min-width: 0; + } + + .app-details h2 { + font-size: 24px; + font-weight: 600; + color: var(--text-color); + margin: 0 0 8px 0; + line-height: 1.2; + } + + .app-description { + font-size: 16px; + color: var(--text-secondary, #ccc); + margin: 0 0 16px 0; + line-height: 1.5; + } + + .app-meta { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + } + + .category-tag { + background: var(--primary-color); + color: var(--text-primary); + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + } + + .status-tag { + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + } + + .status-tag.installed { + background: var(--status-success); + color: var(--text-primary); + } + + .status-tag.available { + background: var(--text-muted); + color: var(--text-primary); + } + + .app-not-found { + text-align: center; + padding: 60px 20px; + color: var(--text-color); + } + + .app-not-found h2 { + font-size: 24px; + font-weight: 600; + margin: 0 0 16px 0; + } + + .config-section { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 22px; + margin-top: 24px; + } + + .config-placeholder { + text-align: center; + } + + .config-placeholder h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-color); + margin: 0 0 16px 0; + } + + .config-placeholder p { + color: var(--text-secondary, #ccc); + margin: 0 0 24px 0; + line-height: 1.5; + } + + .config-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 24px; + } + + .btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 8px; + } + + .btn-primary { + background: var(--primary-color); + color: var(--text-primary); + } + + .btn-primary:hover { + background: var(--primary-hover); + transform: translateY(-1px); + } + + .btn-secondary { + color: var(--text-color); + border: 1px solid var(--border-color); + } + + .btn-secondary:hover { + background: var(--border-color); + transform: translateY(-1px); + } + + /* Main content mobile — go edge-to-edge so config/forms inside + don't get squeezed by stacked padding from .main + .config-section. */ + .main { + padding: 0; + } + + /* App cards mobile */ + .apps-section { + grid-template-columns: 1fr; + gap: 16px; + } + + .app-card { + flex-direction: column; + padding: 16px; + gap: 12px; + } + + .app-card-top { + flex-direction: column; + align-items: center; + gap: 12px; + } + + .app-card-icon { + width: 80px; + height: 80px; + align-self: center; + } + + .app-card-content { + text-align: center; + width: 100%; + } + + .app-card-actions { + width: 100%; + min-width: auto; + flex-direction: row; + } + + .app-card-actions button { + flex: 1; + width: 50%; + } + + .app-card-title { + font-size: 16px; + white-space: normal; + line-height: 1.3; + } + + .app-card-description { + font-size: 13px; + } + + .app-card button { + width: 100%; + padding: 14px 20px; + font-size: 15px; + min-height: 48px; + } + + /* Topbar mobile */ + .topbar { + padding: 0 16px; + } + + .topbar-controls { + gap: 8px; + } + + .topbar-nav { + display: none; + } + + .theme-selector { + font-size: 12px; + padding: 4px 8px; + } + + .donate-btn { + padding: 6px 12px; + font-size: 12px; + } + + /* Category tags mobile */ + .app-card-tags { + justify-content: center; + flex-wrap: wrap; + } + + .app-tag { + font-size: 11px; + padding: 3px 6px; + } +} + +@media (max-width: 480px) { + /* Extra small screens */ + .topbar { + padding: 0 12px; + } + + .main { + padding: 0; + } + + .app-card { + padding: 12px; + } + + .app-card-icon { + width: 50px; + height: 50px; + } + + .app-card-title { + font-size: 15px; + } + + .app-card-description { + font-size: 12px; + } + + .donate-btn { + display: none; + } + + .logo { + font-size: 18px; + } +} + +.dashboard-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + padding: 22px; +} + +.dashboard-content { + /* Remove background - let main container show through */ + border-radius: 12px; + border: 1px solid var(--border-color); + padding: 24px; + margin-bottom: 16px; /* Reduced from 32px to reduce gap */ +} + +/* Dashboard Front Page Installed Apps */ +.frontpage-apps-section { + padding: 0 22px 22px; +} + +.frontpage-apps-grid { + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +.frontpage-app-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.frontpage-app-icon-wrap { + position: relative; + width: 132px; + height: 132px; + background: rgba(var(--text-rgb), 0.08); + border-radius: 22px; + border: 1px solid rgba(var(--text-rgb), 0.15); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + overflow: hidden; +} + +.frontpage-app-tile:hover .frontpage-app-icon-wrap { + transform: translateY(-4px); + border-color: rgba(var(--text-rgb), 0.25); +} + +.frontpage-app-icon-wrap img { + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Overlay that appears on hover when services are available — frosted + veil so the icon underneath still reads through, not a black slab. */ +.frontpage-app-overlay { + display: none; + position: absolute; + inset: 0; + background: rgba(var(--bg-rgb), 0.45); + border-radius: 18px; + backdrop-filter: blur(8px) saturate(140%); + -webkit-backdrop-filter: blur(8px) saturate(140%); + flex-direction: column; + gap: 6px; + padding: 10px; + overflow-y: auto; + justify-content: space-between; +} + +.frontpage-app-icon-wrap:hover .frontpage-app-overlay { + display: flex; +} + +.frontpage-app-overlay a { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 5px; + background: rgba(var(--status-success-rgb), 0.15); + border: 1px solid rgba(var(--status-success-rgb), 0.3); + color: var(--text-primary); + text-decoration: none; + font-size: 10px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background 0.15s ease; + flex-shrink: 0; +} + +.frontpage-app-overlay a:hover { + background: rgba(var(--status-success-rgb), 0.3); +} + +.frontpage-app-overlay a svg { + flex-shrink: 0; + opacity: 0.8; + width: 11px; + height: 11px; +} + +.frontpage-app-name { + display: none; +} + +.frontpage-app-manage-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 6px; + background: var(--accent); + border: 1px solid var(--accent); + color: var(--text-primary); + text-decoration: none; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background 0.15s ease; + flex-shrink: 0; + cursor: pointer; + /* Pin to the bottom of the overlay even when no service buttons are above it. + With justify-content: space-between, a single child sticks to the top — + margin-top: auto consumes the free space and pushes the manage button down. */ + margin-top: auto; +} + +.frontpage-app-manage-btn:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.install-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: var(--status-success); + color: #ffffff; + border: 1px solid var(--status-success); + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.install-btn:hover { + background: var(--status-success-hover); + transform: translateY(-1px); +} + +.stat-card { + background: var(--card-bg); + border: 1px solid rgba(var(--text-rgb), 0.15); + border-radius: 12px; + padding: 24px; + text-align: center; + transition: transform 0.2s ease; + min-height: 120px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.stat-number { + font-size: 36px; + font-weight: bold; + color: var(--primary-color); + margin-bottom: 8px; +} + +.stat-label { + font-size: 14px; + color: var(--text-color); + opacity: 0.8; +} + +.disk-chart { + position: relative; + display: inline-block; +} + +/* Remove old disk chart styles that might conflict */ +#disk-circle { + display: none; /* Hide old SVG circle */ +} + +.disk-circle-container { + width: 60px; + height: 60px; + border-radius: 50%; + background: rgba(var(--text-rgb), 0.1); + position: relative; + overflow: hidden; + border: 2px solid rgba(var(--text-rgb), 0.2); +} + +.disk-circle-fill { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--status-success); + transition: height 0.5s ease-in-out; + border-radius: 0 0 50% 50%; +} + +.disk-percentage { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 12px; + font-weight: bold; + color: var(--text-primary); + pointer-events: none; + z-index: 10; +} + +.chart-label { + position: relative; + text-align: center; + margin-top: 10px; + pointer-events: none; +} + +.chart-text { + font-size: 10px; + color: var(--text-color); + opacity: 0.7; + margin-top: 2px; +} + +.system-info-card { + text-align: left; + padding: 20px; + position: relative; +} + +.system-details { + display: flex; + flex-direction: column; + gap: 8px; +} + +.system-item { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + font-size: 12px; +} + +.system-label { + color: var(--text-color); + opacity: 0.7; + font-weight: 500; +} + +.system-refresh-btn { + position: absolute; + top: 12px; + right: 12px; + background: rgba(var(--text-rgb), 0.1); + border: 1px solid rgba(var(--text-rgb), 0.15); + border-radius: 6px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(var(--text-rgb), 0.7); + cursor: pointer; + transition: all 0.2s ease; +} + +.system-refresh-btn:hover { + background: rgba(var(--text-rgb), 0.15); + color: rgba(var(--text-rgb), 0.9); +} + +.system-refresh-btn svg { + width: 16px; + height: 16px; + stroke-width: 2; +} + +.system-refresh-tooltip { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + background: rgba(var(--bg-rgb), 0.8); + color: rgba(var(--text-rgb), 0.9); + padding: 6px 10px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 10; +} + +.system-refresh-btn:hover .system-refresh-tooltip { + opacity: 1; +} + +/* Category descriptions */ +.category-description { + font-size: 14px; + color: var(--text-secondary, #888); + margin: 8px 0 16px 0; + line-height: 1.4; + font-weight: 400; +} + +.git-section-content { + transition: all 0.3s ease; + border-radius: 8px; + overflow: hidden; + margin-top: 14px; +} + +.git-section-content.hidden { + max-height: 0; + padding: 0; + margin: 0; + opacity: 0; + pointer-events: none; +} + +.info-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 20px; + margin-bottom: 16px; +} + +.info-card h5 { + margin: 0 0 12px 0; + color: var(--primary-color); + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.info-card p { + margin: 0; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Domain Building Blocks */ +.domains-wrapper { + margin-bottom: 0px; +} + +.domains-header { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +} + +.domains-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary, #fff); + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.domains-divider { + width: 100%; + height: 2px; + background: var(--primary-color, var(--accent)); + margin-bottom: 20px; +} + +.add-domain-btn { + gap: 8px; + padding: 8px 20px; + margin-top: 12px; + margin-bottom: 12px; + min-width: 140px; + background: var(--primary-color); + color: var(--text-primary); + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +@media (max-width: 1600px) { + .domain-building-blocks { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 800px) { + .domain-building-blocks { + grid-template-columns: 1fr; + gap: 16px; + } +} + +.delete-domain-btn { + background: var(--status-danger); + color: #ffffff; + border: none; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.delete-domain-btn:hover { + background: var(--status-danger-hover); + transform: scale(1.05); +} + +.delete-domain-btn.disabled { + background: var(--text-muted); + color: #adb5bd; + cursor: not-allowed; + opacity: 0.6; + transform: none; +} + +.delete-domain-btn.disabled:hover { + background: var(--text-muted); + transform: none; +} + +.delete-icon { + font-size: 16px; + font-weight: bold; + line-height: 1; +} + +/* Reusable spacer component */ +.spacer { + display: block; + width: 100%; +} +.spacer-lg { height: 22px; } + +/* Mail Configuration Master Toggle */ +.mail-master-toggle { + margin-bottom: 0; + border-radius: 8px; + width: 100%; + box-sizing: border-box; +} + +/* Generic Configuration Master Toggle */ +.generic-master-toggle { + margin-bottom: 20px; + border-radius: 8px; + width: 100%; + box-sizing: border-box; +} + +.add-domain-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + min-width: 140px; + background: var(--status-success); + color: #ffffff; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.add-domain-btn.disabled { + background: var(--text-muted); + cursor: not-allowed; + opacity: 0.7; +} + +.add-domain-btn.disabled:hover { + background: var(--text-muted); + transform: none; +} + +.add-domain-btn:hover { + background: var(--status-success-hover); + transform: translateY(-1px); +} + +.add-icon { + font-size: 18px; + font-weight: bold; +} + +/* Flash animation for empty domain warning */ +@keyframes flash { + 0%, 100% { + background-color: transparent; + border-color: var(--border-color); + } + 25%, 75% { + background-color: rgba(var(--status-warning-rgb), 0.1); + border-color: var(--status-warning); + } + 50% { + background-color: rgba(var(--status-warning-rgb), 0.2); + border-color: var(--status-warning); + } +} + +/* Password input styling */ +.password-input { + display: flex; + gap: 8px; +} + +.password-input input { + flex: 1; +} + +.password-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--hover-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.password-toggle:hover { + background: var(--primary-color); + border-color: var(--primary-color); +} + +.password-toggle:hover svg { + stroke: white; +} + +.password-field-wrapper { + position: relative; + display: block; +} + +.password-field-wrapper .password-field { + width: 100%; + padding-right: 40px; +} + +.password-field-wrapper .password-toggle { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + width: 28px; + height: 28px; + background: transparent; + border: none; + padding: 0; +} + +.password-field-wrapper .password-toggle:hover { + background: rgba(var(--text-rgb), 0.08); + border: none; +} + +.password-toggle-icon { + font-size: 14px; + line-height: 1; + user-select: none; + color: var(--text-secondary, #a0a0a0); +} + +/* Responsive design */ +@media (max-width: 768px) { + .info-card { + padding: 16px; + margin-bottom: 12px; + } + + .group-header { + padding: 12px 16px; + } + + .group-fields { + padding: 16px; + } + + .field-group { + margin-bottom: 20px; + } + + .group-fields .form-field { + margin-bottom: 16px; + } +} + +.system-value { + color: var(--text-color); + font-weight: 600; +} + +.install-btn { + background: var(--status-success); + border: 1px solid var(--status-success); + border-radius: 8px; + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + white-space: nowrap; +} + +.install-btn:hover { + background: var(--status-success-hover); + border-color: var(--status-success-hover); + transform: translateY(-1px); +} + +.action-btn { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px 20px; + font-size: 14px; + font-weight: 500; + color: var(--text-color); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +.action-btn:hover { + background: var(--hover-bg); + transform: translateY(-1px); +} + +.action-btn.primary { + background: var(--primary-color); + color: var(--text-primary); + border-color: var(--primary-color); +} + +.action-btn.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.action-btn.secondary { + background: rgba(var(--text-rgb), 0.1); + border-color: rgba(var(--text-rgb), 0.2); +} + +.action-btn.secondary:hover { + background: rgba(var(--text-rgb), 0.2); +} + +.installed-apps { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + /* Remove background - let main container show through */ +} + +.empty-state { + grid-column: 1 / -1; + text-align: center; + padding: 60px 20px; + color: var(--text-color); +} + +.empty-state svg { + margin-bottom: 16px; + opacity: 0.5; +} + +.empty-state h3 { + font-size: 20px; + font-weight: 600; + margin-bottom: 12px; +} + +.empty-state p { + font-size: 16px; + opacity: 0.8; +} + +.empty-state a { + color: var(--primary-color); + text-decoration: none; + font-weight: 500; +} + +.empty-state a:hover { + text-decoration: underline; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-indicator.running { + background: var(--status-success); +} + +.status-indicator.stopped { + background: var(--status-danger); +} + +.status-text { + font-size: 12px; + font-weight: 500; +} + +/* Dashboard Mobile Responsive */ +@media (max-width: 768px) { + .dashboard-stats { + grid-template-columns: repeat(2, 1fr); + } + + .dashboard-actions { + flex-direction: column; + } + + .action-btn { + width: 100%; + justify-content: center; + } + + .section-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .filter-controls { + width: 100%; + flex-direction: column; + } + + .search-input { + min-width: auto; + width: 100%; + } + + .installed-apps { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .dashboard-stats { + grid-template-columns: 1fr; + } + + .stat-number { + font-size: 24px; + } +} + +/* New disk circle chart styles */ +.disk-chart { + position: relative; + display: inline-block; + vertical-align: middle; +} + +.disk-circle-container { + width: 60px; + height: 60px; + border-radius: 50%; + background: rgba(var(--text-rgb), 0.1); + position: relative; + overflow: hidden; + border: 2px solid rgba(var(--text-rgb), 0.2); + margin: 0 auto; /* Center horizontally */ +} + +.disk-circle-fill { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--status-success); + transition: height 0.5s ease-in-out; + border-radius: 0 0 50% 50%; /* Rounded bottom only */ +} + +.disk-percentage { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 12px; + font-weight: bold; + color: var(--text-primary); + pointer-events: none; /* Prevent mouse interactions */ + z-index: 10; /* Ensure it's on top */ +} + +/* LibrePortal logo styles */ +.libreportal-logo { + display: flex; + align-items: center; + margin-right: 15px; + padding: 4px; + border-radius: 6px; + background: rgba(var(--text-rgb), 0.1); + transition: all 0.3s ease; +} + +.libreportal-logo:hover { + background: rgba(var(--text-rgb), 0.2); + transform: scale(1.05); +} + +.libreportal-logo img { + width: 32px; + height: 32px; +} + +/* Notification container positioning - ensure bottom-right */ +.notification-container { + position: fixed !important; + bottom: 20px !important; + right: 20px !important; + top: auto !important; + left: auto !important; + z-index: 10000 !important; + pointer-events: none; + display: flex; + flex-direction: column-reverse; + gap: 10px; +} + +.notification { + background: var(--card-bg, #2d3748); + border: 1px solid var(--border-color, #4a5568); + border-radius: 8px; + padding: 16px; + width: auto; + min-width: 350px; + max-width: 700px; + pointer-events: all; + transform: translateY(100%); + opacity: 0; + transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); +} + +/* Dynamic width based on content */ +.notification[data-has-app="true"] { + min-width: 450px; + max-width: 650px; +} + +.notification[data-has-action="true"] { + min-width: 500px; + max-width: 700px; +} + +.notification[data-has-app="true"][data-has-action="true"] { + min-width: 550px; + max-width: 750px; +} + +.notification-show { + transform: translateY(0); + opacity: 1; +} + +.notification-hide { + transform: translateY(100%); + opacity: 0; +} + +.notification-content { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.notification-app-icon { + flex-shrink: 0; + margin-right: 12px; + width: 36px; + height: 36px; + border-radius: 6px; + overflow: hidden; + background: rgba(var(--text-rgb), 0.15); + border: 1px solid rgba(var(--text-rgb), 0.25); + display: flex; + align-items: center; + justify-content: center; +} + +.notification-app-icon img { + width: 28px; + height: 28px; + object-fit: contain; + border-radius: 4px; +} + +.notification-icon { + flex-shrink: 0; + /* Container uses align-items: flex-start so the message stays top-aligned + for multi-line text. Override here so the status icon (20px) sits + vertically centered against the 36px app icon next to it. */ + align-self: center; +} + +.notification-icon svg { + display: block; +} + +.notification-message { + flex: 1; + color: var(--text-color, #e2e8f0); + font-size: 14px; + line-height: 1.4; + margin-top: 2px; +} + +.notification-action-btn { + background: var(--primary-color, var(--accent)); + color: #ffffff; + border: none; + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + margin-left: 12px; + /* Container is `align-items: flex-start` so multi-line message text stays + anchored at the top; override here so the button lines up with the + status / app icons next to it. */ + align-self: center; +} + +.notification-action-btn:hover { + background: var(--primary-hover, #3182ce); + transform: translateY(-1px); +} + +.notification-close { + background: none; + border: none; + color: var(--text-muted, #a0aec0); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s ease; + flex-shrink: 0; + /* Same reasoning as `.notification-action-btn` above — center against the + icons rather than top-aligning with the message. */ + align-self: center; +} + +.notification-close:hover { + background: rgba(var(--text-rgb), 0.1); + color: var(--text-color, #e2e8f0); +} + +.notification-success .notification-icon { + color: #48bb78; +} + +.notification-error .notification-icon { + color: var(--status-danger); +} + +.notification-warning .notification-icon { + color: var(--status-warning); +} + +.notification-info .notification-icon { + color: var(--accent); +} + +.notification-uninstall .notification-icon { + color: var(--status-danger); +} + +/* Mobile positioning */ +@media (max-width: 768px) { + .notification-container { + bottom: 10px !important; + right: 10px !important; + left: 10px !important; + } +} + +/* Tablet positioning */ +@media (max-width: 1024px) and (min-width: 769px) { + .notification-container { + bottom: 15px !important; + right: 15px !important; + top: auto !important; + } +} + +.notification-container .notification { + pointer-events: auto; +} + +/* Hide all content (icons, text, etc.) for install, manage, and uninstall buttons when loading */ +.btn-loading.btn-install, +.btn-loading.btn-manage, +.btn-loading.btn-uninstall, +.btn-loading.manage-btn { + color: transparent !important; +} + +.btn-loading.btn-install *, +.btn-loading.btn-manage *, +.btn-loading.btn-uninstall *, +.btn-loading.manage-btn * { + opacity: 0 !important; + visibility: hidden !important; +} + +/* Show only spinner for install, manage, and uninstall buttons */ +.btn-loading.btn-install::after, +.btn-loading.btn-manage::after, +.btn-loading.btn-uninstall::after, +.btn-loading.manage-btn::after, +.app-card-actions .btn-loading.manage-btn::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + border: 2px solid transparent; + border-top: 2px solid currentColor; + border-radius: 50%; + animation: spin 1s linear infinite; + color: var(--text-primary) !important; + opacity: 1 !important; + visibility: visible !important; +} + +/* Loading text for non-install buttons */ +.btn-loading:not(.btn-install):not(.btn-manage):not(.btn-uninstall):not(.manage-btn)::before { + content: attr(data-loading-text); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: inherit; + font-size: inherit; + white-space: nowrap; +} + +.loading-initial .loading-spinner { + width: 60px; + height: 60px; + border: 4px solid rgba(var(--text-rgb), 0.1); + border-top: 4px solid var(--primary-color, var(--accent)); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 24px; +} + +.loading-initial .loading-subtitle { + font-size: 14px; + color: var(--text-secondary, #ccc); + font-weight: normal; + margin-top: 8px; + opacity: 0.8; +} + +/* Loading spinner styles */ +.loading-categories .loading-spinner, +.loading-apps .loading-spinner { + width: 20px; + height: 20px; + border: 2px solid #e3e3e3; + border-top: 2px solid var(--accent); + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 10px; +} + +/* Loading containers */ +.loading-categories, +.loading-apps { + text-align: center; + padding: 20px; + color: var(--text-color, #666); +} + +.loading-categories p, +.loading-apps p { + margin: 0; + font-size: 14px; +} + +/* Update needed and warning styles */ +.warning-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + margin-bottom: 20px; + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + color: #856404; + font-size: 14px; +} + +.warning-banner svg { + flex-shrink: 0; +} + +.warning-banner span { + flex: 1; +} + +.warning-banner .btn-small { + flex-shrink: 0; + padding: 4px 12px; + font-size: 12px; + background: var(--status-warning); + color: #212529; + border: 1px solid var(--status-warning); +} + +.warning-banner .btn-small:hover { + background: #e0a800; + border-color: #e0a800; +} + +.btn-copy.copied { + background: var(--status-success); + border-color: var(--status-success); +} + +.toggle-content { + display: flex; + flex-direction: column; + margin-left: 12px; + flex: 1; +} + +.toggle-section input[type="checkbox"] { + display: none; +} + +/* Section Dividers */ +.section-divider { + margin: 32px 0 24px 0; + padding: 16px 0; + border-bottom: 2px solid var(--border-color); +} + +.section-divider h3 { + margin: 0 0 8px 0; + color: var(--text-primary); + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.section-divider p { + margin: 0; + color: var(--text-secondary); + font-size: 14px; + font-style: italic; +} + +/* Advanced and Unused Sections */ +.advanced-sections, +.unused-sections { + margin-bottom: 24px; +} + +.advanced-section { + border-left: 4px solid #f39c12; + padding-left: 16px; + padding-right: 16px; + padding-top: 22px; +} + +.unused-section { + border-left: 4px solid #e74c3c; + padding-left: 16px; + padding-right: 16px; + opacity: 0.8; +} + +.advanced-section h3, +.unused-section h3 { + color: var(--text-primary); +} + +/* Mail Connection Test Button */ +.test-connection-btn { + background: var(--accent); + color: #ffffff; + border: 1px solid var(--accent); + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + transition: all 0.2s ease; +} + +.test-connection-btn:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.test-connection-btn:disabled { + background: var(--text-muted); + border-color: var(--text-muted); + cursor: not-allowed; +} + +.test-icon { + font-size: 16px; +} + +.test-text { + font-weight: 500; +} + +/* Test Result Display */ +.test-result { + margin-top: 8px; + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + display: block; + width: 100%; + box-sizing: border-box; +} + +.test-result.testing { + background: #fff3cd; + border: 1px solid #ffeaa7; + color: #856404; +} + +.test-result.success { + background: #d4edda; + border: 1px solid #c3e6cb; + color: #155724; +} + +.test-result.error { + background: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; +} + +/* ============================================== + TABBED INTERFACE STYLES + ============================================== */ + +/* Tabbed Interface Container */ +.tabbed-interface { + margin-top: 20px; +} + +/* Tab Navigation */ +.tab-navigation { + display: flex; + border-bottom: 1px solid rgba(var(--text-rgb), 0.10); + background: rgba(var(--text-rgb), 0.04); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-radius: 12px 12px 0px 0px; +} + +.tab-button { + background: transparent; + border: none; + padding: 12px 16px; + cursor: pointer; + border-radius: 8px 8px 0px 0px; + font-size: 14px; + font-weight: 500; + color: rgba(var(--text-rgb), 0.7); + transition: all 0.3s ease; + border-bottom: 2px solid transparent; +} + +.tab-button:hover { + color: var(--accent); + background: rgba(var(--accent-rgb), 0.1); +} + +.tab-button.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: rgba(var(--accent-rgb), 0.1); +} + +/* Flatten the .tab-button default 8px-on-both-top-corners inside any + tab container — we want only the FIRST tab's top-left and LAST + tab's top-right to follow the strip's curve. Without the reset, + the inner corner of the active tab still picks up the default + 8px and looks rounded against an unrounded sibling. */ +.tab-navigation > .tab-button, +.tabs-wrapper .tabs-list .tab-button { + border-radius: 0; +} + +/* When the first / last tab is the active one, its filled background + should follow the parent strip's 12px top-corner radius. Without + this, the active tab squares off in the corner of the strip, + leaving an L-shaped notch against the rounded chrome. */ +.tab-navigation > .tab-button:first-child, +.tab-navigation > .main-tab-button:first-child, +.tabs-wrapper .tabs-list .tab-button:first-child { + border-top-left-radius: 12px; +} + +.tab-navigation > .tab-button:last-child, +.tab-navigation > .main-tab-button:last-child, +.tabs-wrapper .tabs-list .tab-button:last-child { + border-top-right-radius: 12px; +} + +.main-tab-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 12px 20px; + background: transparent; + border: none; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary, #ccc); + transition: all 0.2s ease; + white-space: nowrap; + border-bottom: 2px solid transparent; + flex: 1; +} + +.main-tab-button:hover { + color: var(--accent); + background: rgba(var(--accent-rgb), 0.1); +} + +.main-tab-button.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: rgba(var(--accent-rgb), 0.1); +} + +.tab-button svg { + width: 16px; + height: 16px; +} + +/* Tab Content */ +.tab-content { + min-height: 400px; +} + +.tab-pane { + display: none; + padding: 20px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-top: none; + border-radius: 0px 0px 12px 12px; + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.04); +} + +.tab-pane.active { + display: block; +} + +.backups-section h3 { + color: var(--accent); + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 8px; +} + +.backups-section p { + color: var(--text-muted); + font-style: italic; +} + +/* Padding stays at 0 so .tasks-title's own 20px provides the inset — + same recipe as .services-section / .config-section. */ +.tasks-section { + display: flex; + flex-direction: column; + padding: 0; +} + +.tasks-container { + /* Make app tasks look like main tasks page */ + flex: 1; + overflow-y: auto; + padding: 16px; + margin: 16px; + background: rgba(var(--bg-rgb), 0.2); + border-radius: 8px; +} + +/* Hide scrollbar when not needed, show only when scrolling */ +.tasks-container::-webkit-scrollbar { + width: 8px; +} + +.tasks-container::-webkit-scrollbar-track { + background: transparent; +} + +.tasks-container::-webkit-scrollbar-thumb { + background: var(--input-bg); + border-radius: 4px; +} + +.tasks-container::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* ============================================== + TASK ITEM AND DETAILS STYLING + ============================================== */ + +.task-item { + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.1); + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; + transition: all 0.2s ease; + padding: 0px; +} + +.task-item:hover { + background: rgba(var(--text-rgb), 0.08); + border-color: rgba(var(--text-rgb), 0.15); + transform: translateY(-1px); +} + +.task-details { + display: none; + background: rgba(var(--bg-rgb), 0.2); + border-top: 1px solid rgba(var(--text-rgb), 0.1); + padding: 16px; +} + +.task-details.task-details-open { + display: block; +} + +.task-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.1); +} + +.meta-item { + font-size: 12px; + color: var(--text-muted); +} + +.meta-item strong { + color: var(--text-primary); + font-weight: 500; +} + +.task-id-link, +.task-app-link { + color: inherit; + text-decoration: none; +} + +.task-id-link:hover, +.task-app-link:hover { + color: var(--accent); + text-decoration: underline; +} + +.task-logs h4, +.task-output h4, +.task-error h4, +.task-running h4 { + color: var(--text-primary); + font-size: 14px; + margin: 16px 0 8px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.task-output .output-content, +.task-error .error-content { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.04); + border-radius: 10px; + padding: 12px; + font-family: 'Courier New', monospace; + font-size: 12px; + line-height: 1.4; + white-space: pre-wrap; + overflow-x: auto; +} + +.task-error .error-content { + color: var(--status-danger); + border-color: rgba(var(--status-danger-rgb), 0.3); +} + +.task-running .running-indicator { + display: flex; + align-items: center; + gap: 12px; + color: var(--status-info); + font-style: italic; +} + +.task-running .spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(23, 162, 184, 0.3); + border-top: 2px solid var(--status-info); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* ============================================== + TASK HEADER ENHANCEMENTS + ============================================== */ + +.task-header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 12px; + cursor: pointer; +} + +.task-info { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.task-app-icon { + width: 32px; + height: 32px; + border-radius: 8px; + object-fit: cover; + border: 1px solid #f0f0f03d; + background: #f8f9fa24; + padding: 3px; +} + +.task-type-icon { + font-size: 16px; + margin-left: 6px; + margin-right: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.task-title { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 400; + color: #c5c8ca; + font-size: 15px; + line-height: 1.2; +} + +.task-status { + font-size: 12px; + font-weight: 500; + padding: 4px 8px; + border-radius: 4px; + display: inline-flex; + align-items: center; + gap: 4px; + text-transform: uppercase !important; +} + +.status-queued { + background: rgba(255, 189, 46, 0.2); + color: #ffbd2e; + border: 1px solid rgba(255, 189, 46, 0.3); + text-transform: uppercase !important; +} + +.status-running { + background: rgba(40, 202, 66, 0.2); + color: #28ca42; + border: 1px solid rgba(40, 202, 66, 0.3); + text-transform: uppercase !important; +} + +.status-completed { + background: rgba(0, 255, 0, 0.2); + color: #00ff00; + border: 1px solid rgba(0, 255, 0, 0.3); + text-transform: uppercase !important; +} + +.status-failed { + background: rgba(var(--status-danger-rgb), 0.2); + color: var(--status-danger); + border: 1px solid rgba(var(--status-danger-rgb), 0.3); + text-transform: uppercase !important; +} + +.status-cancelled { + background: rgba(var(--text-rgb), 0.2); + color: var(--text-muted); + border: 1px solid rgba(var(--text-rgb), 0.3); + text-transform: uppercase !important; +} + +.task-time { + font-size: 11px; + color: var(--text-muted); + margin-left: auto; + margin-right: 8px; +} + +.task-duration { + font-size: 11px; + color: var(--text-muted); + margin-right: 8px; +} + +.task-title, +.task-time, +.task-duration { + color: var(--text-muted); +} + +.task-app-icon { + border-color: var(--border); + background: var(--surface-elevated); +} + +/* Mirrors .config-title — see config.css. */ +.tasks-title { + padding: 20px; + background: transparent; + border-bottom: 1px solid var(--border-color); + margin-bottom: 0; +} + +.tasks-title h3 { + margin: 0 0 8px 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.tasks-title p { + margin: 0; + color: var(--text-secondary, #ccc); + font-size: 13px; +} + +/* ============================================== + TASK ACTIONS STYLING + ============================================== */ + +.task-actions { + display: flex; + gap: 12px; + align-items: center; +} + +.task-btn { + background: rgba(var(--text-rgb), 0.1); + border: 1px solid rgba(var(--text-rgb), 0.2); + color: var(--text-muted); + padding: 6px 10px; + border-radius: 6px; + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 4px; +} + +.task-btn:hover { + background: rgba(var(--text-rgb), 0.2); + border-color: rgba(var(--text-rgb), 0.3); + transform: translateY(-1px); +} + +.task-btn.retry { + background: rgba(var(--status-danger-rgb), 0.1); + border-color: rgba(var(--status-danger-rgb), 0.2); + color: var(--status-danger); +} + +.task-btn.retry:hover { + background: rgba(var(--status-danger-rgb), 0.2); + border-color: rgba(var(--status-danger-rgb), 0.3); +} + +.task-btn.view-logs { + background: rgba(var(--status-info-rgb), 0.12); + border-color: rgba(var(--status-info-rgb), 0.30); + color: var(--text-primary); +} + +.task-btn.view-logs:hover { + background: rgba(var(--status-info-rgb), 0.22); + border-color: rgba(var(--status-info-rgb), 0.50); +} + +.task-btn.toggle-details { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--text-rgb), 0.18); + color: var(--text-secondary); +} + +.task-btn.toggle-details:hover { + background: rgba(var(--text-rgb), 0.12); + border-color: rgba(var(--text-rgb), 0.32); + color: var(--text-primary); +} + +.task-btn.toggle-details.expanded { + background: rgba(var(--text-rgb), 0.14); + border-color: rgba(var(--text-rgb), 0.36); + color: var(--text-primary); +} + +.task-btn.delete { + background: rgba(var(--status-danger-rgb), 0.14); + border-color: rgba(var(--status-danger-rgb), 0.40); + color: var(--text-primary); +} + +.task-btn.delete:hover { + background: rgba(var(--status-danger-rgb), 0.28); + border-color: rgba(var(--status-danger-rgb), 0.65); + color: var(--text-primary); +} + +/* Text label sitting next to the SVG inside any .task-btn (Restart, + Logs, Delete, etc.). Buttons that only carry an icon don't include + the span, so they stay icon-only. */ +.task-btn .task-btn-label { + margin-left: 4px; + font-size: 11px; + font-weight: 500; + line-height: 1; +} + +.task-btn:has(.task-btn-label) { + padding-right: 12px; +} + +/* ============================================== + TERMINAL STYLING FOR TASK LOGS + ============================================== */ + +.terminal-style { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.04); + color: var(--text-primary); + font-family: 'Courier New', 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 15px; + line-height: 1.3; + border-radius: 10px; + padding: 16px; + overflow: auto; + white-space: pre; + word-wrap: normal; + overflow-wrap: normal; + /* Browsers only honour `resize` when overflow != visible. Vertical + only — drag the bottom handle down to extend the log viewport. */ + resize: vertical; + min-height: 120px; + max-height: none; +} + +.terminal-style .log-line { + margin: 0; + padding: 0; + line-height: 1.3; + height: auto; + margin-bottom: 4px !important; +} + +.terminal-style .log-entry { + margin: 0; + padding: 0; + line-height: 1.3; + height: auto; + margin-bottom: 4px !important; +} + +.terminal-style div { + margin: 0; + padding: 0; + line-height: 1.3; + height: auto; +} + +/* Force remove any default spacing but allow gap */ +.terminal-style * { + margin: 0; + padding: 0; + line-height: 1.3; +} + +.terminal-style .log-line, +.terminal-style .log-entry { + margin-bottom: 4px !important; +} + +/* ANSI color styles for terminal */ +.terminal-style span[style*="color: green"] { + color: #00ff00 !important; +} + +.terminal-style span[style*="color: red"] { + color: var(--status-danger) !important; +} + +.terminal-style span[style*="color: yellow"] { + color: #ffd93d !important; +} + +.terminal-style span[style*="color: blue"] { + color: #6bb6ff !important; +} + +.terminal-style span[style*="color: cyan"] { + color: #4ecdc4 !important; +} + +.terminal-style span[style*="color: magenta"] { + color: #ff6ec7 !important; +} + +.terminal-style span[style*="color: white"] { + color: var(--text-primary) !important; +} + +.terminal-style span[style*="color: black"] { + color: #000000 !important; +} + +.terminal-style span[style*="background-color: black"] { + background-color: #000000 !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: red"] { + background-color: #ff0000 !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: green"] { + background-color: #00ff00 !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: yellow"] { + background-color: #ffff00 !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: blue"] { + background-color: #0000ff !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: cyan"] { + background-color: #00ffff !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: magenta"] { + background-color: #ff00ff !important; + padding: 0 2px; +} + +.terminal-style span[style*="background-color: white"] { + background-color: var(--text-primary) !important; + color: #000000 !important; + padding: 0 2px; +} + +/* Modal log viewer enhancements */ +.task-logs-modal .log-viewer.terminal-style { + max-height: 400px; + overflow-y: auto; + overflow-x: auto; + border: 1px solid var(--border-strong); + white-space: pre; + word-wrap: normal; + overflow-wrap: normal; +} + +.task-logs-modal .log-viewer.terminal-style::-webkit-scrollbar { + width: 8px; +} + +.task-logs-modal .log-viewer.terminal-style::-webkit-scrollbar-track { + background: var(--surface-elevated); +} + +.task-logs-modal .log-viewer.terminal-style::-webkit-scrollbar-thumb { + background: var(--text-secondary); + border-radius: 4px; +} + +.task-logs-modal .log-viewer.terminal-style::-webkit-scrollbar-thumb:hover { + background: #777; +} + +/* Task preview log container styling — initial height is set inline + (200px) so the user lands on a familiar size, but `resize: vertical` + from .terminal-style lets them drag the bottom handle down to grow + it. We deliberately don't set a max-height here. */ +.task-logs .log-container.terminal-style { + overflow-y: auto; + border: 1px solid var(--border); + margin: 8px 0; +} + +.task-logs .log-container.terminal-style::-webkit-scrollbar { + width: 6px; +} + +.task-logs .log-container.terminal-style::-webkit-scrollbar-track { + background: var(--surface-elevated); +} + +.task-logs .log-container.terminal-style::-webkit-scrollbar-thumb { + background: var(--text-secondary); + border-radius: 3px; +} + +.task-logs .log-container.terminal-style::-webkit-scrollbar-thumb:hover { + background: #777; +} + +.task-logs .log-entry { + margin-bottom: 0; + line-height: 1.2; +} + +.task-output .output-content.terminal-style { + max-height: 150px; + overflow-y: auto; + border: 1px solid var(--border); + margin: 8px 0; +} + +/* Update Indicator Animations */ +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes requiredFlash { + 0%, 100% { box-shadow: 0 0 0 3px rgba(var(--status-danger-rgb), 0.20); } + 50% { box-shadow: 0 0 0 6px rgba(var(--status-danger-rgb), 0.35); } +} + +/* Welcome chip styles live in css/service-buttons.css. */ + +/* ===== Mobile (≤768px) — safety net + app detail page ===== */ +@media (max-width: 768px) { + /* Keep the page from ever scrolling horizontally on mobile — + long URLs, wide tables, or rogue inline widths still scroll + inside their own containers if they need to. */ + html, body { + overflow-x: hidden; + max-width: 100%; + } + + /* Stack the app header so service buttons fall below app info. */ + .app-header { + flex-direction: column; + align-items: stretch; + padding: 16px; + gap: 16px; + } + + .app-header .app-info { + flex-direction: column; + align-items: center; + text-align: center; + gap: 12px; + padding: 0; + background: transparent; + border: none; + } + + .app-header .app-info .app-card-icon { + align-self: center; + } + + .app-header .app-details { + width: 100%; + } + + .app-header .app-details h2 { + font-size: 20px; + } + + .app-header .app-description { + font-size: 14px; + } + + .app-header .app-meta { + justify-content: center; + flex-wrap: wrap; + gap: 8px; + } + + /* Service buttons stack vertically full-width below app info. */ + .service-buttons-container { + flex-direction: column; + width: 100%; + margin-top: 0; + align-items: stretch; + } + + .service-buttons-container .service-button { + width: 100%; + justify-content: center; + } + + .service-buttons-container .service-trigger, + .service-buttons-container .service-trigger-icon { + width: 100%; + } +} diff --git a/containers/libreportal/frontend/css/tasks.css b/containers/libreportal/frontend/css/tasks.css new file mode 100644 index 0000000..5a9b155 --- /dev/null +++ b/containers/libreportal/frontend/css/tasks.css @@ -0,0 +1,885 @@ +/* Tasks page styling. Extracted from tasks-content.html so theme + overrides and edits live alongside the rest of the CSS. All + colors reference theme variables — see themes//theme.css. */ + +/* Tasks Layout - Match Apps/Config Style */ +.tasks-layout { + display: flex; + height: calc(100vh - 60px); + background: transparent; +} + +/* Sidebar Styles - Match existing LibrePortal style */ +.sidebar-container { + width: 220px; + background: var(--bg-primary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; +} + +.sidebar { + width: 220px; + height: 100%; + overflow-y: auto; +} + +.sidebar h2 { + color: var(--text-primary); + font-size: 18px; + font-weight: 600; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); +} + +.sidebar-category { + margin-bottom: 24px; + padding: 0 20px; +} + +.sidebar-category h3 { + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.sidebar-items { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sidebar-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + color: var(--text-secondary); + text-decoration: none; + border-radius: 6px; + transition: all 0.2s; + font-size: 14px; +} + +.sidebar-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.sidebar-item.active { + background: var(--accent-color); + color: white; +} + +.task-count { + margin-left: auto; + background: var(--bg-secondary); + padding: 2px 6px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + min-width: 20px; + text-align: center; +} + +.sidebar-item.active .task-count { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +/* Main Content Area */ +.main-content { + flex: 1; + display: flex; + flex-direction: column; + background: transparent; + overflow: hidden; +} + +/* Status Bar — glassy strip matching the loading-screen system-card recipe. */ +.terminal-status-bar { + background: rgba(var(--text-rgb), 0.04); + border-bottom: 1px solid rgba(var(--text-rgb), 0.08); + padding: 12px 20px; + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.status-item { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: 11px; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-queued { background: var(--status-warning); text-transform: uppercase !important; } +.status-running { background: var(--status-success); animation: pulse 1.5s infinite; text-transform: uppercase !important; } +.status-completed { background: var(--status-success); text-transform: uppercase !important; } +.status-failed { background: var(--status-danger); text-transform: uppercase !important; } + +/* Force uppercase on all task status elements */ +.task-status.status-queued, +.task-status.status-running, +.task-status.status-completed, +.task-status.status-failed, +.task-status.status-cancelled { + text-transform: uppercase !important; +} + +.status-installed { + background: var(--status-success); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.refresh-btn { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.12); + color: var(--text-secondary); + padding: 4px 10px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + margin-left: auto; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.refresh-btn:hover { + background: rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.45); + color: var(--accent); +} + +.clear-btn { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.12); + color: var(--text-secondary); + padding: 4px 10px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + margin-left: 8px; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.clear-btn:hover { + background: rgba(var(--status-danger-rgb), 0.18); + border-color: rgba(var(--status-danger-rgb), 0.50); + color: var(--status-danger); +} + +/* Tasks Terminal */ +.tasks-terminal { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.tasks-list { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +/* Hide scrollbar when not needed, show only when scrolling */ +.tasks-list::-webkit-scrollbar { + width: 8px; +} + +.tasks-list::-webkit-scrollbar-track { + background: transparent; +} + +.tasks-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.tasks-list::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); +} + +/* Hide scrollbar by default, show only on hover or when content overflows */ +.task-highlighted { + border: 2px solid var(--accent); + background: var(--accent-soft); +} + +.task-details-open { + display: block !important; +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +/* Task Items — glass tiles in the loading-screen system-card style: + light translucent fill, soft white border, inset top highlight, hover + lifts the card with a cyan glow. */ +.task-item { + background: rgba(var(--text-rgb), 0.05); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 12px; + margin-bottom: 10px; + overflow: hidden; + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.06); + transition: background 0.2s ease, border-color 0.2s ease, + transform 0.2s ease, box-shadow 0.2s ease; +} + +.task-item:hover { + background: rgba(var(--text-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.40); + transform: translateY(-2px); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.10); +} + +.task-header { + padding: 4px 16px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; +} + +.task-info { + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} + +.task-title { + font-size: 12px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.3; +} + +.task-status { + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +/* Task-status PILL — glass tinted tag, matches the button language. + Running/completed use a bright mint (#86efac) instead of the theme + --status-success (#28a745) which reads as muddy olive against the + nebula gradient. Same treatment used on the setup wizard's valid + border + the apps "Installed" pill. */ +.task-status.status-queued { + background: rgba(var(--status-warning-rgb), 0.22); + border: 1px solid rgba(var(--status-warning-rgb), 0.60); + color: #fcd34d; +} +.task-status.status-running { + background: rgba(var(--status-success-rgb), 0.35); + border: 1px solid rgba(var(--status-success-rgb), 0.70); + color: #86efac; +} +.task-status.status-completed { + background: rgba(var(--status-success-rgb), 0.35); + border: 1px solid rgba(var(--status-success-rgb), 0.70); + color: #86efac; +} + +/* Services persist when they're running; the pulse only makes sense + for transient task state, so disable it on service rows. The .status-running + class (line ~134) sets animation: pulse on anything that wears it — this + overrides for service pills. Task pills still pulse. */ +.service-item .task-status.status-running { + animation: none; +} +.task-status.status-failed { + background: rgba(var(--status-danger-rgb), 0.22); + border: 1px solid rgba(var(--status-danger-rgb), 0.60); + color: #fca5a5; +} + +.task-command { + color: var(--status-success); + font-family: 'Courier New', monospace; + font-size: 11px; + flex: 1; +} + +.task-time { + color: var(--text-muted); + font-size: 10px; + margin-right: 8px; +} + +.task-actions { + display: flex; + gap: 6px; +} + +.task-btn { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.12); + color: var(--text-secondary); + padding: 3px 8px; + border-radius: 6px; + cursor: pointer; + font-size: 10px; + display: flex; + align-items: center; + gap: 3px; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.task-btn:hover { + background: var(--surface-hover); + color: var(--status-success); + border-color: var(--status-success); +} + +.task-btn.retry:hover { + background: var(--status-warning); + color: #000; + border-color: var(--status-warning); +} + +.task-btn.delete:hover { + background: var(--status-danger); + color: var(--text-on-accent); + border-color: var(--status-danger); +} + +.task-details { + border-top: 1px solid rgba(var(--text-rgb), 0.10); + background: transparent; + padding: 14px 16px 4px; + display: none; +} + +.task-details.show { + display: block; +} + +.task-output { + padding: 12px; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-muted); + font-family: 'Courier New', monospace; + font-size: 12px; + line-height: 1.4; + max-height: 200px; + overflow-y: auto; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .sidebar-container { + position: fixed; + left: -220px; + top: 0; + height: 100vh; + z-index: 1000; + transition: left 0.3s ease; + } + + .sidebar-container.mobile-open { + left: 0; + } + + .main-content { + margin-left: 0; + } + + .mobile-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + display: none; + } + + .mobile-overlay.active { + display: block; + } +} + +/* Loading Categories */ +.loading-categories { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px; + color: var(--text-secondary); + font-size: 12px; +} + +.loading-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(var(--text-rgb), 0.3); + border-top: 2px solid var(--text-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* Task metadata strip. style.css turns .task-meta into a grid of + auto-fit columns, so the items wrap horizontally instead of stacking. + Uses a dark-tint overlay (not a light-tint) so the white labels and + values pop against the strip on nebula's gradient — the previous + rgba(text, 0.10) lifted the strip towards white and washed out the + text it was supposed to highlight. */ +.task-meta { + background: rgba(var(--bg-rgb), 0.30); + border-radius: 10px; + padding: 12px 16px; + margin-bottom: 14px; + border: 1px solid rgba(var(--text-rgb), 0.10); +} + +/* Bump label/value contrast inside the metadata strip — the global + .meta-item uses --text-muted (65% alpha on nebula) which reads as + dim grey. --text-secondary (82%) keeps the hierarchy vs the white + labels but is actually readable. */ +.task-meta .meta-item { + color: var(--text-secondary); +} + +/* Soften the log/output terminal box. The .log-container default is + var(--surface-sunken) — on nebula that's rgba(0,0,0,0.22) which on + top of the cosmic dark stack reads as pitch black and feels foreign + to the rest of the glass UI. Anchor it to nebula's navy chrome with + moderate opacity so the gradient still bleeds through faintly. */ +.task-logs .log-container.terminal-style, +.task-output .output-content.terminal-style { + background: rgba(15, 25, 50, 0.45); + border: 1px solid rgba(var(--text-rgb), 0.10); +} + +.meta-item { + display: flex; + align-items: baseline; + justify-content: center; + gap: 6px; + padding: 2px 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta-item > strong { + flex-shrink: 0; +} + +.meta-item > a { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.meta-item code { + background: var(--code-bg); + color: var(--code-text); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; +} + +.task-duration { + background: var(--accent-soft); + color: var(--accent); + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; +} + +.task-logs { + margin-bottom: 16px; +} + +.task-logs h4 { + margin: 0 0 12px 0; + color: var(--accent); + font-size: 14px; + font-weight: 600; +} + +.log-container { + background: var(--surface-sunken); + border-radius: 8px; + padding: 12px; + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--border-subtle); +} + +.log-entry { + display: flex; + align-items: flex-start; + padding: 4px 0; + border-bottom: 1px solid var(--border-subtle); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-timestamp { + color: var(--text-muted); + margin-right: 12px; + white-space: nowrap; + font-size: 11px; +} + +.task-output h4, +.task-error h4 { + margin: 0 0 8px 0; + font-size: 14px; + font-weight: 600; +} + +.task-output h4 { + color: var(--status-success); +} + +.output-content, +.error-content { + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.04); + border-radius: 10px; + padding: 12px; + margin: 0; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + line-height: 1.4; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.error-content { + background: rgba(var(--status-danger-rgb), 0.1); + border-color: rgba(var(--status-danger-rgb), 0.3); + color: var(--status-danger); +} + +/* Original "task is running…" placeholder panel styling. The + .task-running class is also used as a JS state marker on buttons + and tab buttons (see app-tabbed-manager.js / apps-manager.js); the + :not(...) chain keeps those out so they don't suddenly grow 20px + of padding (and visibly jump taller) when a task starts. */ +.task-running:not(button):not(.tab-button):not(.btn):not(.task-btn) { + text-align: center; + padding: 20px; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(var(--status-warning-rgb), 0.3); + border-top: 2px solid var(--status-warning); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.info-content { + text-align: center; + color: var(--text-muted); + padding: 20px; + font-style: italic; +} + +/* Modal Styles */ +.task-logs-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +/* These rules are scoped to .task-logs-modal so they don't override the + generic modal styling in modal.css used by every other modal. */ +.task-logs-modal .modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); +} + +.task-logs-modal .modal-content { + position: relative; + background: var(--bg-secondary); + border-radius: 12px; + width: 90%; + max-width: 800px; + max-height: 80vh; + overflow: hidden; + box-shadow: var(--card-shadow-hover); + border: 1px solid var(--border-subtle); +} + +.task-logs-modal .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--border-subtle); + background: rgba(var(--text-rgb), 0.05); +} + +.task-logs-modal .modal-header h3 { + margin: 0; + color: var(--text-primary); + font-size: 18px; + font-weight: 600; +} + +.task-logs-modal .modal-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.2s ease; +} + +.task-logs-modal .modal-close:hover { + background: var(--surface-hover); + color: var(--text-primary); +} + +.task-logs-modal .modal-body { + padding: 24px; + max-height: calc(80vh - 80px); + overflow-y: auto; +} + +.task-info-summary { + background: rgba(var(--text-rgb), 0.05); + border-radius: 8px; + padding: 16px; + margin-bottom: 20px; + border: 1px solid var(--border-subtle); +} + +.info-row { + display: flex; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.info-row:last-child { + border-bottom: none; +} + +.info-row code { + background: var(--code-bg); + color: var(--code-text); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + margin-left: 8px; +} + +.logs-section, +.output-section, +.error-section { + margin-bottom: 20px; +} + +.logs-section h4, +.output-section h4, +.error-section h4 { + margin: 0 0 12px 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.log-viewer, +.output-viewer, +.error-viewer { + background: var(--surface-sunken); + border-radius: 8px; + padding: 16px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + line-height: 1.4; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + border: 1px solid var(--border-subtle); + max-height: 300px; + overflow-y: auto; +} + +.log-viewer { + max-height: 400px; +} + +.log-line { + display: flex; + align-items: flex-start; + padding: 4px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.log-line:last-child { + border-bottom: none; +} + +.log-line .timestamp { + color: var(--text-muted); + margin-right: 12px; + white-space: nowrap; + font-size: 11px; + min-width: 140px; +} + +.log-line .message { + color: var(--text-primary); + flex: 1; + word-break: break-word; +} + +.error-viewer { + background: rgba(var(--status-danger-rgb), 0.1); + border-color: rgba(var(--status-danger-rgb), 0.3); + color: var(--status-danger); +} + +/* Button enhancements */ +.task-btn.view-logs { + background: var(--accent-soft); + color: var(--accent); +} + +.task-btn.view-logs:hover { + background: rgba(var(--accent-rgb), 0.3); +} + +/* Responsive */ +@media (max-width: 768px) { + .modal-content { + width: 95%; + max-height: 90vh; + } + + .modal-header, + .modal-body { + padding: 16px; + } + + .log-line { + flex-direction: column; + gap: 4px; + } + + .log-line .timestamp { + min-width: auto; + } + + /* Task + service rows: stack the row so info, status, and actions + no longer fight for horizontal space. */ + .task-header { + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 10px 12px; + } + + .task-info { + flex-wrap: wrap; + gap: 8px; + } + + .task-title { + flex: 1 1 100%; + word-break: break-word; + } + + .task-actions { + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; + } + + /* Status bar: compress padding, allow refresh/clear to wrap below. */ + .terminal-status-bar { + padding: 10px 12px; + gap: 8px; + } + + .refresh-btn, + .clear-btn { + margin-left: 0; + } + + /* Task metadata strip: stack key/value pairs vertically. */ + .task-meta { + padding: 10px 12px; + } + + /* Services row container: trim outer margins so cards reach edge. */ + .services-rows { + margin: 10px; + padding: 10px; + } +} diff --git a/containers/libreportal/frontend/css/themes.css b/containers/libreportal/frontend/css/themes.css new file mode 100644 index 0000000..f2e16ea --- /dev/null +++ b/containers/libreportal/frontend/css/themes.css @@ -0,0 +1,331 @@ +/* ============================================================ + Cross-component rules. + + Per-theme palettes live in frontend/themes//theme.css and + are loaded dynamically by js/system/theme-registry.js (plus the + inline bootstrap in index.html for the current theme on first + paint). Anything in this file should consume tokens via + var(--token) so it adapts to whichever theme is active. + ============================================================ */ + +/* Danger zone banner: red-tinted glass card with a solid red left edge. + Same recipe as the Traefik "not installed" notice but tinted with + var(--status-danger). Reads cleanly on every theme thanks to alpha + overlays driven by --status-danger-rgb. */ +.danger-zone-section { + margin-top: 14px; + margin-bottom: 14px; + padding: 20px 24px; + background: rgba(var(--status-danger-rgb), 0.10); + border: 1px solid rgba(var(--status-danger-rgb), 0.30); + border-left: 4px solid var(--status-danger); + border-radius: 12px; + color: var(--text-primary); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.06); +} + +.danger-zone-header { + margin-bottom: 20px; + text-align: center; +} + +.danger-zone-header h3 { + margin: 0 0 8px 0; + color: var(--status-danger); + font-size: 20px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.danger-zone-header p { + margin: 0; + color: var(--text-secondary); + font-size: 14px; + font-style: italic; +} + +/* Header-only variant used as a page-level banner (e.g. config?=features). + Without inner content below the header, the header's bottom margin + is wasted whitespace, and the section needs its own bottom margin + to separate from whatever follows. */ +.danger-zone-section--header-only { + margin-bottom: 24px; +} + +.danger-zone-section--header-only .danger-zone-header { + margin-bottom: 0; +} + +.danger-zone-section--header-only .danger-zone-header p { + margin-top: 4px; +} + +/* Inline variant — same recipe as .danger-zone-section, smaller padding. */ +.danger-zone-banner { + background: rgba(var(--status-danger-rgb), 0.10); + border: 1px solid rgba(var(--status-danger-rgb), 0.30); + border-left: 4px solid var(--status-danger); + border-radius: 12px; + padding: 14px 18px; + margin-bottom: 18px; + display: flex; + align-items: flex-start; + gap: 12px; + color: var(--text-primary); + box-shadow: inset 0 1px 0 rgba(var(--text-rgb), 0.06); +} + +.danger-zone-content { + display: flex; + align-items: center; + gap: 12px; +} + +.danger-zone-icon { + color: var(--status-danger); + flex-shrink: 0; + font-size: 20px; +} + +.danger-zone-text { + color: var(--text-primary); + font-size: 14px; + line-height: 1.4; +} + +.danger-zone-text strong { + color: var(--status-danger); + align-items: flex-start; +} + +/* Solid status / accent buttons — the default look used by dark-blue + and light. Nebula overrides these below to get the welcome-button + gradient + glow recipe. */ +.install-btn, +.btn-install, +.app-card .install-btn { + background: var(--status-success) !important; + color: #ffffff !important; + border: 1px solid var(--status-success) !important; +} + +.install-btn:hover:not(:disabled), +.btn-install:hover:not(:disabled), +.app-card .install-btn:hover:not(:disabled) { + background: var(--status-success-hover) !important; + border-color: var(--status-success-hover) !important; +} + +.uninstall-btn, +.btn-uninstall { + background: var(--status-danger) !important; + color: #ffffff !important; + border: 1px solid var(--status-danger) !important; +} + +.uninstall-btn:hover:not(:disabled), +.btn-uninstall:hover:not(:disabled) { + background: var(--status-danger-hover) !important; + border-color: var(--status-danger-hover) !important; +} + +.manage-btn, +.btn-manage, +.btn-primary, +.app-card .manage-btn, +.app-card-actions .manage-btn { + background: var(--accent) !important; + color: var(--text-on-accent) !important; + border: 1px solid var(--accent) !important; +} + +.manage-btn:hover:not(:disabled), +.btn-manage:hover:not(:disabled), +.btn-primary:hover:not(:disabled), +.app-card .manage-btn:hover:not(:disabled), +.app-card-actions .manage-btn:hover:not(:disabled) { + background: var(--accent-hover) !important; + border-color: var(--accent-hover) !important; +} + +/* "Back to Apps" — same solid pill as Update Configuration (btn-manage), + but in amber so it reads as a distinct secondary action. */ +.config-actions .btn-secondary, +.console-actions .btn-secondary { + background: var(--status-warning) !important; + color: #1a1200 !important; + border: 1px solid var(--status-warning) !important; +} + +.config-actions .btn-secondary:hover:not(:disabled), +.console-actions .btn-secondary:hover:not(:disabled) { + background: #e0a800 !important; + border-color: #e0a800 !important; +} + +/* ------------------------------------------------------------------ + Nebula-only: outline + tint buttons — copied from the .service-button + "Welcome" recipe (rgba(, 0.10) bg + rgba(, 0.30) + border + neutral text). Topbar pills use that exact alpha for the + light feel; in-content CTAs (Install/Manage/Uninstall) bump the + alphas a bit and add a subtle coloured outer glow so they feel + weightier without becoming solid. + ------------------------------------------------------------------ */ + +/* --- Topbar pills (light, transparent) --------------------------- */ +[data-theme="nebula"] .topbar .donate-btn { + background: rgba(var(--accent-rgb), 0.10) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--accent-rgb), 0.30) !important; + box-shadow: none !important; + text-shadow: none; + font-weight: 600; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease, box-shadow 0.2s ease !important; +} + +[data-theme="nebula"] .topbar .donate-btn:hover:not(:disabled) { + background: rgba(var(--accent-rgb), 0.20) !important; + border-color: rgba(var(--accent-rgb), 0.55) !important; + transform: translateY(-1px); +} + +[data-theme="nebula"] .topbar .logout-btn { + background: rgba(var(--status-danger-rgb), 0.10) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--status-danger-rgb), 0.30) !important; + box-shadow: none !important; + text-shadow: none; + font-weight: 600; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease, box-shadow 0.2s ease !important; +} + +[data-theme="nebula"] .topbar .logout-btn:hover:not(:disabled) { + background: rgba(var(--status-danger-rgb), 0.20) !important; + border-color: rgba(var(--status-danger-rgb), 0.55) !important; + transform: translateY(-1px); +} + +/* --- Topbar active nav (App Center / etc.) — translucent pill ---- + Replaces the solid var(--primary-color) fill so the active page + indicator matches the Donate / Logout pill style. */ +[data-theme="nebula"] .topbar-nav .nav-item.nav-active, +[data-theme="nebula"] .topbar-nav .nav-item.active { + background: rgba(var(--accent-rgb), 0.18) !important; + color: var(--text-primary) !important; + border-color: rgba(var(--accent-rgb), 0.50) !important; +} + +[data-theme="nebula"] .topbar-nav .nav-item.nav-active:hover, +[data-theme="nebula"] .topbar-nav .nav-item.active:hover { + background: rgba(var(--accent-rgb), 0.28) !important; + color: var(--text-primary) !important; + border-color: rgba(var(--accent-rgb), 0.70) !important; +} + +/* --- In-content CTAs (more solid than the topbar but still + transparent — alpha set above the body's accent-glow so the + brand colour reads clearly against Nebula's gradient.) ------- */ +[data-theme="nebula"] .install-btn, +[data-theme="nebula"] .btn-install, +[data-theme="nebula"] .app-card .install-btn { + background: rgba(var(--status-success-rgb), 0.55) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--status-success-rgb), 0.90) !important; + text-shadow: none; + font-weight: 600 !important; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease !important; +} + +[data-theme="nebula"] .install-btn:hover:not(:disabled), +[data-theme="nebula"] .btn-install:hover:not(:disabled), +[data-theme="nebula"] .app-card .install-btn:hover:not(:disabled) { + background: rgba(var(--status-success-rgb), 0.70) !important; + border-color: rgba(var(--status-success-rgb), 1.00) !important; + transform: translateY(-1px); +} + +/* "Open" button on installed app cards — same success recipe. */ +[data-theme="nebula"] .service-trigger-icon { + background: rgba(var(--status-success-rgb), 0.35) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--status-success-rgb), 0.65) !important; +} + +[data-theme="nebula"] .service-trigger:hover .service-trigger-icon, +[data-theme="nebula"] .service-trigger.open .service-trigger-icon { + background: rgba(var(--status-success-rgb), 0.50) !important; + border-color: rgba(var(--status-success-rgb), 0.85) !important; +} + +[data-theme="nebula"] .uninstall-btn, +[data-theme="nebula"] .btn-uninstall, +[data-theme="nebula"] .btn-danger { + background: rgba(var(--status-danger-rgb), 0.35) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--status-danger-rgb), 0.65) !important; + text-shadow: none; + font-weight: 600 !important; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease !important; +} + +[data-theme="nebula"] .uninstall-btn:hover:not(:disabled), +[data-theme="nebula"] .btn-uninstall:hover:not(:disabled), +[data-theme="nebula"] .btn-danger:hover:not(:disabled) { + background: rgba(var(--status-danger-rgb), 0.50) !important; + border-color: rgba(var(--status-danger-rgb), 0.85) !important; + transform: translateY(-1px); +} + +[data-theme="nebula"] .manage-btn, +[data-theme="nebula"] .btn-manage, +[data-theme="nebula"] .btn-primary, +[data-theme="nebula"] .app-card .manage-btn, +[data-theme="nebula"] .app-card-actions .manage-btn { + background: rgba(var(--accent-rgb), 0.35) !important; + color: var(--text-primary) !important; + border: 1px solid rgba(var(--accent-rgb), 0.65) !important; + text-shadow: none; + font-weight: 600 !important; + transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease !important; +} + +[data-theme="nebula"] .manage-btn:hover:not(:disabled), +[data-theme="nebula"] .btn-manage:hover:not(:disabled), +[data-theme="nebula"] .btn-primary:hover:not(:disabled), +[data-theme="nebula"] .app-card .manage-btn:hover:not(:disabled), +[data-theme="nebula"] .app-card-actions .manage-btn:hover:not(:disabled) { + background: rgba(var(--accent-rgb), 0.50) !important; + border-color: rgba(var(--accent-rgb), 0.85) !important; + transform: translateY(-1px); +} + +[data-theme="nebula"] .install-btn:disabled, +[data-theme="nebula"] .btn-install:disabled, +[data-theme="nebula"] .uninstall-btn:disabled, +[data-theme="nebula"] .btn-uninstall:disabled, +[data-theme="nebula"] .manage-btn:disabled, +[data-theme="nebula"] .btn-manage:disabled, +[data-theme="nebula"] .btn-primary:disabled, +[data-theme="nebula"] .btn-danger:disabled, +[data-theme="nebula"] .topbar .donate-btn:disabled, +[data-theme="nebula"] .topbar .logout-btn:disabled { + opacity: 0.50; + cursor: not-allowed; +} + +/* Warning banner — amber tint that reads in every theme. */ +.warning-banner { + background: rgba(var(--status-warning-rgb), 0.12); + border: 1px solid rgba(var(--status-warning-rgb), 0.35); + color: var(--text-primary); +} + +/* Nebula: the cyan accent is bright enough that a dark glyph (the + theme's text-on-accent = #0a1426) still reads, but white reads + better with the rest of nebula's white-text-on-glass system. */ +[data-theme="nebula"] .tooltip::after { + color: #ffffff; +} diff --git a/containers/libreportal/frontend/css/tools.css b/containers/libreportal/frontend/css/tools.css new file mode 100644 index 0000000..c87df61 --- /dev/null +++ b/containers/libreportal/frontend/css/tools.css @@ -0,0 +1,632 @@ +/* + Tools tab — mirrors the Services tab visual structure (.task-item, + .task-header, .task-info, .task-actions) plus a generic input modal + for tools that need user inputs. +*/ + +.tools-section { padding: 0; } + +.tools-title { + padding: 20px; + background: transparent; + border-bottom: 1px solid var(--border-color); + margin-bottom: 0; +} + +.tools-title h3 { + margin: 0 0 8px 0; + color: var(--text-primary, #fff); + font-size: 18px; + font-weight: 600; +} + +.tools-title p { + margin: 0; + color: var(--text-secondary, #ccc); + font-size: 13px; +} + +.tools-list { display: flex; flex-direction: column; } + +.tools-rows { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 1rem 1.25rem 2rem; +} + +.tools-cat-pane { display: none; } +.tools-cat-pane.active { display: flex; } + +.tools-tab-bar { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0.75rem 1.25rem 0; + border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); + margin-bottom: 0.25rem; +} + +.tools-tab { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: transparent; + border: 1px solid transparent; + border-bottom: none; + color: var(--text-secondary, #a0a0a0); + padding: 0.45rem 0.85rem; + font-size: 13px; + font-weight: 500; + border-radius: 6px 6px 0 0; + cursor: pointer; + transition: color 120ms ease, background 120ms ease, border-color 120ms ease; + position: relative; + bottom: -1px; +} + +.tools-tab:hover { + color: var(--text-primary, #fff); + background: rgba(255, 255, 255, 0.04); +} + +.tools-tab.active { + color: var(--text-primary, #fff); + background: var(--surface-color, rgba(255, 255, 255, 0.06)); + border-color: var(--border-color, rgba(255, 255, 255, 0.08)); + border-bottom-color: var(--surface-color, rgba(255, 255, 255, 0.06)); +} + +.tools-tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.4em; + padding: 0 0.4em; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary, #ccc); + font-size: 11px; + font-weight: 600; + line-height: 1.5; +} + +.tools-tab.active .tools-tab-count { + background: var(--accent-color, #6c63ff); + color: #fff; +} + +.tools-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 2rem; + color: var(--text-secondary, var(--text-muted)); +} + +.tools-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(var(--text-rgb), 0.15); + border-top-color: var(--accent-color, var(--accent)); + border-radius: 50%; + animation: tools-spin 0.7s linear infinite; +} + +@keyframes tools-spin { to { transform: rotate(360deg); } } + +.tools-empty { + text-align: center; + padding: 2.5rem 1rem; + color: var(--text-secondary, var(--text-muted)); +} + +.tools-empty-icon { + font-size: 2rem; + display: block; + margin-bottom: 0.5rem; +} + +/* Tool row -------------------------------------------------------- */ + +/* Mirror .task-item shell from style.css so tool rows visually match + task rows, but use a horizontal flex layout so the action button + stays vertically centered across the whole row regardless of + description length. */ +.tool-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; +} + +.tool-text { flex: 1 1 auto; min-width: 0; } + +.tool-head { + display: flex; + align-items: center; + gap: 8px; +} + +.tool-icon { font-size: 18px; line-height: 1; } + +.tool-title { + color: var(--text-primary, #fff); + font-size: 14px; + font-weight: 600; +} + +.tool-desc { + margin: 4px 0 0 0; + color: var(--text-secondary, var(--text-muted)); + font-size: 12px; + line-height: 1.4; +} + +.tool-action { flex: 0 0 auto; display: flex; align-items: center; } + +/* Matches the .task-btn.delete look (translucent fill + colored border) + but bigger and green. The delete button uses bootstrap red var(--status-danger); + we use bootstrap green var(--status-success) for parity. */ +.tool-run-btn { + background: rgba(var(--status-success-rgb), 0.12); + border: 1px solid rgba(var(--status-success-rgb), 0.3); + color: var(--status-success); + padding: 10px 22px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.2px; + min-width: 96px; + cursor: pointer; + transition: all 0.2s ease; +} + +.tool-run-btn:hover { + background: rgba(var(--status-success-rgb), 0.22); + border-color: rgba(var(--status-success-rgb), 0.45); + transform: translateY(-1px); +} + +.tool-run-btn:active { transform: translateY(0); } + +.tool-run-btn.destructive { + background: rgba(var(--status-danger-rgb), 0.1); + border-color: rgba(var(--status-danger-rgb), 0.3); + color: var(--status-danger); +} + +.tool-run-btn.destructive:hover { + background: rgba(var(--status-danger-rgb), 0.22); + border-color: rgba(var(--status-danger-rgb), 0.45); +} + +/* Tool modal ------------------------------------------------------ */ + +/* Center the modal vertically + horizontally. Mirrors the gluetun-modal + trick: the inline `style="display: block"` set in JS triggers this + selector, which overrides to flexbox so the content sits in the + middle of the viewport regardless of its height. */ +.tool-modal { + position: fixed; + inset: 0; + z-index: 1100; +} +.tool-modal[style*="display: block"] { + display: flex !important; + align-items: center; + justify-content: center; +} + +/* Global .modal-body sets padding: 0 (it's used for full-bleed + content like the readme iframe). The tool modal's form needs + real breathing room around it — match the gluetun modal's + padding so the picker cards don't sit flush against the edges. + overflow:hidden so any inner scrollable region (e.g. the URL + list in app_urls_multi) is the *only* thing that scrolls, + not the whole modal body. */ +.tool-modal .modal-body { + padding: 20px; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; +} +.tool-modal .tool-form { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} +.tool-modal .tool-form .form-group-app-urls, +.tool-modal .tool-form .form-group:has(.app-urls-multi) { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + margin-bottom: 0; + gap: 0; +} +.tool-modal .app-urls-multi { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + gap: 0; +} + +/* Single bordered shell that holds the search row + URL list as one + visual unit, mirroring the framing the rest of the WebUI uses. */ +.app-urls-container { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + background: rgba(var(--text-rgb), 0.02); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; + overflow: hidden; +} +/* Title bar at the top of the picker container — replaces the old + floating .form-label so the field reads as one cohesive unit. */ +.app-urls-title { + padding: 10px 14px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + background: rgba(var(--accent-rgb), 0.08); + border-bottom: 1px solid rgba(var(--accent-rgb), 0.20); + letter-spacing: 0.2px; + flex-shrink: 0; +} +.app-urls-title .required-mark { color: var(--status-danger); margin-left: 2px; } + +.app-urls-header { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid rgba(var(--text-rgb), 0.06); + background: rgba(var(--bg-rgb), 0.12); + flex-shrink: 0; +} +.tool-modal .modal-footer { + padding: 16px 20px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: stretch; + gap: 12px; +} +.tool-modal .modal-footer .btn { + flex: 1 1 0; +} + +.tool-modal-confirm { + background: rgba(var(--status-warning-rgb), 0.12); + border: 1px solid rgba(var(--status-warning-rgb), 0.4); + color: var(--status-warning); + padding: 10px 12px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 14px; +} + +.tool-form .form-group { margin-bottom: 14px; } +.tool-form .form-group:last-child { margin-bottom: 0; } + +.tool-form .form-label { + display: block; + margin-bottom: 6px; + color: var(--text-primary, #fff); + font-size: 13px; + font-weight: 500; +} + +.tool-form .required-mark { color: var(--status-danger); } + +/* installed_apps_multi — visually mirrors gluetun country picker. */ +.installed-apps-multi { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Framed card holding the search input + bulk-action buttons, same + treatment as .gluetun-search-card. */ +.installed-apps-search-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 12px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; +} +.installed-apps-search-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(var(--text-rgb), 0.04); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 8px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.installed-apps-search-row:focus-within { + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12); +} +.installed-apps-search-icon { + color: rgba(var(--text-rgb), 0.55); + flex-shrink: 0; +} +.installed-apps-search { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text-primary); + font-size: 14px; + padding: 2px 0; +} +.installed-apps-actions { + display: flex; + gap: 8px; +} +.installed-apps-actions .btn { flex: 1 1 0; } + +/* Grid of selectable apps, matching .gluetun-country-list. */ +.installed-apps-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 6px 14px; + max-height: 45vh; + overflow-y: auto; + padding: 4px 2px; + background: transparent; + border: none; +} +.installed-apps-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 10px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} +.installed-apps-item:hover { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--accent-rgb), 0.25); +} +.installed-apps-item:has(input:checked) { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.45); +} + +.installed-apps-item input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + flex-shrink: 0; + cursor: pointer; + border-radius: 5px; + background: rgba(var(--text-rgb), 0.04); + border: 1.5px solid rgba(var(--text-rgb), 0.18); + box-shadow: inset 0 0 0 1px rgba(var(--text-rgb), 0.02); + position: relative; + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.12s ease; + margin: 0; +} +.installed-apps-item:hover input[type="checkbox"] { + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.08); +} +.installed-apps-item input[type="checkbox"]:focus-visible { + outline: none; + border-color: rgba(var(--accent-rgb), 0.85); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.20); +} +.installed-apps-item input[type="checkbox"]:checked { + background: linear-gradient(135deg, var(--accent), var(--accent)); + border-color: rgba(var(--accent-rgb), 0.9); + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.35); +} +.installed-apps-item input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: center; + background-size: 13px 13px; + animation: installedAppsCheckPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); +} +@keyframes installedAppsCheckPop { + 0% { transform: scale(0.4); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +.installed-apps-icon { + width: 22px; + height: 22px; + border-radius: 5px; + object-fit: contain; + flex-shrink: 0; +} +.installed-apps-name { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} +.installed-apps-multi-empty { + padding: 24px; + text-align: center; + color: rgba(var(--text-rgb), 0.6); + font-size: 13px; +} + +/* app_urls_multi — flat task-style list. One URL per row, no per-app + grouping. Each row is slim (icon + label + URL inline + checkbox) + and visually echoes the .task-item shell from style.css. */ +.app-urls-list { + display: flex !important; + flex-direction: column; + gap: 4px; + grid-template-columns: none !important; + padding: 8px 10px; + background: transparent; + border: none; + /* Fill available space inside the container and scroll only this + region — overrides the inherited .installed-apps-list max-height + which was sized for the wide-grid layout. */ + flex: 1; + min-height: 0; + max-height: none; + overflow-y: auto; +} +.app-urls-loading { + padding: 18px; + text-align: center; + color: rgba(var(--text-rgb), 0.55); + font-size: 13px; + font-style: italic; +} + +.app-url-row.installed-apps-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + min-height: 34px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.06); + border-radius: 6px; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; + line-height: 1; +} +.app-url-row:hover { + background: rgba(var(--text-rgb), 0.06); + border-color: rgba(var(--accent-rgb), 0.25); +} +.app-url-row:has(input:checked) { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.40); +} + +/* Slim flat row checkbox — ~⅓ smaller than the wide-grid gluetun + style, no glow. */ +.app-url-row input[type="checkbox"] { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; + align-self: center; +} +.app-url-row input[type="checkbox"]:checked { + box-shadow: none; +} +.app-url-row input[type="checkbox"]:checked::after { + background-size: 8px 8px; +} + +.app-url-icon { + width: 28px; + height: 28px; + border-radius: 6px; + object-fit: contain; + flex-shrink: 0; + align-self: center; +} + +.app-url-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-primary); + font-weight: 500; + white-space: nowrap; + line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; +} +.app-url-sep { + color: rgba(var(--text-rgb), 0.35); + font-weight: 400; + margin: 0 2px; +} + +/* User list modal — opens after a list_users tool task completes. */ +.user-list { display: flex; flex-direction: column; gap: 6px; } +.user-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.08); + border-radius: 8px; +} +.user-row:hover { background: rgba(var(--text-rgb), 0.05); } +.user-row-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.user-row-primary { font-size: 14px; font-weight: 500; color: var(--text-primary); } +.user-row-secondary { font-size: 12px; color: rgba(var(--text-rgb), 0.55); font-family: ui-monospace, "SF Mono", Menlo, monospace; } +.user-row-roles { + display: inline-flex; + align-self: flex-start; + margin-top: 2px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 2px 6px; + border-radius: 3px; + background: rgba(var(--accent-rgb), 0.10); + border: 1px solid rgba(var(--accent-rgb), 0.25); + color: var(--accent); +} + +.user-row-actions { display: flex; gap: 4px; flex-shrink: 0; } +.user-row-btn { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + background: transparent; + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 5px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; + color: rgba(var(--text-rgb), 0.85); +} +.user-row-btn:hover { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.40); +} +.user-row-btn.danger:hover { + background: rgba(var(--status-danger-rgb), 0.12); + border-color: rgba(var(--status-danger-rgb), 0.45); +} +.user-row-roles.is-admin { + background: rgba(var(--status-warning-rgb), 0.12); + border-color: rgba(var(--status-warning-rgb), 0.30); + color: var(--status-warning); +} + +/* Toggle inside a tool form — match form-group spacing so it sits flush + with siblings. .form-group:last-child rule already handles the bottom. */ +.tool-form > .tool-form-toggle { margin-bottom: 14px; } +.tool-form > .tool-form-toggle:last-child { margin-bottom: 0; } diff --git a/containers/libreportal/frontend/css/topbar.css b/containers/libreportal/frontend/css/topbar.css new file mode 100644 index 0000000..b7bbf02 --- /dev/null +++ b/containers/libreportal/frontend/css/topbar.css @@ -0,0 +1,257 @@ + + +/* Topbar layout, nav pills, donate/logout buttons. Extracted from style.css. Theme via var(--*) tokens. */ + +/* Topbar */ +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 24px; + height: 60px; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + background: var(--bg-primary, #1a1a1a); + border-bottom: 1px solid var(--border-color, #444); + backdrop-filter: blur(12px) saturate(140%); + -webkit-backdrop-filter: blur(12px) saturate(140%); +} + +.topbar-left { + display: flex; + align-items: center; + flex: 0 0 auto; +} + +.topbar .logo { + font-size: 20px; + font-weight: 600; +} + +.topbar .donate-btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + background: var(--primary-color); + color: var(--text-primary); + cursor: pointer; + transition: background 0.2s; + font-weight: 600; +} + +.topbar .logout-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid rgba(var(--status-danger-rgb), 0.3); + border-radius: 6px; + background: rgba(var(--status-danger-rgb), 0.08); + color: var(--status-danger); + cursor: pointer; + font-size: 0.8rem; + font-weight: 500; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.topbar .logout-btn:hover { + background: rgba(var(--status-danger-rgb), 0.18); + border-color: rgba(var(--status-danger-rgb), 0.5); +} + +.topbar-nav { + display: flex; + gap: 8px; + align-items: center; +} + +.topbar-nav .nav-item { + background: rgba(var(--text-rgb), 0.1); + border: 1px solid rgba(var(--text-rgb), 0.2); + border-radius: 8px; + padding: 12px 16px; + font-size: 14px; + font-weight: 500; + color: var(--text-muted); + text-decoration: none; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + cursor: pointer; + min-height: 44px; + white-space: nowrap; +} + +.topbar-nav .nav-item:hover { + background: rgba(var(--text-rgb), 0.2); + transform: translateY(-1px); +} + +.topbar-nav .nav-item.active { + background: var(--primary-color); + color: var(--text-primary); + border-color: var(--primary-color); +} + +.topbar-nav .nav-item.nav-active { + background: var(--primary-color); + color: var(--text-primary); + border-color: var(--primary-color); +} + +.topbar-nav .nav-item.nav-active:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.topbar-nav .nav-item svg { + width: 16px; + height: 16px; +} + +/* Disabled state used while a system-wide task (e.g. config_update) is running + so the user can't navigate to App Center / Config mid-flight and act on + stale data. */ +.topbar-nav .nav-item.nav-item-disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: auto; /* keep cursor styling, but the JS click handler returns early */ +} + +.topbar-nav .nav-item.nav-item-disabled:hover { + background: transparent; +} + +.topbar-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.mobile-drawer { + display: flex; + align-items: center; + flex: 1; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.mobile-drawer-page-section { + display: none; +} + +@media (max-width: 768px) { + .topbar { + padding: 0 12px; + gap: 8px; + justify-content: flex-start; + } + + .topbar-left { + flex: 0 0 auto; + } + + .mobile-drawer { + position: fixed; + top: 60px; + left: 0; + width: 100vw; + height: calc(100vh - 60px); + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 0; + padding: 16px; + background: var(--surface-bg-solid, #1a1a1a); + border-right: 1px solid var(--border-color, #444); + box-shadow: 6px 0 24px rgba(0, 0, 0, 0.35); + overflow-y: auto; + overscroll-behavior: contain; + transform: translateX(-100%); + transition: transform 0.3s ease; + z-index: 101; + } + + .mobile-drawer.mobile-open { + transform: translateX(0); + } + + .mobile-drawer .topbar-nav { + flex-direction: column; + align-items: stretch; + gap: 6px; + width: 100%; + } + + .mobile-drawer .topbar-nav .nav-item { + width: 100%; + justify-content: flex-start; + padding: 12px 14px; + } + + .mobile-drawer-page-section { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(var(--text-rgb), 0.12); + } + + .mobile-drawer-page-section:empty { + display: none; + } + + .mobile-drawer .topbar-controls { + flex-direction: column; + align-items: stretch; + gap: 8px; + width: 100%; + margin-top: auto; + padding-top: 16px; + padding-bottom: 16px; + border-top: 1px solid rgba(var(--text-rgb), 0.12); + position: sticky; + bottom: -16px; + flex-shrink: 0; + background: var(--surface-bg-solid, #1a1a1a); + z-index: 1; + } + + .mobile-drawer .topbar-controls .custom-select, + .mobile-drawer .topbar-controls .donate-btn, + .mobile-drawer .topbar-controls .logout-btn { + width: 100%; + justify-content: center; + } + + .mobile-drawer .topbar-controls .custom-select-button { + width: 100%; + justify-content: space-between; + } +} + +/* Compact custom-select for the topbar theme switcher. The default + .custom-select-button is form-input sized (12px 16px padding, + 14px font), too tall for the 60px topbar. */ +.topbar-controls .custom-select { + width: auto; + min-width: 110px; +} + +.topbar-controls .custom-select-button { + padding: 6px 12px; + font-size: 13px; + border-radius: 6px; + min-height: 0; + line-height: 1.2; +} + +.topbar-controls .custom-select-popup { + min-width: 140px; +} diff --git a/containers/libreportal/frontend/html/app-content.html b/containers/libreportal/frontend/html/app-content.html new file mode 100755 index 0000000..d451637 --- /dev/null +++ b/containers/libreportal/frontend/html/app-content.html @@ -0,0 +1,96 @@ + +
+ +
+ + + + + +
+
+ +
+ + +
+
+ + + + + +
+ + +
+ +
+
+ +
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
Loading…
+
+ + Open backup center → +
+
+
+
+
+ +
+
+
+
diff --git a/containers/libreportal/frontend/html/apps-content.html b/containers/libreportal/frontend/html/apps-content.html new file mode 100755 index 0000000..16d0004 --- /dev/null +++ b/containers/libreportal/frontend/html/apps-content.html @@ -0,0 +1,71 @@ + +
+ +
+ + +
+ + + + + +
+
+ +
+
+
+ Loading applications... +
+
+ Discovering the perfect applications for you... +
+
+
+
+
+ diff --git a/containers/libreportal/frontend/html/apps-unified-layout.html b/containers/libreportal/frontend/html/apps-unified-layout.html new file mode 100755 index 0000000..25fea05 --- /dev/null +++ b/containers/libreportal/frontend/html/apps-unified-layout.html @@ -0,0 +1,240 @@ + +
+ +
+ + + + + +
+ +
+
+ +
+
+
+ Loading applications... +
+
+ Discovering the perfect applications for you... +
+
+
+
+ + +
+
+ +
+ + +
+
+ + + + + + +
+ + +
+ +
+
+ +
+
+

Loading configuration...

+
+ +
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+

💾 Backups

+

Snapshots for this app across all configured repositories.

+
+
+
Loading…
+
+ + Open backup center → +
+
+
+
+
+ + +
+
+
+

📋 Task Management

+

Tasks for this application - Monitor and manage application tasks

+
+
+ +
+
+

Loading tasks...

+
+ +
+
+
+
+
+
+
+
+ diff --git a/containers/libreportal/frontend/html/backup-content.html b/containers/libreportal/frontend/html/backup-content.html new file mode 100644 index 0000000..5967183 --- /dev/null +++ b/containers/libreportal/frontend/html/backup-content.html @@ -0,0 +1,173 @@ +
+
+ + + +
+
+
+ +
+ + +
+
+
+
+
+

Per-app status

+ Latest backup per app on this host +
+
+
+
+
+

Locations

+ Active destinations +
+
+
+
+
+ +
+
+ + +
+
+ + + + + + + + + + + + +
AppHostLocationWhenIDActions
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+

Restore backup

+ +
+
+ +
+
+ +
+
+
+

Delete backup

+ +
+
+ +
+
+ +
+
+
+

Backup engine details

+ +
+
+ +
+
+ +
+
+
+

Add a backup location

+ +
+
+ +
+
diff --git a/containers/libreportal/frontend/html/config-content.html b/containers/libreportal/frontend/html/config-content.html new file mode 100755 index 0000000..ab1d25f --- /dev/null +++ b/containers/libreportal/frontend/html/config-content.html @@ -0,0 +1,21 @@ + +
+ +
+ + + + + +
+
+ +
+
+
diff --git a/containers/libreportal/frontend/html/dashboard-content.html b/containers/libreportal/frontend/html/dashboard-content.html new file mode 100755 index 0000000..5501f81 --- /dev/null +++ b/containers/libreportal/frontend/html/dashboard-content.html @@ -0,0 +1,53 @@ + + +
+ + +
+
+
+
0
+
Installed Apps
+
+
+
+
+
+
0%
+
+
+
+
Disk Used
+
+
+
+
+
+ OS: + Loading... +
+
+ Uptime: + Loading... +
+
+ Memory: + Loading... +
+
+ +
+
+ + + +
diff --git a/containers/libreportal/frontend/html/tasks-content.html b/containers/libreportal/frontend/html/tasks-content.html new file mode 100755 index 0000000..ba364c5 --- /dev/null +++ b/containers/libreportal/frontend/html/tasks-content.html @@ -0,0 +1,198 @@ + +
+ +
+ + + + + +
+ +
+
+ + Queued: 0 +
+
+ + Running: 0 +
+
+ + Completed: 0 +
+
+ + Failed: 0 +
+
+ + +
+
+ + +
+
+ +
+
+

Loading tasks...

+

Please wait while we fetch your tasks

+
+
+
+
+
+ diff --git a/containers/libreportal/frontend/html/topbar.html b/containers/libreportal/frontend/html/topbar.html new file mode 100755 index 0000000..3a1380a --- /dev/null +++ b/containers/libreportal/frontend/html/topbar.html @@ -0,0 +1,71 @@ + +
+ +
+ +
+ +
diff --git a/containers/libreportal/frontend/icons/apps/adguard.svg b/containers/libreportal/frontend/icons/apps/adguard.svg new file mode 100644 index 0000000..f6118fc --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/adguard.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/authelia.svg b/containers/libreportal/frontend/icons/apps/authelia.svg new file mode 100644 index 0000000..9880b3b --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/authelia.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/bookstack.svg b/containers/libreportal/frontend/icons/apps/bookstack.svg new file mode 100644 index 0000000..a6ad581 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/bookstack.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/crowdsec.svg b/containers/libreportal/frontend/icons/apps/crowdsec.svg new file mode 100644 index 0000000..fd4ffac --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/crowdsec.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/frontend/icons/apps/dashy.svg b/containers/libreportal/frontend/icons/apps/dashy.svg new file mode 100644 index 0000000..ce68744 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/dashy.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/frontend/icons/apps/default.svg b/containers/libreportal/frontend/icons/apps/default.svg new file mode 100644 index 0000000..343d2de --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/default.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/focalboard.svg b/containers/libreportal/frontend/icons/apps/focalboard.svg new file mode 100644 index 0000000..b78e7e3 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/focalboard.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/gitea.svg b/containers/libreportal/frontend/icons/apps/gitea.svg new file mode 100644 index 0000000..11c6df8 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/gitea.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/apps/gluetun.svg b/containers/libreportal/frontend/icons/apps/gluetun.svg new file mode 100644 index 0000000..a39521c --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/gluetun.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/containers/libreportal/frontend/icons/apps/grafana.svg b/containers/libreportal/frontend/icons/apps/grafana.svg new file mode 100644 index 0000000..54be1e2 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/grafana.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/frontend/icons/apps/headscale.svg b/containers/libreportal/frontend/icons/apps/headscale.svg new file mode 100644 index 0000000..06f406a --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/headscale.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/invidious.svg b/containers/libreportal/frontend/icons/apps/invidious.svg new file mode 100644 index 0000000..80e78a4 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/invidious.svg @@ -0,0 +1,2 @@ + + diff --git a/containers/libreportal/frontend/icons/apps/ipinfo.svg b/containers/libreportal/frontend/icons/apps/ipinfo.svg new file mode 100644 index 0000000..656169c --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/ipinfo.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/jellyfin.svg b/containers/libreportal/frontend/icons/apps/jellyfin.svg new file mode 100644 index 0000000..0e56a50 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/jellyfin.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/jitsimeet.svg b/containers/libreportal/frontend/icons/apps/jitsimeet.svg new file mode 100644 index 0000000..5a3526a --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/jitsimeet.svg @@ -0,0 +1,650 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/frontend/icons/apps/libreportal.svg b/containers/libreportal/frontend/icons/apps/libreportal.svg new file mode 100644 index 0000000..a476796 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/libreportal.svg @@ -0,0 +1,605 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/apps/linkding.svg b/containers/libreportal/frontend/icons/apps/linkding.svg new file mode 100644 index 0000000..089630d --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/linkding.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/mastodon.svg b/containers/libreportal/frontend/icons/apps/mastodon.svg new file mode 100644 index 0000000..dd5075e --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/mastodon.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/nextcloud.svg b/containers/libreportal/frontend/icons/apps/nextcloud.svg new file mode 100644 index 0000000..336aff5 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/nextcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/apps/ollama.svg b/containers/libreportal/frontend/icons/apps/ollama.svg new file mode 100644 index 0000000..6bba73a --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/ollama.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/onlyoffice.svg b/containers/libreportal/frontend/icons/apps/onlyoffice.svg new file mode 100644 index 0000000..364522c --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/onlyoffice.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/owncloud.svg b/containers/libreportal/frontend/icons/apps/owncloud.svg new file mode 100644 index 0000000..cf650c7 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/owncloud.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/pihole.svg b/containers/libreportal/frontend/icons/apps/pihole.svg new file mode 100644 index 0000000..5bda461 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/pihole.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/portainer.svg b/containers/libreportal/frontend/icons/apps/portainer.svg new file mode 100644 index 0000000..45cf83a --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/portainer.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/prometheus.svg b/containers/libreportal/frontend/icons/apps/prometheus.svg new file mode 100644 index 0000000..309d704 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/prometheus.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/searxng.svg b/containers/libreportal/frontend/icons/apps/searxng.svg new file mode 100644 index 0000000..2ddf53b --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/searxng.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/speedtest.svg b/containers/libreportal/frontend/icons/apps/speedtest.svg new file mode 100644 index 0000000..2fd0d2b --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/speedtest.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/traefik.svg b/containers/libreportal/frontend/icons/apps/traefik.svg new file mode 100644 index 0000000..a86b9b7 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/traefik.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/trilium.svg b/containers/libreportal/frontend/icons/apps/trilium.svg new file mode 100644 index 0000000..2ecb6e4 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/trilium.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/unbound.svg b/containers/libreportal/frontend/icons/apps/unbound.svg new file mode 100644 index 0000000..cfc5d8d --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/unbound.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/vaultwarden.svg b/containers/libreportal/frontend/icons/apps/vaultwarden.svg new file mode 100644 index 0000000..41ca105 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/vaultwarden.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/apps/wireguard.svg b/containers/libreportal/frontend/icons/apps/wireguard.svg new file mode 100644 index 0000000..b778001 --- /dev/null +++ b/containers/libreportal/frontend/icons/apps/wireguard.svg @@ -0,0 +1 @@ + diff --git a/containers/libreportal/frontend/icons/categories/all.svg b/containers/libreportal/frontend/icons/categories/all.svg new file mode 100755 index 0000000..c54a5c3 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/all.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/containers/libreportal/frontend/icons/categories/communication.svg b/containers/libreportal/frontend/icons/categories/communication.svg new file mode 100755 index 0000000..6cf99aa --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/communication.svg @@ -0,0 +1,3 @@ + + + diff --git a/containers/libreportal/frontend/icons/categories/development.svg b/containers/libreportal/frontend/icons/categories/development.svg new file mode 100755 index 0000000..c3edcd6 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/development.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/categories/installed.svg b/containers/libreportal/frontend/icons/categories/installed.svg new file mode 100755 index 0000000..0f77db6 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/installed.svg @@ -0,0 +1,3 @@ + + + diff --git a/containers/libreportal/frontend/icons/categories/knowledge.svg b/containers/libreportal/frontend/icons/categories/knowledge.svg new file mode 100755 index 0000000..3942809 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/knowledge.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/containers/libreportal/frontend/icons/categories/media.svg b/containers/libreportal/frontend/icons/categories/media.svg new file mode 100755 index 0000000..b4e81a3 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/media.svg @@ -0,0 +1,3 @@ + + + diff --git a/containers/libreportal/frontend/icons/categories/misc.svg b/containers/libreportal/frontend/icons/categories/misc.svg new file mode 100755 index 0000000..4036d79 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/misc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/containers/libreportal/frontend/icons/categories/monitoring.svg b/containers/libreportal/frontend/icons/categories/monitoring.svg new file mode 100644 index 0000000..d8cb64b --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/monitoring.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/categories/networking.svg b/containers/libreportal/frontend/icons/categories/networking.svg new file mode 100755 index 0000000..705ad1d --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/networking.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/categories/productivity.svg b/containers/libreportal/frontend/icons/categories/productivity.svg new file mode 100755 index 0000000..42e8225 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/productivity.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/categories/recommended.svg b/containers/libreportal/frontend/icons/categories/recommended.svg new file mode 100644 index 0000000..66262ad --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/recommended.svg @@ -0,0 +1,3 @@ + + + diff --git a/containers/libreportal/frontend/icons/categories/security.svg b/containers/libreportal/frontend/icons/categories/security.svg new file mode 100755 index 0000000..ee389fb --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/security.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/categories/storage.svg b/containers/libreportal/frontend/icons/categories/storage.svg new file mode 100755 index 0000000..d798892 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/storage.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/categories/system.svg b/containers/libreportal/frontend/icons/categories/system.svg new file mode 100755 index 0000000..5aaaef4 --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/system.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/categories/utils.svg b/containers/libreportal/frontend/icons/categories/utils.svg new file mode 100755 index 0000000..b6a135a --- /dev/null +++ b/containers/libreportal/frontend/icons/categories/utils.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/config/backup.svg b/containers/libreportal/frontend/icons/config/backup.svg new file mode 100755 index 0000000..78447f5 --- /dev/null +++ b/containers/libreportal/frontend/icons/config/backup.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/config/features.svg b/containers/libreportal/frontend/icons/config/features.svg new file mode 100755 index 0000000..42e8225 --- /dev/null +++ b/containers/libreportal/frontend/icons/config/features.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/config/general.svg b/containers/libreportal/frontend/icons/config/general.svg new file mode 100755 index 0000000..79c2536 --- /dev/null +++ b/containers/libreportal/frontend/icons/config/general.svg @@ -0,0 +1,3 @@ + + + diff --git a/containers/libreportal/frontend/icons/config/network.svg b/containers/libreportal/frontend/icons/config/network.svg new file mode 100755 index 0000000..705ad1d --- /dev/null +++ b/containers/libreportal/frontend/icons/config/network.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/containers/libreportal/frontend/icons/config/security.svg b/containers/libreportal/frontend/icons/config/security.svg new file mode 100644 index 0000000..58f460c --- /dev/null +++ b/containers/libreportal/frontend/icons/config/security.svg @@ -0,0 +1,4 @@ + + + + diff --git a/containers/libreportal/frontend/icons/config/webui.svg b/containers/libreportal/frontend/icons/config/webui.svg new file mode 100755 index 0000000..69d15b4 --- /dev/null +++ b/containers/libreportal/frontend/icons/config/webui.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/containers/libreportal/frontend/icons/favicon.ico b/containers/libreportal/frontend/icons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..622f2d3b8283a74729862a1279d05e8db8c2c8e4 GIT binary patch literal 87483 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

3w+mQ{uOKS$Hp1i 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/airvpn.svg b/containers/libreportal/frontend/icons/vpn/airvpn.svg new file mode 100644 index 0000000..4d92e2b --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/airvpn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/cyberghost.png b/containers/libreportal/frontend/icons/vpn/cyberghost.png new file mode 100644 index 0000000000000000000000000000000000000000..eed763b6adae5386fea5c4ea9ccbe779942e29c7 GIT binary patch literal 1147 zcmV->1cdvEP)Px(B}qgAd zjoZAwejM}$?>*)QK^%BEGv}VU-+R98o*U-$gJ778M3Q*YMG>#&Tn=vK*k~fjCPk!@ z)RB5c9ob8Y$zYO1+;7WxGMPipk|zXuWX(A;kEAr)E6SzA)y>dM>d9yl<CWljH02+jk^cKaqo}^Pma6PyjDm#>G&q&QDiv@S?%`e zNf+uiMWQG#0mFxMNA|!}4Ea436LY#^{rp&5+uedfv?5AL8=ZhLE4*Gmde9sFzUzV0TOxJg zSWMhz0p09&e|1+2{PuG?=x*6{PIfYmmbS&|&5#)~_-d)ayaz8N^XwG~h+*YMz&8&igM0e3C&zf$o5a4HOUSRHdG zbdukiP~@83ztYbAg;PI%nRL^c2O;xy3fWf@jn62sXj%eAy_&CB8iQb2Yh4)1i06fX zXi{afNRltp2uOTaGZ#?X*hA;GwZgaG7$_@% zobq!&Q33*3#rUG8u>gid5^@1OW%j`4!uBX%*iMPEMj1CuI2wfah@t9}6o0Sogls7n1l=KK1lhcnFtR-UD!I4aJDBbXS zRag*oL^+9fax7m;8XOtXAIsz3)r-j}67?RunmKzp-Sf6yPT%?K`Ex=DblmIp1^NI0 N002ovPDHLkV1n*b7~uc_ literal 0 HcmV?d00001 diff --git a/containers/libreportal/frontend/icons/vpn/expressvpn.svg b/containers/libreportal/frontend/icons/vpn/expressvpn.svg new file mode 100644 index 0000000..508f1a9 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/expressvpn.svg @@ -0,0 +1 @@ +ExpressVPN \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/fastestvpn.png b/containers/libreportal/frontend/icons/vpn/fastestvpn.png new file mode 100644 index 0000000000000000000000000000000000000000..96719ef45267275188eb2e32f7c96e8590610392 GIT binary patch literal 112317 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 EKY9IVx&QzG literal 0 HcmV?d00001 diff --git a/containers/libreportal/frontend/icons/vpn/giganews.png b/containers/libreportal/frontend/icons/vpn/giganews.png new file mode 100644 index 0000000000000000000000000000000000000000..182556c77338e0b4c45f5e8cbdf87d92a0e0c756 GIT binary patch literal 2301 zcmVePJ_ACpG(_me5|B;Z~L21KOBVziS0NDAtb=B-~XfHN7(H@yej~dQX2gMjH^18 zS6@en-{bDQ!TnD(+-(ehI3kX_#Nw_Hts`e}w7>55ccintBmo!L2KxMeRsv1|B|4vJ z^m`lR>kdI*FzSInLcHH7szjs-wIYdbySk}uXG+C>X{Dan#o{J1xi-A>`HfWEmvgHx zFyDBCjB{EEp`BnvDJ2Mw1pT{k_yw3p8ka7tqv=<1hZ}6|=^PCmVj4t23BM1qj}Z(< zMEHbagk~2V%c*Uc=qer#)OggN4?4}}w7u=5mdf&x^B!mBp{Eb*-j2p(uuir1Cb7EA zG!)24F!cq&7y=LC5Cg!rO-gemtlT1IsxD-rM9|!2I_{i~#>00~DeY}QfkH_sl}t7h z8C~1E`S%+OX~?eq@-#xfijo^Br5#fuxOdl~-D+TEbc_t7^H5lZU<9pi9s;oJEDKAg zDb&u9Dy$OA7_V#LA6ZW1^*2-!E2&wGy)oM1w(@%Y`J?V$bF{zdWb=ZI3fDz``AtYJ zr~f&O7Zz~m?ewI@8TbB zF}QP69NpU%?R}R{t4F{22}*L>m+GCI$=gVvdEG(YXrP-qX$w>WX&Vv;CIifJg~IE< zfXYRN_dLSzCKxuri~-%SN#|oFKc0kbH}l&694Nh1vzi?mk%CpqUQ=%uFDJKxhJ? z=^)aioEnAaY^>!=%)JtDw7bRLt$)(FwTX9oOVH{I5&@0_XHH|juukE%*ARAz;q85b z+n#tksb6^+VcBT; zB}Bf4Fw+k=7(iY?L4%aQEZP(6N&_?X0|B52h8~@-?{IkQ7O`1mzTG2J zu(1}a7=ezZ@@UF@xJ3gjAO%7f=xGzZQY5J_pw|{bNl2wmQ2##wKpcZNlC%#7j7A5H zJ(p5Zv9Nlc)TBsmAUTS7G<_a>YM@()ToJKU0~vvx{-F#!krV{3OXrJCl2#Wb7YSQE zlF10;f#()*BE%YK8d}c5YMcc%MbG6)!XF60Fd&x|l&b|w%XMTN)4zX6|IRiji^vc0 z2W|4Ef}DUNU_O~kX1+*a?L|-#X1<8*On(>v-7v6nS)6(uyM7LZz}x@>NqxHFbmH!Ltt2lY?;hTmio_LBez&49`$gyw%Gg6 zChae7G1|TZ+lGu)hyVB3Fw*VMdYAXM*(1DhNVGoeqQDJx~yhN7P^b2_okp z>MMwP4e_P@w5mG(fsYy;fQ(RKZPpWX>qk_91QjZRPM^+n6T=iNter=%))0jPhkum9fp7PonDx5ItSrnG4m`@Xu1PG5; zav}!67&&ecY)dpFOLqAJXc|n1F!g5Q1Q@Qb5M!UC;Q@}h>~-)o297~aL+E)V`dxyX|AthKWalOf zcOjKPF@xchE7H`I@S8iLITYNgG=u19ppb#0s6(ZUVdwH6rv#3z(a@&K@wV>~yG`hn2-~|9 zx-Oae8I0Tlk()5xZ9~t4@DU|65}1rh4i5?L+=kF2X|-V(z&Jq$F=22BnsNz7J7lyz zl?5neF?8K{uhzJB6Az|@Zi~PllhPEV1&ArQU1S&# z8ch&t*3+$=%QU@%QS6heNSxv(bE_Y6n7$4@31D2j@~arwQc8Xxg!rov$3-qQkzGEu zVOQ!`Wu$NTEnke?wir)bkwh+O&brg=HSXfe+sVxr1*Ep2d5kjJA zE<)d8`IY$jKh5r%4}_?HeB1d>VX~`hT4C+-5t?4}+e0_r-qYgI)G_RIBb6ySX{AW% zCYsG`1h*Y9Z5M+bEUVA_yrfjzWJP_*t@d>yBqxjb#0>L<{pGjs3ZSH-X%`E@SE_jb^{>v``S*8EWFU_?^prKo$qFVpW zE#AF8z^_cfuuFcCYX@ASi-ckB0001YNkl \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/ivpn.png b/containers/libreportal/frontend/icons/vpn/ivpn.png new file mode 100644 index 0000000000000000000000000000000000000000..b6ca7cd1fc6eca1ab97210235c5492523b921791 GIT binary patch literal 1375 zcmV-l1)%zgP)C0001KP)t-sDJd@h zGejdPE=wvg$T2=9DK5(~KRhZiIVv#NF+fi%G94%`RVy;XF+Cb7E$uQw^D;xVFFg1& zLoF#UGb%5rFFVjNKb0;yTq`q+EjQ;fLVztcMk+C`FFSB7HHIxWo-R6MEHrs7Hlo5A zBLDydu}MThRCt{2nc0%6Fc3g7*s?^z77*D4{{JIybZV4zzyz`8;a1MnJdBg2*Q7f- zj>TfJSS%Kc#bU8oEEdaO0w#lUo*Rw(V0-Yn!6@%G`9R*WDxYRrUR}<5-M~wOVyZ%S z9WP&*>%W=gR{f<6Mxqyh7l*74PRDhMa8VE7*XYfxvQ7@=SucR&+>*n4>S<8`5}q^@ z52fv>e@&>|lI?{ss@<@0M%0=Ltslk^lBAk=xFG|IuJ4W{mRnsY=U-XQ^4FUL(L zPd}wschen9sQ8(~CV7Jx9fl;81x%SdpE9E(U>%B0JV^@@Pqt9jvwiz15Fe=+N#zZh zaB)W=C1=M9ZDjKV70I7d{dOFJU~8etZX(iObUe3+ro}1FFZ@#yHgYv6v zVURyj3if^Ej=UW0u(7n{jM zaiJoU^vsomBAfPf2*6RPQ8@3L(6Xf{E$j2vYEU)-O0rW+-?rgho}-{Bx0fPw=qN82 zkNBpONE)gfRqGF{CP0qX8`!(2m@;G|GSIlo1EN1b+Xdo2SbvNvI72?G_nZO`0gy&n zh3ldj@kEGXv3>iCk{gv91;IVqi<9sGGQhqZxC4`X6a*eweMjtkB6}>`+>R0)OhF*<;bP`i@whX*jEej(ch$NdoZQ@I~va_#i7d@Yz7u z``ULu(pNFC_9z-pxEA`-_NqKQUGhO-_7?sPmhXdI7ymYt+Vm0f)1MhYgyo zeGlzb9~2+F1%Awe50d^54Iq+DxevbF59lCcfH3|x;!EUb#3xS>y8y%=Z*Q?!EEbE! hVzF2(7K`OS;vciiOKQwkfLs6o002ovPDHLkV1h{|c%}dV literal 0 HcmV?d00001 diff --git a/containers/libreportal/frontend/icons/vpn/mullvad.svg b/containers/libreportal/frontend/icons/vpn/mullvad.svg new file mode 100644 index 0000000..1e17241 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/mullvad.svg @@ -0,0 +1 @@ +Mullvad \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/nordvpn.svg b/containers/libreportal/frontend/icons/vpn/nordvpn.svg new file mode 100644 index 0000000..76b5df5 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/nordvpn.svg @@ -0,0 +1 @@ +NordVPN \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/privado.png b/containers/libreportal/frontend/icons/vpn/privado.png new file mode 100644 index 0000000000000000000000000000000000000000..25eee7ab5c04c358cd3fc45d7f491f8747aab36c GIT binary patch literal 15086 zcmdU#&x>V8702%|LUuZc8`IsddwNF6MhFO^f%Ir7>Fv0@(Fnn$phLe*zzBdg2h`D>wvi_Zq48v<7@IuIh%JA)|%spS3;Wa0KmUo-qoW_59v%KQIIf?n zq8k`s?Xq`U>tn1-oQdtz@t*5lmc4t%d-CA;=*Kl5Zi{z|bBT@HinTZoR;$lmCr`fo z#m}$5`O-H^{n0BwC@pi;z4k2f3Y*%$Nn6g^cn%kB!}axF{$l;^yZ>D8Q~vbFKNO#K z_!2pF@bXQ#m-9K#)yWTHn>oX)7jO52HDt05b6fq0^ZhhF?Bb5izr6EKsl>m{4LP^- za@8013xB|HZPs4Xhnh3Xc%41Lej8i3kykT5NgqApK%rfd-8YxYS>2KE*#>bmAFvN! z=G>E|__r}fULO{8F84XpOV61W6MLC=yN-{&IepJDS58cDom0NF@m$(=7V7hTwuj)6 z8)EsafrEBv&1wBA0j>WqhaMcokuM_V892yNmCNnY`+@gr$j#oV;8Nu2nASP)@sEOC z`uJiVAxmXV8OHLWPjL=h%EEcL?A@Kp`#YU0uv^@AtbJN+=Gf-7+(%9v{87g_$FvmY zvp#*e<~%BQu}cp83IB5+JZv)8`jh3xyL|i>YV?VR_&r}( zb1z)a-0Z`Fx>V3@ZTC2vW34RjU_xsFN{&E1zSm|5K$up-;ZkO^+j`DX5w*5ADD=z%mgNeVq zm@~$5&T{m7B|mps`K3K*_mr`|xCVcHNw|I&oA_tF+)p}_Y%iX@+)>x_nf(UCbz_I! z9J?hR_58lAKCQA~+3)0CvAGn_jAtLL-t#u^7kG~K1^w)cc(pCxBb&Jr9yx=TW0Ym} zFUOO<9F#?FDfZ?Eve?n?k}qP?^6u^ZR!3JH?PfjrICj1D0WRQB>Guk2Ee_aJbj){b zv@sd^kKcjC$gLb{Df;8S=D2}<-2y-D$yXc3L=MR1zIy%+>)oFxnb2+VdO4-cES_$oyTy^zu)QC-(j=wC&U6=^)p}Rj2p3pn{kI56%6LkHRj049ew8WoYq{wJgOA z87lhJmJfOv6B)*NZbK(Mb4IrFf0w@B_Iv=_y}%dvum^J1c8`0zmbov;@Adr_k9zN9 z?CAqs*>^e6?#dn3v%GyUjRA3A;2w8{>-}HMws!(tDA+S^V{G)( zvwog2%QM@2$o8`@ZSKeedHwRtF8QxcX`hn2Ue#QhJy)|0c#->Ufgd#&#-5Xm(dpm# z|L^pnoas3G9UB{aPKUsF!HzjY~#b~`yII60sW@ux25upBi8sPdu}Z`{`c0rp>$;+y}{n)~-A=hrAE1|JV3QUb)D69K&gA4emjGK?e>QQ$J{H zE$?r~U}PC>6T@7x59%_P3p(g4n_IbsZoNBp7Tk z>wRq+*EnbkU2P!4cw3PZvg*l$wzcD3G3KY|uQJ#&j_wO<;I#RU990|msV=f`5jVBv z`O7;XzkkyD%!d{Sd+=0;F*0(=F;He|>-(qJ=Z;%rPYVt{(8qV}icO1MPuh$+Qe55;&BFfHX%bFj44)$e(-#BV z;1s{=9-;HIpFAoReimd|v!JJ3@uBW7{W1+!T(1$A} z+h>mT-~9SlB|c!393$7NUFE?N100@%#y#^iC((x+GQ_r3TA Ue8G3$_=bA6$I}QIWYIzQe|>zB&;S4c literal 0 HcmV?d00001 diff --git a/containers/libreportal/frontend/icons/vpn/private-internet-access.svg b/containers/libreportal/frontend/icons/vpn/private-internet-access.svg new file mode 100644 index 0000000..d48eca5 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/private-internet-access.svg @@ -0,0 +1 @@ +Private Internet Access \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/privatevpn.png b/containers/libreportal/frontend/icons/vpn/privatevpn.png new file mode 100644 index 0000000000000000000000000000000000000000..afc14d276788710b32df59376ec7131460727f39 GIT binary patch literal 15086 zcmeHOX;f9$6~1cbnwqqYjVnz<(xyYQvXZW@rmIO?vzn$!nkeW4hbGmiNgNW5isBR% z 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 VT+Ot~-%(g$nb#ae@Ehy({s%Np%*_A* literal 0 HcmV?d00001 diff --git a/containers/libreportal/frontend/icons/vpn/protonvpn.svg b/containers/libreportal/frontend/icons/vpn/protonvpn.svg new file mode 100644 index 0000000..7d3c936 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/protonvpn.svg @@ -0,0 +1 @@ +Proton VPN \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/purevpn.png b/containers/libreportal/frontend/icons/vpn/purevpn.png new file mode 100644 index 0000000000000000000000000000000000000000..1d07f73928a6846025b426289f3c31f11149796e GIT binary patch literal 1505 zcmV<71s?i|P) z3s96*6vw4;bjEBV6Gu(cMorCE5A(Sqph1S_`!xtC3TkPZVwR6FH6K$6Ng^p)ra?Z& zWHgN=!bk>4#00`%BoZxU2zJ@!wLJFpKOfx7-F1P*(L3`SnBDK5^Z(90=iIYxTC>JD zhd7OO4tWCf0j|Ka0UhWKI<$EdtHG%U*bWjvJ}`jt2K3-2_z`RZqaTVGHUg%A^S}tm z0W6>xoCXs>y9agN4@3f!Jx98yJ|owsKPNYraB}M$j{n2>Gu!8iK?vy93df)FIgbQa zfJHSpzVkkMy~o!yqhAco9~?)%!+xQKFC~G?*xeYM~M z80_eqj|Q2xj^BLlq~>tRtG`j;h_q%|JUo@funzZVn9y-S1;Q3AYdop%OFfEW(D`sQqC z-IRR#(Dy&P4tE6^DE$3WIqlSznbyQttp_0NnI=b7WJ+pa!AuSs#qrl~=MnNZ> zw*`lVy2-u1MW)v$+;VK*H21D_x-(Eu2R0iiHmZX1vTCTJ%tBuQ2o?py7Z z+te|{gN`gf2lPM-kw2P>mUN@iIjc1zI~MCHE5%HXEEX4ks!|GgR}nC)|2b7aKIqE= ze1I9ygsumXd@1~}Rz{oW6>3I43eeNFM8^b_8!dEbt5FI-NlF2823{bYv+N0_U_1+0 zD^yMF8p(Al%WlK$f25)4kTNnO$y!=D>D9;;gUo>y?te5Tpd5IzfM6lO&1F9=d^t&O z;4+d~zs#T+Z*JAr5XD56%RO5b6miqCdjzV008j(SwNn^MFhOou3H4k3ilxD0ADgJc z)N~XIZ`V@j>QebU%9nFV+QTKL3NQf=7T~Vl^)sJ8D>p2~<_)tNk736WJ*C7}*{uTp z?ALRZvI#N4-m(#Cr9G+VF;&2AFn|U02DyL$YW1kqjrY!k8&**W3ofO51+`?m^hI^F ze}hauZr5mH8PX14+wLwAs({PDnFX{5Cjc=#WcUn`olv{s-kW-hjY?t;mQ%I4F${He zM2Tnd_5t!4wC=wt5Q!`kaKL$SD+Ag^Xz)5U!MV_uL=hv8X~$1>ZAIT}3P)KsJc zw*V>KQN5mJm&B3q+j6Vr$cTEE_(8arJa@@;@IrGl;bUM1xT9`#M~B2YaxytGkRpG6 zz!4s>b)E+%3STXWm)k7w;S%F0iA-0i1ZThyNBlOGm}@|$5aJ+akO~4txjo>LzleK+AaD}o zf>Ikn)j$uj!8gDMxIBtq$?ZTd;06|f;0Anw4)g$RTVwNo{N!gKWV#il00000NkvXX Hu0mjfr*zCO literal 0 HcmV?d00001 diff --git a/containers/libreportal/frontend/icons/vpn/slickvpn.png b/containers/libreportal/frontend/icons/vpn/slickvpn.png new file mode 100644 index 0000000000000000000000000000000000000000..e88efed349d6ebf39e8a5027d9a0e3f18f2687a5 GIT binary patch literal 5430 zcmeH~e@s(X6vv;PVMNVV2&hQcR)z|pKy6=tTG~>}4}}#Zfatb{5Q3KL zo^#JV=e_&d-dm1az==4qn1hvbg$p@uHOFysx!3PPybiGl#2}I3jl=w}FNJ$Zq__{J ztURS}#Z-*NCqUlJ#lYM`Pz3Ujw*&T_l(Q@J(01q@Ld*rt@?10L&-7KhB>vVGRj#E$ zR&A~gR?Hlq@Ohq>f(oz$6oYh)NxjEV$lv7i9F3-(VQy<}KsMq0&iQ2t^EmA3D=B!LWeQG6gedzQXx~ZJ!(rs? z+)Ak(7Sf%}Iy;f$%krPSrO@_Pj+xq$DWhX6rG2}XV%zqY`kFU=T(NhvG4@PjyRy6T zp|WRRozy0M=ABQ^f1cL0ZZ`A#$35itxA~~y zYXwld0&|qW0o?*xK@t20*n^-9>_dMC_TehT6XBa+OIRE>AGQnBLf62uoJLqSzs_fW zH~O<4?x6x$Rx24=1#5$K02{Oxy~QbgA?%s0@Y7*)K_;{r)){>tare;2fZZRfkqevc ztN&caB_^pTL@yVInpXPHW!^u{#TiCotq*Rrd~N^D+7wOej|6ydmg$kLf3dw(tyYV0 zb}#~VCh-F%;e29)R)IhvZr5*#DzG(2KGJ=f?@^RHGtE`OzsFZgDZV~nEJQ0~b5{ci zdq!A2Si-c>!!E@AQGZ-BADW$WE`ff~0PcXV06P=0STb_1>k^cM`ZP7Ea+2=xI`s(R zff|Eq7-z9dmRfP9Q2Qq0?Sj4mZ32Em|52geQPg|FG`ldq1muD50PA@N<5FR)U&Ct- zurX`>vR>WkgQP7D{n3!OjF58|G>#YX*5Pp$b8B#K%J>L&{$u@bfnmVTaihr31}rvy zFG7zG;IrNvfj^k+kKX-;odF)Te(j*KZPSS1aN+}V*^;}a5;5^fYerD3Vp{(%j82Ki znE58Dbt-mtW&IVP5VU|N$ajNq=*j-@9q{jg+t6$sCi_G8n|3YzBW};)C%R1v(%RNi zoa;5xWXnBk-ww$@{&nQ!uQIF&=QaFe!~3s~ns)^f?wh?s)_)MN=V#Bx)+}9k=E?qo z&pu;8g7#e3tO5Qjca?vdGw+Mdr%I zwEdKm%DcBw!jYwv)EY$VPn${i$*P*!)SXL?%(lW$dwlBy)wwp(oGhip;{jxE3Z{+6 zl$3I=l=22z$kwGNWoI2l6sYubsXg0VT|>t4n9dVqJD*1JjS*x%xRf?@St#RBC6y1} zAVc?dQg$C9S%P%@J3rgHv&{?7SQQl2)jVuE@1odKO=Q22LK`|`Xxqgq$~v=|_|DxF z-L(7iZ0o&9Uf8w`l9&TquEkvFAkEb;Nmm_3(N{W1d83!2w}+MC|BTN)_C@+mpChwN z7lsw2s#e#0=!`8>cPNH_B2{1YfIRWv?>+rKm{t}`tcKpAOZYFgeCF7^KfeO=EAVo! z0QtC{raY`Cdg{&c$cX-w(LlWF*I&5O$e%g)_dhJiji!NDXq@3!i5QQ-7;=em1w8-y E3lw$;tN;K2 literal 0 HcmV?d00001 diff --git a/containers/libreportal/frontend/icons/vpn/surfshark.svg b/containers/libreportal/frontend/icons/vpn/surfshark.svg new file mode 100644 index 0000000..a0bcd93 --- /dev/null +++ b/containers/libreportal/frontend/icons/vpn/surfshark.svg @@ -0,0 +1 @@ +Surfshark \ No newline at end of file diff --git a/containers/libreportal/frontend/icons/vpn/torguard.png b/containers/libreportal/frontend/icons/vpn/torguard.png new file mode 100644 index 0000000000000000000000000000000000000000..2fa22820ac025b450364a47fd7284476209db610 GIT binary patch literal 15406 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 bESEm`x3Q|%+P-`~9}oJY{P7JG`5X8@|2w^N literal 0 HcmV?d00001 diff --git a/containers/libreportal/frontend/icons/vpn/vpn-unlimited.png b/containers/libreportal/frontend/icons/vpn/vpn-unlimited.png new file mode 100644 index 0000000000000000000000000000000000000000..7aaeeb4d13fdd3a2f46c3474dd9cb919365bcbe7 GIT binary patch literal 1397 zcmV-*1&aEKP)tSIq0WfaQ;w0G2piS|Mq)HEAVmd(8AB8yS7MVWn7#>~7XrmAd$=)B z%3Lbr?fyD*NBEr*zRCQSycDqiU*rs-J?=69YmpNR4&^IEM(W&0X&^O6&_E`lMlkx4ybwL+T zWj=KTD)S5ht|j2@!3eoQ!YL90CvR|^DL2T6oq%J(NLxi->PW0AK!#W5S)z};mxkkU zWJFLKqn-4+ePiRL) zH|#=8grqQw85KE`F?Ng@eJB*VRn#((_|MHeHV87Bc6d2NbBPF2+Y zK#b4PpuL~TtldQ1rsVh@MbBw6NbgI&*OpVoG$Nf1Kyd|ufG{XX0PQLva!tKUub671 z`^Tn$>OgSO8Bg*SO~Epx$;hjIGRP${n_h%+!vkfSD$++qq0tA;PxpTM<^}(2z9Zg7 zqFZ_t_%#ZgE&h{S>8cA1&A&U1H9+@#F$iUhihGG8+vLW+{2#Gx^DX_}6UfG;e@f{p zbNP?mXH6{t%lrQGNCRxj15C{!$25mSMW^xlt}ZH6H$uVJ{}uZEZ1fOXM~;5!`^ef5 z-9PC>r0?kke-o?0`O!{TiChxT@f>)##UP;5%cYr8D)QiK zo8EOdr4%3$c-7oN_0S&wUbwvwBNO_S?f)Ae?0Fx3Rduj!odm@JA3Iy|U#4jj!_kG*vIjpm+|0y5um+K9<6$%6BD4m_}F)GpuW znCDSma1}4w`ti*DgL-Edx1w(141V!eM4|-rWG$%;E*e#y-%v}ApU^aBqrte{S_(%> z0XmwTmdp%q)4`pew;kVmIMk9%K!iFnd`ez_cst^?Jozxe+hzKqO*t>a#uQw$JcaZ| zK}_c>Xxf~K6RH{#KrC0hWkYS#?-l>NP#N8{bIX!?*p=k`Coyvo#ko%>PW5SRU&#Zz z$&6x!#*-Gr@YWLa%b^ODwNZTy)AZWmdwK0gbBX@|w3QHUrHxZ=00000NkvXXu0mjf DDbk3C literal 0 HcmV?d00001 diff --git a/containers/libreportal/frontend/icons/vpn/vpnsecure.png b/containers/libreportal/frontend/icons/vpn/vpnsecure.png new file mode 100644 index 0000000000000000000000000000000000000000..ccde9df5c0cb11cfe37f9cc2caf0998e1f37e5bf GIT binary patch literal 370070 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 DW$?{n literal 0 HcmV?d00001 diff --git a/containers/libreportal/frontend/icons/vpn/windscribe.png b/containers/libreportal/frontend/icons/vpn/windscribe.png new file mode 100644 index 0000000000000000000000000000000000000000..df54bce5811dd0e7c4b460c3fbc030a277572b36 GIT binary patch literal 15406 zcmeI333OFO(#J0@-;6rWrx4r*9dr~|k^l+_3K5kFizpC+qGo^@1Vm86u7oWFMiNZe zL{UIN5m0esQABY?Mo|P2gh+^h0!biY5i|3NHTA2!eslBk-Xl@uobQ~;IeqR;ch$fC z-M5$O>VBSA%{$RM;|x!qwY^>^d0s=$^J>>F`~R{tJ#Uug#m5KA>v-O(nx1!=%BYHJ zl;!KA(tovTd8gE^;dO1@;KJ1;-pM-}KwOMhr`}m7P3+zA=Ay;(XG`9u^_F-ik1}M! z;aqpo8MDR@>V0J6r>o`NWv@v8l=f2p?2|3=P99~*AnV5fll(Up&68Ui){)p6Uf4t# zGRXRQD6gyyAAcZiuen4L z!Q@|?H&gZ(d?z0+e^suneY!NM{d@W7&Bc~S8QhRX$F=j~d9&pHJKF{Iw`sc-?#!1} z%U`p^J9$nYS#`)-)lBByB`->k_BTq=x8GReojmlBK^C3Xs@t~V zC9Air`$XC`x>Tn9eWYyo=)pclbl$z;^q0$<>p4RaqY*lYQ+*6a!;3_{=n|Zvg*wx zRt7I*(Q$o}ezoz_kL0#%FPEG#L*?VHON)8n6vcx-ilp%vGI_UmfyY*tc^XgXj(iAKI>hpdh zsVTR~drKEd@$Q{+a9^S9-&-Ir&zo(Dck(Dh23d5_1p}z-7Hhb z4VRni)+%SmZ!^+6I_QD{7MNg1`xeSCEivu_D+RAl* zIx`}#=5=DE|J}C+<UFww^bw*2-lujyT)azDPiWRdk1`~s&JY9Ea6@;1v|H#ZE* zq$D<$UE8*lvyGhJKA7}FY+wtUj)U^aWPC7x;LG{PnBP9*30&i#{rb+Eu8}*H2Yg(< zU&e1AOt7(mEo@>xu$JtZrSm5k4-e}XtQT$Iw?BWzB-ye-=b``y=3SEepE6|J|9<;m zflV2<^#46>9b^3fr%?Se|GXAzf70kd)|Ya%A2}QDNgn+S8J8DoA8c%3E3jtS8k*K8 z#dH?VoN9HCk2yhH8vNJfARgFoN{p{9Th;G$c6i4>rp#|2U5CSya%`Z7O&tfeevf&e zhvhx@*dr!0yiZq|I(~$urO!VTwBziLAKFjmJo<OH_u=<-tzaKrhXW?|v4MST z>i^hJX?oEavS)jq?9R`%@!jFNxbIb4_Nu!$8v;(FPYZ2djIN3!B&u^nrqBrkC*=+rNkUpeIV^@Kn={X5V2S%|2LQf=%7ArM3&4&%{{h)I83{ z=em#HvGLdKmgdLIe)o0>h6y%v0JhxtV+^t`7lwt<81P_>ZT{7tIz#Sx&DXl6vd#ggGt@7fvr$qK>55rq{r`*-bVSYTow8`z@W{y)})YyNPG?8x0D zMY^wRarrqmo*&Ojm)TP?EzOvi5z)5efQ*x8Z__gNe8U10Y;1&EfAAHRSN`Lfql{

AJX95|1tf9OV5(t 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 X;xm>4aRSPYGoAmM`)> + + + + + LibrePortal - Modern Docker Management + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/containers/libreportal/frontend/js/components/app/app-manager.js b/containers/libreportal/frontend/js/components/app/app-manager.js new file mode 100755 index 0000000..1398ecc --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/app-manager.js @@ -0,0 +1,363 @@ +// App Manager - Dynamic app loading with beautiful styling +class AppManager { + constructor() { + this.cache = new Map(); + } + + getRandomLoadingMessage() { + const messages = [ + "Preparing your application settings...", + "Gathering application information...", + "Loading application configuration...", + "Setting up your app management panel...", + "Loading the perfect app settings...", + "Crafting your application experience...", + "Preparing your app control panel...", + "Loading application details...", + "Setting up your app workspace...", + "Configuring your application environment..." + ]; + + return messages[Math.floor(Math.random() * messages.length)]; + } + + async loadApp(appName) { + //console.log(`AppManager: Loading ${appName} app...`); + + // Check cache first + if (this.cache.has(appName)) { + //console.log(`AppManager: Using cached ${appName} app`); + return this.cache.get(appName); + } + + try { + // Load app data from apps.json + const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`Failed to load apps.json: ${response.status}`); + } + + const appsData = await response.json(); + + // Try multiple ways to find the app + let app = appsData.apps.find(app => + app.name.toLowerCase().includes(appName.toLowerCase()) || + app.command.toLowerCase().includes(appName.toLowerCase()) || + app.name === appName || + app.name.toLowerCase() === appName.toLowerCase() + ); + + if (!app) { + // Try case-insensitive exact match + app = appsData.apps.find(app => + app.name.toLowerCase() === appName.toLowerCase() || + app.command.toLowerCase().includes(appName.toLowerCase()) + ); + } + + if (!app) { + //console.log(`Available apps:`, appsData.apps.map(a => ({ name: a.name, command: a.command }))); + throw new Error(`App ${appName} not found`); + } + + //console.log(`AppManager: Loaded ${appName} app:`, app); + + // Cache the result + this.cache.set(appName, app); + + return app; + } catch (error) { + console.error(`AppManager: Error loading ${appName} app:`, error); + return null; + } + } + + async renderApp(appName) { + //console.log(`AppManager: Rendering ${appName} app...`); + + const configSection = document.getElementById('config-section'); + if (!configSection) { + console.error('AppManager: config-section element not found'); + return; + } + + // Show loading with enhanced visual + configSection.innerHTML = ` +
+
+
+
+ Loading application... +
+
+ ${this.getRandomLoadingMessage()} +
+
+ +
+ `; + + // Update loading bar if available + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(60); + } + + try { + // Load app data + const app = await this.loadApp(appName); + + // Update loading bar + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(70); + } + + if (!app) { + configSection.innerHTML = '
Application not found
'; + return; + } + + // App config comes from apps.json (window.apps), not a separate + // per-app JSON. Pass null — the renderer's config section is gated + // on appConfig?.config keys so it just skips that section. + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(80); + } + + await this.renderWithOriginalStyling(appName, app, null); + + // Final progress update + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(80); + } + + //console.log(`AppManager: Successfully rendered ${appName} app`); + + } catch (error) { + console.error(`AppManager: Error rendering ${appName} app:`, error); + configSection.innerHTML = `
Failed to load ${appName} application: ${error.message}
`; + } + } + + async renderWithOriginalStyling(appName, app, appConfig) { + const configSection = document.getElementById('config-section'); + + // Render using the original app-config system + let formHTML = ` +
+

${app.displayName || app.name} Application

+

${app.description || 'Manage settings and configuration for ' + (app.displayName || app.name)}

+
+
+
+ `; + + // App information section + formHTML += ` +
+

Application Information

+

Basic information about this application

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ `; + + // Configuration section if available + if (appConfig && appConfig.config && Object.keys(appConfig.config).length > 0) { + // Use ConfigShared if available for beautiful rendering + if (typeof ConfigShared !== 'undefined') { + const groupedConfigs = ConfigShared.groupConfigKeys(appConfig.config); + const categoryOrder = ConfigShared.extractCategoryOrder(appConfig.config); + + for (const category of categoryOrder) { + const keys = groupedConfigs[category]; + if (keys && keys.length > 0 && category !== 'Hidden/Unused Options') { + const displayCategory = ConfigShared.formatCategoryName(category); + const categoryDescription = await ConfigShared.getCategoryDescription(category); + + formHTML += ` +
+

${displayCategory}

+

${categoryDescription}

+
+ ${ConfigShared.generateFieldsForCategory(keys, category, appConfig.config, (fieldId, key, value, title, description, options, config) => ConfigShared.generateField(fieldId, key, value, title, description, options, config))} +
+
+ `; + } + } + } else { + // Fallback simple rendering + formHTML += ` +
+

Configuration

+

Application-specific settings

+
+
+ +
+ +
+
+
+
+ `; + } + } else { + // No configuration available + formHTML += ` +
+

Configuration

+

No specific configuration available for this application

+
+
+ +
+ +
+
+
+
+ `; + } + + formHTML += ` +
+
+ + +
+
+ `; + + configSection.innerHTML = formHTML; + } + + static async saveAppConfig(appName) { + //console.log(`AppManager: Saving ${appName} config...`); + + const form = document.getElementById(`app-form-${appName}`); + if (!form) { + console.error('AppManager: Form not found'); + return; + } + + // Show success message + if (typeof ConfigShared !== 'undefined' && ConfigShared.showNotification) { + ConfigShared.showNotification('Application configuration saved successfully!', 'success'); + } else { + // Fallback message + const message = document.createElement('div'); + message.className = 'config-message success'; + message.textContent = 'Application configuration saved successfully!'; + + const actionsDiv = form.parentElement.querySelector('.config-actions'); + actionsDiv.insertBefore(message, actionsDiv.firstChild); + + // Remove message after 3 seconds + setTimeout(() => { + if (message.parentNode) { + message.parentNode.removeChild(message); + } + }, 3000); + } + } + + static async resetAppConfig(appName) { + //console.log(`AppManager: Resetting ${appName} config...`); + + if (confirm('Are you sure you want to reset all settings to their default values?')) { + // Reload the page to reset + window.location.reload(); + } + } + + async loadScript(src) { + // Check if script is already loaded + const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_'); + if (document.getElementById(scriptId)) { + //console.log(`Script ${src} already loaded, skipping`); + return; + } + + //console.log(`Loading script: ${src}`); + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.id = scriptId; + script.onload = () => { + //console.log(`Script loaded successfully: ${src}`); + resolve(); + }; + script.onerror = (error) => { + console.error(`Script failed to load: ${src}`, error); + reject(new Error(`Failed to load script: ${src}`)); + }; + document.head.appendChild(script); + }); + } +} + +// Global instance +window.appManager = new AppManager(); diff --git a/containers/libreportal/frontend/js/components/app/app-tabbed-manager.js b/containers/libreportal/frontend/js/components/app/app-tabbed-manager.js new file mode 100755 index 0000000..6d7d1d1 --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/app-tabbed-manager.js @@ -0,0 +1,1138 @@ +// Enhanced App Manager with Tabbed Interface +// Integrates app management with task history + +class AppTabbedManager { + constructor() { + // console.log('🔍 AppTabbedManager constructor called'); + // console.log('🔍 URL in constructor:', window.location.href); + // console.log('🔍 Search params in constructor:', window.location.search); + + // Store original URL for task parameter detection + this.originalUrl = window.location.href; + this.originalSearch = window.location.search; + + // Check sessionStorage for task parameter (fallback) + const sessionTaskId = sessionStorage.getItem('pendingTaskId'); + // console.log('🔍 Session storage task ID:', sessionTaskId); + + // Debug: Check if task parameter exists in original URL + const originalParams = new URLSearchParams(this.originalSearch); + const originalTaskId = originalParams.get('task'); + // console.log('🔍 Original task ID in constructor:', originalTaskId); + + // Try to get task parameter from performance navigation if available + if (performance && performance.getEntriesByType) { + const navigationEntries = performance.getEntriesByType('navigation'); + if (navigationEntries.length > 0) { + const navEntry = navigationEntries[0]; + // console.log('🔍 Navigation entry URL:', navEntry.name); + const navParams = new URLSearchParams(new URL(navEntry.name).search); + const navTaskId = navParams.get('task'); + // console.log('🔍 Navigation task ID:', navTaskId); + } + } + + this.currentApp = this.getAppFromURL(); + this.currentTab = this.getTabFromURL(); + this.tasksManager = new TasksManager(); + this.appsManager = new AppsManager(); + this.initialized = false; + + // Button state management + this.disabledButtons = new Set(); + this.activeTaskId = null; + // Track running tasks. Key is `${appName}|${action}` (using `|` so app names with + // hyphens don't collide). Value is { taskId, appName, action } so callers never + // have to parse the key. + this.runningTasks = new Map(); + } + + // Build the runningTasks key for an (app, action) pair. Use `|` since `-` and `_` + // appear in app/action strings (e.g. 'delete_all'). + taskKey(appName, action) { + return `${appName}|${action}`; + } + + // Find the most recent running task for the given app, or null. + getRunningTaskForApp(appName) { + if (!appName) return null; + for (const info of this.runningTasks.values()) { + if (info.appName === appName) return info; + } + return null; + } + + // Switch the manager to a new app, clearing DOM-bound state from the previous app + // and re-evaluating tab/button state for the new one. Callers (e.g. apps-manager) + // should use this instead of mutating `currentApp` directly so disabled tabs from + // app A don't bleed into app B's view. + setCurrentApp(appName) { + if (this.currentApp === appName) return; + + // console.log('🔄 setCurrentApp: switching from %s to %s', this.currentApp, appName); + this.currentApp = appName; + + // Before clearing disabled button references, restore any static backup action buttons + // that may have spinners/disabled state from a previous app + const backupActions = document.querySelectorAll('.backup-actions button'); + backupActions.forEach(button => { + if (button && button.dataset.originalContent) { + button.disabled = false; + button.classList.remove('disabled', 'task-running'); + button.innerHTML = button.dataset.originalContent; + delete button.dataset.originalContent; + } + }); + + this.disabledButtons.clear(); + this.activeTaskId = null; + this.enableTabs(); + + const running = this.getRunningTaskForApp(appName); + // console.log('🔍 setCurrentApp: running task for %s = %o', appName, running); + if (running) { + this.activeTaskId = running.taskId; + } + } + + // Get app name from URL parameter + getAppFromURL() { + const urlParams = new URLSearchParams(window.location.search); + let appName = urlParams.get('app'); + + // Fallback to old format if app param not found + if (!appName) { + const fullPath = window.location.search; + if (fullPath.includes('?=')) { + const [basePath, query] = fullPath.split('?='); + appName = query.split('&')[0]; // Get only the app name, ignore other params + } + } + + // console.log('🔍 Original app name from URL:', appName); + + // Convert full app name to slug for task filtering + if (appName && window.apps) { + const appData = window.apps.find(app => { + // Extract slug from command + const command = app.command || ''; + const parts = command.split(' '); + return parts[parts.length - 1] === appName; + }); + + if (appData) { + const command = appData.command || ''; + const parts = command.split(' '); + const slug = parts[parts.length - 1]; // Return the slug + // console.log('🔄 Converted to slug:', slug, 'from appData:', appData.name); + return slug; + } else { + // console.log('⚠️ No app data found for:', appName); + } + } + + // console.log('🔄 Returning original app name:', appName); + return appName; + } + + // Get tab from URL parameter + getTabFromURL() { + const currentUrl = window.location.href; + const urlParams = new URLSearchParams(window.location.search); + const tab = urlParams.get('tab') || 'config'; + // console.log('🔍 getTabFromURL debug:', { + //currentUrl: currentUrl, + //search: window.location.search, + //tabParam: urlParams.get('tab'), + //defaultTab: 'config', + //finalTab: tab === 'logs' ? 'tasks' : tab + //}); + // Convert "logs" to "tasks" for backward compatibility + return tab === 'logs' ? 'tasks' : tab; + } + + // Check if we're on an app page before doing anything + isAppPage() { + const pathname = window.location.pathname; + const search = window.location.search; + + // Only individual app pages (/app?=appname), NOT the apps listing page (/apps) + return (pathname.startsWith('/app') && !pathname.startsWith('/apps') || + pathname.endsWith('/index.html') || pathname === '/index.html' || + search.includes('app=') || + search.includes('?=')); // Old format app pages + } + + // Update URL with app and tab + updateURL(app = null, tab = null) { + // console.log('🔍 updateURL called with:', { app, tab }); + // console.log('🔍 Current URL before update:', window.location.href); + + // Only update URLs on app pages - prevent interference with other pages + if (!this.isAppPage()) { + // console.log('🚫 Not on app page, skipping URL update'); + return; + } + + const url = new URL(window.location); + const params = new URLSearchParams(url.search); + const fullPath = window.location.search; // Define here for both blocks + + // Handle both old format (?=appname) and new format (?app=appname) + if (app) { + // Check if we're using the old format + if (fullPath.includes('?=')) { + // Update old format: /app?=appname&tab=tabname + const newURL = `/app?=${app}`; + if (tab) { + // console.log('🔄 Updating URL to:', `${newURL}&tab=${tab}`); + window.history.replaceState({}, '', `${newURL}&tab=${tab}`); + } else { + // console.log('🔄 Updating URL to:', newURL); + window.history.replaceState({}, '', newURL); + } + } else { + // Update new format: /app?app=appname&tab=tabname + if (tab) { + params.set('app', app); + params.set('tab', tab); + } else { + params.set('app', app); + } + const newSearch = params.toString(); + // console.log('🔄 Updating URL to:', `${window.location.pathname}?${newSearch}`); + window.history.replaceState({}, '', `${window.location.pathname}?${newSearch}`); + } + } else { + // Only updating tab, preserve existing app and task parameters + if (fullPath.includes('?=')) { + // Old format: preserve app and task, update tab + const currentApp = params.get('=') || this.currentApp; + const currentTask = params.get('task'); + let newURL = `/app?=${currentApp}&tab=${tab}`; + if (currentTask) { + newURL += `&task=${currentTask}`; + } + // console.log('🔄 Updating URL (old format) to:', newURL); + window.history.replaceState({}, '', newURL); + } else { + // New format: preserve app and task, update tab + const currentApp = params.get('app') || this.currentApp; + const currentTask = params.get('task'); + params.set('app', currentApp); + params.set('tab', tab); + if (currentTask) { + params.set('task', currentTask); + } + const newSearch = params.toString(); + // console.log('🔄 Updating URL (new format) to:', `${window.location.pathname}?${newSearch}`); + window.history.replaceState({}, '', `${window.location.pathname}?${newSearch}`); + } + } + } + + // Update current app and refresh content + updateApp(newAppName) { + this.setCurrentApp(newAppName); + + // Reset URL to config tab + const currentUrl = window.location.href; + let newUrl; + if (currentUrl.includes('tab=')) { + newUrl = currentUrl.replace(/tab=[^&]*/, 'tab=config'); + } else { + newUrl = `${currentUrl}&tab=config`; + } + history.replaceState({}, '', newUrl); + + this.switchTab('config'); + } + + // Switch between tabs + switchTab(tabId) { + // console.log('🔄 switchTab called with:', tabId); + // console.log('🔍 Current currentApp before switch:', this.currentApp); + // console.log('🔍 Current URL when switching:', window.location.href); + // console.log('🔍 URL search when switching:', window.location.search); + + // Remove active class from all main navigation tabs + document.querySelectorAll('.main-tab-button').forEach(btn => { + btn.classList.remove('active'); + }); + + // Hide all tab panes + document.querySelectorAll('.tab-pane').forEach(pane => { + pane.classList.remove('active'); + }); + + // Add active class to selected main navigation tab + const selectedTab = document.querySelector(`.main-tab-button[data-tab="${tabId}"]`); + if (selectedTab) { + selectedTab.classList.add('active'); + //// // console.log('✅ Tab button activated:', tabId); + } else { + console.warn('⚠️ Main navigation tab button not found:', tabId); + } + + // Add active class to selected tab pane + const selectedPane = document.getElementById(`${tabId}-tab`); + if (selectedPane) { + selectedPane.classList.add('active'); + //// // console.log('✅ Tab pane activated:', tabId); + } else { + console.warn('⚠️ Tab pane not found:', tabId); + } + + // Update URL (only tab, not app) - but only on app pages + if (this.isAppPage()) { + // console.log('🔄 About to updateURL with tab:', tabId); + this.updateURL(null, tabId); + } + + // Load tab-specific content + // console.log('🔄 About to load tab content for tab:', tabId, 'with currentApp:', this.currentApp); + this.loadTabContent(tabId); + } + + // Load content for specific tab + async loadTabContent(tabId) { + const actualTabId = tabId === 'logs' ? 'tasks' : tabId; + + const currentAppFromUrl = this.getAppFromURL(); + // console.log('📂 loadTabContent: tabId=%s, currentApp=%s, fromUrl=%s', + // tabId, this.currentApp, currentAppFromUrl); + + // Update currentApp if URL has different app name. Route through setCurrentApp + // so any disable state from the previous app gets cleared before we render. + if (currentAppFromUrl && currentAppFromUrl !== this.currentApp) { + this.setCurrentApp(currentAppFromUrl); + } + + // Ensure app detail view is shown and app is loaded before loading tab content + if (!this.currentApp || this.currentApp === 'null') { + console.warn('⚠️ No current app set, cannot load tab content'); + return; + } + + // Toggle the Tools tab button visibility based on whether this app has + // any tools. Tools-less apps simply don't see the tab. If the user + // landed on the tools tab via a deep link for such an app, redirect + // them to config so they're not staring at an empty pane. + if (window.toolsManager) { + const toolsResult = await window.toolsManager.prepare(this.currentApp); + if (actualTabId === 'tools' && (!toolsResult || toolsResult.tools.length === 0)) { + return this.switchTab('config'); + } + } + + // Routing tab is Traefik-only — show on Traefik, hide everywhere else. + const isTraefik = this.currentApp === 'traefik'; + document.querySelectorAll('[data-tab="routing"]').forEach(btn => { + btn.style.display = isTraefik ? '' : 'none'; + }); + if (actualTabId === 'routing' && !isTraefik) { + return this.switchTab('config'); + } + + // Make sure app detail view is visible and app is loaded + if (window.appsManager) { + // Use showAppDetail to ensure proper initialization (same as config tab) + // console.log('🔄 Ensuring app detail is loaded for:', this.currentApp); + window.appsManager.showAppDetail(this.currentApp); + + // Wait a bit for DOM to be ready after app detail is rendered + await new Promise(resolve => setTimeout(resolve, 200)); + } + + switch (actualTabId) { + case 'tasks': + // console.log('🔄 loadTabContent: Loading tasks for app:', this.currentApp); + await this.loadAppTasks(); + break; + case 'backups': + // console.log('🔄 loadTabContent: Loading backups for app:', this.currentApp); + await this.loadAppBackups(); + // IMPORTANT: Re-apply button state if there are running tasks + this.restoreButtonState(); + break; + case 'services': + if (window.servicesManager) { + await window.servicesManager.load(this.currentApp); + } + this.restoreButtonState(); + break; + case 'tools': + if (window.toolsManager) { + await window.toolsManager.load(this.currentApp); + } + this.restoreButtonState(); + break; + case 'routing': + if (window.routingManager) { + await window.routingManager.load(this.currentApp); + } + this.restoreButtonState(); + break; + case 'config': + // Config is already handled by showAppDetail above + // console.log('🔧 Config content already loaded by showAppDetail'); + // IMPORTANT: Re-apply button state if there are running tasks + this.restoreButtonState(); + break; + default: + // Config is handled by existing app management system + break; + } + + // Tear down the services tab (timers + SSE) when switching away. + if (actualTabId !== 'services' && window.servicesManager) { + window.servicesManager.unload(); + } + if (actualTabId !== 'tools' && window.toolsManager) { + window.toolsManager.unload(); + } + } + + // Load tasks specific to current app + async loadAppTasks() { + // console.log('🔄 loadAppTasks called, currentApp:', this.currentApp); + + // Show loading spinner by showing the initial loading state + const tasksContainer = document.getElementById('app-tasks'); + if (tasksContainer) { + tasksContainer.innerHTML = ` +
+
+

Loading tasks...

+
+ `; + } + + if (!this.currentApp) { + if (tasksContainer) { + tasksContainer.innerHTML = '

No app selected.

'; + } + return; + } + + try { + // Load all tasks + // console.log('🔄 Loading tasks...'); + // console.log('🔍 Using currentApp for filtering:', this.currentApp); + await this.tasksManager.loadTasks(); + const allTasks = this.tasksManager.tasks || []; + // console.log('📊 All tasks loaded:', allTasks.length); + // console.log('📋 All tasks data:', allTasks); + // console.log('📋 Sample task app names:', allTasks.slice(0, 3).map(t => t.app)); + + // Filter tasks for current app + const appTasks = allTasks.filter(task => task.app === this.currentApp); + // console.log('🎯 Filtering tasks for app:', this.currentApp); + // console.log('📋 Available task.app values:', [...new Set(allTasks.map(t => t.app))]); + // console.log('🎯 Filtered tasks for', this.currentApp, ':', appTasks.length); + + // Debug: Show what would match if we used different app names + // console.log('🔍 Debug - Testing different app names:'); + ['libreportal', 'fail2ban', 'LibrePortal', 'Fail2Ban'].forEach(testApp => { + const testTasks = allTasks.filter(task => task.app === testApp); + // console.log(` - "${testApp}": ${testTasks.length} tasks`); + }); + + if (appTasks.length === 0) { + // console.log('⚠️ No tasks found for', this.currentApp, '- checking if tasks have different app names'); + // Show some task details for debugging + if (allTasks.length > 0) { + // console.log('📋 Sample tasks:', allTasks.slice(0, 3).map(t => ({ id: t.id, app: t.app, command: t.command }))); + } + tasksContainer.innerHTML = `

No tasks found for ${this.currentApp}.

`; + return; + } + + // Setup global functions for task interactions + this.tasksManager.setupGlobalFunctions(); + + // Render app-specific tasks + const tasksHtml = appTasks.map(task => this.tasksManager.renderTask(task)).join(''); + tasksContainer.innerHTML = tasksHtml; + + // Setup app-specific task interactions (separate from main tasks system) + this.setupAppTaskFunctions(); + + // Handle pending task ID from URL parameter + if (this.pendingTaskId) { + // console.log('🔍 Handling pending task ID after tasks loaded:', this.pendingTaskId); + setTimeout(() => { + if (typeof window.toggleAppTaskDetails === 'function') { + // console.log('🔍 Opening task details for pending task:', this.pendingTaskId); + window.toggleAppTaskDetails(this.pendingTaskId); + + // Scroll to the task element after opening details + this.scrollToTask(this.pendingTaskId); + + this.pendingTaskId = null; // Clear pending task ID + } + }, 500); // Wait a bit for DOM to be ready + } + + // Task events are handled by individual task components + // No additional initialization needed + + } catch (error) { + console.error('AppTabbedManager: Error loading app tasks:', error); + tasksContainer.innerHTML = `

Error loading tasks: ${error.message}

`; + } + } + + // Scroll to specific task element with smooth animation + scrollToTask(taskId) { + // console.log('🔍 Scrolling to task:', taskId); + + // Find the task element by ID or data attribute + let taskElement = document.getElementById(`task-${taskId}`); + + // If not found by ID, try to find by data-task-id attribute + if (!taskElement) { + taskElement = document.querySelector(`[data-task-id="${taskId}"]`); + } + + // If still not found, try to find the task details element + if (!taskElement) { + const detailsElement = document.getElementById(`details-${taskId}`); + if (detailsElement) { + taskElement = detailsElement.closest('.task-item'); + } + } + + if (taskElement) { + // console.log('🔍 Found task element, scrolling to it:', taskElement); + + // Smooth scroll to the task element + taskElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', // Center the task in the viewport + inline: 'nearest' + }); + + // Add a highlight effect to make the task more visible + taskElement.classList.add('task-highlighted'); + + // Remove the highlight after 3 seconds + setTimeout(() => { + taskElement.classList.remove('task-highlighted'); + }, 3000); + + } else { + console.warn('⚠️ Task element not found for scrolling:', taskId); + } + } + + // Setup app-specific task functions to avoid conflicts with main tasks page + setupAppTaskFunctions() { + // Create app-specific toggleTaskDetails function + window.toggleAppTaskDetails = (taskId) => { + // console.log('🔍 App-specific toggleTaskDetails called for:', taskId); + + const details = document.getElementById(`details-${taskId}`); + const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`); + + if (details) { + const isOpen = details.style.display === 'block'; + + // Close all other task details and reset their buttons + document.querySelectorAll('.task-details').forEach(d => { + if (d.id !== `details-${taskId}`) { + d.style.display = 'none'; + d.classList.remove('task-details-open'); + } + }); + + document.querySelectorAll('.task-btn.toggle-details').forEach(btn => { + btn.classList.remove('expanded'); + }); + + if (isOpen) { + details.style.display = 'none'; + details.classList.remove('task-details-open'); + if (toggleBtn) toggleBtn.classList.remove('expanded'); + } else { + details.style.display = 'block'; + details.classList.add('task-details-open'); + if (toggleBtn) toggleBtn.classList.add('expanded'); + + // Auto-load logs when opened + if (this.tasksManager && this.tasksManager.loadTaskLogs) { + this.tasksManager.loadTaskLogs(taskId); + } + + // Update URL to include task parameter + const currentUrl = window.location.href; + const urlParams = new URLSearchParams(currentUrl.search); + + // Get current app from AppTabbedManager + const currentApp = this.currentApp || ''; + + // Construct proper URL with correct parameter order + const newUrl = `/app?=${currentApp}&tab=tasks&task=${taskId}`; + // console.log('🔍 Updating URL with task parameter:', newUrl); + history.pushState({}, '', newUrl); + } + } else { + console.warn('⚠️ App task details not found for:', taskId); + } + }; + + // Override global toggleTaskDetails to use app-specific version when on app page + const originalToggleTaskDetails = window.toggleTaskDetails; + window.toggleTaskDetails = (taskId) => { + if (window.appTabbedManager && window.location.pathname.includes('/app')) { + // Use app-specific version + window.toggleAppTaskDetails(taskId); + } else { + // Use original version for main tasks page + originalToggleTaskDetails(taskId); + } + }; + } + + // Load app backups + async loadAppBackups() { + const backupAppNameElement = document.getElementById('backup-app-name'); + if (backupAppNameElement) { + const formattedAppName = this.currentApp + ? (window.getAppDisplayName ? window.getAppDisplayName(this.currentApp) : (this.currentApp.charAt(0).toUpperCase() + this.currentApp.slice(1))) + : 'Unknown App'; + backupAppNameElement.textContent = formattedAppName; + } + + if (!this.currentApp || typeof BackupAppCard === 'undefined') { + const status = document.getElementById('backup-app-card-status'); + if (status) status.textContent = 'No app selected.'; + return; + } + + if (!this.backupAppCard || this.backupAppCard.appName !== this.currentApp) { + this.backupAppCard = new BackupAppCard(this.currentApp); + window.backupAppCard = this.backupAppCard; + } + await this.backupAppCard.render(); + } + + // Initialize the tabbed manager + async initialize() { + // Prevent double initialization + if (this.initialized) { + // console.log('⚠️ AppTabbedManager already initialized, skipping'); + return; + } + + // console.log('🚀 AppTabbedManager initializing, currentApp:', this.currentApp); + + // Initialize task system if not already done (with retry) + if (this.tasksManager && !this.tasksManager.commands) { + let initialized = false; + let attempts = 0; + const maxAttempts = 5; + + while (!initialized && attempts < maxAttempts) { + // console.log(`🔄 Attempting to initialize task system (${attempts + 1}/${maxAttempts})...`); + try { + initialized = this.tasksManager.initializeTaskSystem(); + if (initialized) { + // console.log('✅ Task system initialized successfully'); + } + } catch (error) { + console.error('❌ Task system initialization error:', error); + } + + if (!initialized) { + attempts++; + await new Promise(resolve => setTimeout(resolve, 200)); // Wait 200ms + } + } + + if (!initialized) { + console.warn('⚠️ Task system initialization failed after retries'); + } + } + + // Stale .task-running from a prior session won't survive a reload, but the + // DOM might still carry it from a re-render — clear it up front. + this.enableTabs(); + document.querySelectorAll('button.task-running, .tab-button.task-running, .main-tab-button.task-running') + .forEach(button => this.restoreButton(button)); + + // SSE drives task lifecycle events; the reconcile pass below is a + // safety net for the cases where they don't reach us — bus disconnect, + // missed event during reconnect, throttled background tab. Several + // triggers so a stuck "running" state self-heals quickly: + // * a faster periodic tick (5s) instead of the previous 30s + // * whenever the SSE bus reconnects (`taskBusReady`) + // * whenever the page becomes visible again (`visibilitychange`) + // * whenever the window regains focus + if (!this._watchdogStarted) { + this._watchdogStarted = true; + const reconcile = () => this.reconcileRunningTasks().catch(() => {}); + // Adaptive cadence: when a task is actively running, poll every + // 1.5s so a missed SSE event surfaces quickly. When idle, fall back + // to 5s. Net: worst-case lag drops from ~10s to ~1.5s while keeping + // background load minimal. + const tick = () => { + reconcile(); + const next = (this.runningTasks && this.runningTasks.size > 0) ? 1500 : 5000; + clearTimeout(this._reconcileTimer); + this._reconcileTimer = setTimeout(tick, next); + }; + this._reconcileTimer = setTimeout(tick, 1500); + window.addEventListener('taskBusReady', reconcile); + window.addEventListener('focus', reconcile); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') reconcile(); + }); + } + + // Set current app from URL BEFORE setting up URL monitoring + const urlAppName = this.getAppFromURL(); + // console.log('🔍 Setting initial currentApp from URL:', urlAppName); + this.currentApp = urlAppName; + + // Check for running tasks for this app and auto-switch to tasks tab if found + if (this.currentApp) { + const running = this.getRunningTaskForApp(this.currentApp); + if (running) { + this.switchTab('tasks'); + this.disableTabs(); + this.activeTaskId = running.taskId; + } + } + + // Check for task parameter and handle it AFTER tasks are loaded + // Use original URL since the current URL might have been modified + const urlParams = new URLSearchParams(this.originalSearch); + // console.log('🔍 Original URL search during init:', this.originalSearch); + // console.log('🔍 Original URL params during init:', Object.fromEntries(urlParams.entries())); + let taskId = urlParams.get('task'); + // console.log('🔍 Task ID from original params:', taskId); + + // Fallback: Check sessionStorage if URL doesn't have task parameter + if (!taskId) { + taskId = sessionStorage.getItem('pendingTaskId'); + // console.log('🔍 Task ID from sessionStorage fallback:', taskId); + // Clear sessionStorage after using it + if (taskId) { + sessionStorage.removeItem('pendingTaskId'); + } + } + if (taskId) { + // console.log('🔍 Task parameter found:', taskId); + // Store the task ID to handle after tasks are loaded + this.pendingTaskId = taskId; + // Force tasks tab + this.switchTab('tasks'); + } + + // Monitor URL changes for app navigation + this.setupURLMonitoring(); + + // Listen for task creation events + this.setupTaskEventListeners(); + + // Set initial active tab (only if no task parameter) + if (!taskId) { + const initialTab = this.getTabFromURL(); + // console.log('🔄 Setting initial tab:', initialTab, 'with currentApp:', this.currentApp); + this.switchTab(initialTab); + } + + // Set global reference for other components + window.appTabbedManager = this; + } + + // Monitor URL changes for app navigation + setupURLMonitoring() { + // Listen for popstate events (browser back/forward) + window.addEventListener('popstate', () => { + if (!this.isAppPage()) return; // Only monitor on app pages + + const newAppName = this.getAppFromURL(); + // Only update if currentApp is already set and app actually changed + if (this.currentApp && newAppName !== this.currentApp) { + // console.log('🔄 URL changed, updating app from', this.currentApp, 'to', newAppName); + this.updateApp(newAppName); + } + }); + } + + // Watch for new tasks and switch to logs tab + watchForTaskCreation() { + // Auto-switch to Tasks tab when a fresh task appears for the current app. + setInterval(async () => { + if (this.currentApp && this.currentTab !== 'tasks') { + // Skip if a recent uninstall asked us not to auto-switch. + const until = this._suppressTaskAutoSwitch?.get(this.currentApp); + if (until && Date.now() < until) return; + if (until) this._suppressTaskAutoSwitch.delete(this.currentApp); + try { + await this.tasksManager.loadTasks(); + const allTasks = this.tasksManager.tasks || []; + // Only switch on RUNNING/QUEUED tasks created recently — completed + // ones don't need watching, and would otherwise bounce the user + // back to Tasks right after they've been switched away. + const recentTasks = allTasks.filter(task => + task.app === this.currentApp && + (task.status === 'running' || task.status === 'queued' || task.status === 'pending') && + new Date(task.createdAt) > new Date(Date.now() - 5000) + ); + if (recentTasks.length > 0) this.switchTab('tasks'); + } catch (error) { + console.error('Error watching for tasks:', error); + } + } + }, 5000); + } + + // Create backup (placeholder function) + async createBackup(appName) { + // Placeholder - will be implemented with actual backup logic + // console.log(`Creating backup for ${appName}...`); + } + + // Setup task event listeners for button state management + setupTaskEventListeners() { + window.addEventListener('taskCreated', (event) => { + const { taskId, appName, action } = event.detail; + const key = this.taskKey(appName, action); + + // console.log('📌 taskCreated: appName=%s, currentApp=%s, action=%s, key=%s', + // appName, this.currentApp, action, key); + + if (this.runningTasks.has(key)) { + const existing = this.runningTasks.get(key); + // Same task firing twice — `createAndExecuteTask` dispatches taskCreated + // synchronously, and the SSE bus also dispatches it when the new task + // file shows up. Both events carry the same taskId; treat as a no-op. + if (existing && existing.taskId === taskId) return; + + if (window.notificationSystem) { + // Match the per-task-type icon used everywhere else (install ✅, + // backup 💾, etc.) so the user sees *what kind* of task is in + // progress, not just a generic warning triangle. + const typeIcon = (window.tasksManager && window.tasksManager.getTaskTypeIcon + ? window.tasksManager.getTaskTypeIcon({ type: action }) + : null)?.icon || ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + window.notificationSystem.show( + `Task Already Running
+ A ${action} task for ${appName} is already in progress.
+ Please wait for the current task to complete.`, + 'warning', + null, + null, + null, + customIcon + ); + } + return; + } + + this.runningTasks.set(key, { taskId, appName, action }); + // console.log('📌 taskCreated: stored in runningTasks, will disable=%s', appName === this.currentApp); + + if (appName === this.currentApp) { + this.disableAppButtons(appName, action); + this.activeTaskId = taskId; + } + }); + + window.addEventListener('taskCompleted', (event) => { + const { taskId, appName, action } = event.detail; + const key = this.taskKey(appName, action); + + // Primary path: delete by exact (app, action) key. + this.runningTasks.delete(key); + // Belt-and-braces: also remove any entry that matches the taskId. If + // `action` ever differs between the original `taskCreated` and this + // `taskCompleted` event (different code paths produce slightly + // different action strings), the key-based delete above is a no-op + // and the row would stay "running" forever. Match on id too. + for (const [k, info] of this.runningTasks) { + if (info && info.taskId === taskId) this.runningTasks.delete(k); + } + + const stillRunning = this.getRunningTaskForApp(appName); + if (stillRunning) { + if (appName === this.currentApp) this.activeTaskId = stillRunning.taskId; + return; + } + + // Always run the DOM cleanup; enableAppButtons is idempotent. + this.enableAppButtons(appName); + if (appName === this.currentApp) this.activeTaskId = null; + + if ((action === 'backup' || action === 'delete' || action === 'delete_all') && this.backupAppCard) { + this.backupAppCard.render(); + } + }); + + // Extra safety net: any `taskUpdated` whose status is terminal should + // also clear our local tracking. The bus normally dispatches a + // dedicated `taskCompleted` instead — but if a single task file write + // jumps a status straight from queued/pending to completed in a way + // that the bus classifies as "updated, !isNew", we'd miss it otherwise. + window.addEventListener('taskUpdated', (event) => { + const t = event.detail && event.detail.task; + if (!t || !t.id) return; + const terminal = t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled'; + if (!terminal) return; + let removed = false; + for (const [k, info] of this.runningTasks) { + if (info && info.taskId === t.id) { this.runningTasks.delete(k); removed = true; } + } + if (!removed) return; + const appName = t.app || null; + if (appName && !this.getRunningTaskForApp(appName)) { + this.enableAppButtons(appName); + if (appName === this.currentApp) this.activeTaskId = null; + } + }); + } + + // Helper method to get action for a task (used for duplicate detection) + getActionForTask(taskId) { + // Prefer our own runningTasks map — it knows the action by source of truth. + for (const info of this.runningTasks.values()) { + if (info.taskId === taskId) return info.action; + } + if (this.tasksManager && this.tasksManager.tasks) { + const task = this.tasksManager.tasks.find(t => t.id === taskId); + return task ? task.type : 'unknown'; + } + return 'unknown'; + } + + // Disable config, services and backup tabs when task is running + disableTabs() { + const tabs = ['config', 'services', 'tools', 'backups'] + .map(name => document.querySelector(`.main-tab-button[data-tab="${name}"], .tab-button[data-tab="${name}"]`)) + .filter(Boolean); + + for (const tab of tabs) { + tab.disabled = true; + tab.classList.add('disabled', 'task-running'); + tab.style.opacity = '0.5'; + tab.style.pointerEvents = 'none'; + tab.title = 'Disabled due to task running'; + } + } + + // Enable config, services and backup tabs when task completes + enableTabs() { + const tabs = ['config', 'services', 'tools', 'backups'] + .map(name => document.querySelector(`.main-tab-button[data-tab="${name}"], .tab-button[data-tab="${name}"]`)) + .filter(Boolean); + + for (const tab of tabs) { + tab.disabled = false; + tab.classList.remove('disabled', 'task-running'); + tab.style.opacity = ''; + tab.style.pointerEvents = ''; + tab.title = ''; + } + } + + // Disable app buttons during task execution + disableAppButtons(appName, action) { + // console.log('🚫 disableAppButtons called: appName=%s, action=%s, currentApp=%s', + // appName, action, this.currentApp); + + // Also disable config and backup tabs + this.disableTabs(); + + // Find ALL action buttons in the app content section (config, backup, etc.) + // This includes install, uninstall, update, backup, and any other action buttons + const appContent = document.querySelector('.app-content, .config-section, .backup-section, .tab-content'); + if (!appContent) { + console.warn('⚠️ App content section not found'); + return; + } + + // Disable ALL buttons in the app content section + const allButtons = appContent.querySelectorAll('button:not([disabled]):not(.tab-button)'); + // console.log('🚫 disableAppButtons found %d buttons to disable', allButtons.length); + + allButtons.forEach(button => { + // Skip tab buttons (config, backup, tasks tabs) + if (button.classList.contains('tab-button')) { + return; + } + // Logs toggle buttons stay clickable while a task runs — viewing + // log output is read-only and the whole point during a long task. + if (button.dataset.action === 'toggle-logs' || + button.classList.contains('service-logs') || + button.classList.contains('toggle-details')) { + return; + } + + // Hide download buttons permanently as they're not needed + if (button.textContent && button.textContent.toLowerCase().includes('download')) { + button.style.display = 'none'; + return; + } + + button.disabled = true; + button.classList.add('disabled', 'task-running'); + + // Add loading spinner to buttons that don't already have one + if (!button.querySelector('.spinner')) { + const originalContent = button.innerHTML; + button.dataset.originalContent = originalContent; + + // Replace entire content with spinner + text (no icons) + // Extract text content from original HTML (remove icons/SVGs) + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = originalContent; + const textContent = tempDiv.textContent || tempDiv.innerText || originalContent; + + // console.log('🔃 Adding spinner to button:', button.textContent.trim(), 'for app:', appName); + button.innerHTML = ` + + ${textContent.trim()} + `; + } + }); + + // Track which buttons were disabled + allButtons.forEach(button => { + this.disabledButtons.add(button); + }); + + // console.log(`🔍 Disabled ${allButtons.length} buttons for ${appName} during ${action}`); + } + + // Restore button state when switching tabs. Only disable if the *current* app + // has a running task — without this check, switching tabs on app B while a task + // is running for app A would disable app B's buttons and tabs. + restoreButtonState() { + const running = this.getRunningTaskForApp(this.currentApp); + if (running) { + this.activeTaskId = running.taskId; + this.disableAppButtons(this.currentApp, running.action); + } else { + this.enableTabs(); + this.enableAppButtons(this.currentApp); + } + } + + // SSE safety-net. The bus normally delivers terminal transitions in + // milliseconds; this re-syncs from the API if the bus has been disconnected. + async reconcileRunningTasks() { + if (this.runningTasks.size === 0) { + // Nothing tracked but the DOM still says disabled — that's a stale + // leftover from an earlier run whose `taskCompleted` we missed. Just + // force-enable so the user isn't stuck. + const configTab = document.querySelector('.main-tab-button[data-tab="config"], .tab-button[data-tab="config"]'); + if (configTab && (configTab.classList.contains('task-running') || configTab.disabled)) { + this.enableTabs(); + if (this.currentApp) this.enableAppButtons(this.currentApp); + } + return; + } + for (const info of Array.from(this.runningTasks.values())) { + let task = null; + if (window.taskEventBus) task = window.taskEventBus.getTask(info.taskId); + if (!task) { + try { + const res = await fetch(`/api/tasks/${info.taskId}`); + if (res.ok) task = await res.json(); + else if (res.status === 404) { + // Task file gone — fire one synthetic completion + stop polling + // so we don't loop forever on a deleted task. + this.runningTasks.delete(info.taskId); + window.dispatchEvent(new CustomEvent('taskCompleted', { + detail: { taskId: info.taskId, appName: info.appName, action: info.action, status: 'completed', task: null, timestamp: Date.now() } + })); + continue; + } + } catch { continue; } + } + if (!task) continue; + if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { + window.dispatchEvent(new CustomEvent('taskCompleted', { + detail: { taskId: task.id, appName: info.appName, action: info.action, status: task.status, task, timestamp: Date.now() } + })); + } + } + } + + restoreButton(button) { + if (!button) return; + button.disabled = false; + button.classList.remove('disabled', 'task-running'); + if (button.dataset.originalContent) { + button.innerHTML = button.dataset.originalContent; + delete button.dataset.originalContent; + } + button.querySelectorAll('.spinner').forEach(s => s.remove()); + } + + enableAppButtons(appName) { + this.enableTabs(); + + this.disabledButtons.forEach(button => this.restoreButton(button)); + this.disabledButtons.clear(); + + document.querySelectorAll('button.task-running, .tab-button.task-running, .main-tab-button.task-running') + .forEach(button => this.restoreButton(button)); + + const appContent = document.querySelector('.app-content, .config-section, .backup-section, .tab-content'); + if (appContent) { + appContent.querySelectorAll('button.disabled, button[disabled]:not(.tab-button)') + .forEach(button => this.restoreButton(button)); + } + } +} + +// Export for use +window.AppTabbedManager = AppTabbedManager; + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', async () => { + // console.log('🔍 DOMContentLoaded: Skipping automatic initialization - SPA will handle it'); + // Don't initialize here - let SPA handle it +}); + +// Also initialize when scripts are loaded (for SPA navigation) +window.addEventListener('load', async () => { + // console.log('🔍 Window load: Skipping automatic initialization - SPA will handle it'); + // Don't initialize here - let SPA handle it +}); diff --git a/containers/libreportal/frontend/js/components/app/apps-manager.js b/containers/libreportal/frontend/js/components/app/apps-manager.js new file mode 100755 index 0000000..e87f6bd --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/apps-manager.js @@ -0,0 +1,4073 @@ +// Single source of truth for "given an apps-services.json entry, what +// clickable links should we render for it?" Returns an array of +// { url, label } pairs. +// +// New schema: each service entry carries a `links: [{label, externalURL, +// internalURL}, ...]` array (built from the comma-separated label/path +// pairs in CFG__PORT_N cols 9 and 10). +// +// Legacy fallback: if `links` is missing, build a single entry from +// externalURL (or serverIP+externalPort) + buttonText, matching the +// original single-button behaviour for snapshots that pre-date the +// multi-button generator. +// +// Used by every UI surface that renders Open buttons: +// - dashboard.js (dashboard app-card hover overlay) +// - apps-manager.js (apps list popup + app-header buttons) +// - services-manager.js (Services tab Open buttons) +window.expandServiceLinks = function(s) { + const proto = ['http', 'https'].includes((s.protocol || '').toLowerCase()) + ? s.protocol.toLowerCase() + : 'http'; + if (Array.isArray(s.links) && s.links.length > 0) { + return s.links + .map(l => ({ + url: l.externalURL || l.internalURL, + label: l.label || s.buttonText || s.name + })) + .filter(l => !!l.url); + } + const fallbackUrl = s.externalURL || + (s.serverIP + ? `${proto}://${s.serverIP}:${s.externalPort}` + : `${proto}://localhost:${s.externalPort}`); + return fallbackUrl ? [{ url: fallbackUrl, label: s.buttonText || s.name }] : []; +}; + +// Apps Manager - Manages the apps list page and app details +class AppsManager { + constructor() { + this.cache = new Map(); + this.setupTaskCompletionListener(); + } + + setupTaskCompletionListener() { + // Listen for task completion events to reload apps data + window.addEventListener('taskCompleted', async (event) => { + const { action, appName, status } = event.detail; + + // Tool tasks mutate per-app config — refresh cache silently for next read. + if (action === 'tool' && status === 'completed') { + this.clearCache(); + await this.reloadAppsData(); + // If the user is viewing this app's detail page, re-render the + // config section in place so updated CFG_* values (e.g. a freshly + // reset password) show without needing a page refresh. Don't + // switch tabs — they may be reading the tool's task log. + const url = new URL(window.location.href); + const currentAppFromUrl = url.searchParams.get('app') || url.searchParams.get(''); + const onAppDetail = window.location.pathname === '/app' || window.location.pathname.startsWith('/app/'); + if (onAppDetail && appName && currentAppFromUrl === appName) { + this.displayConfigForm?.((window.apps || []).find(a => + (a.command || '').endsWith(' ' + appName) + )); + } + return; + } + + // First-install welcome modal — only on the very first successful install per app per browser. + if (action === 'install' && status === 'completed' && appName) { + const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`; + try { + if (!localStorage.getItem(key)) { + setTimeout(() => this.showInstallWelcome(appName), 600); + } + } catch (_) {} + } + + // Only reload on successful install or uninstall + if ((action === 'install' || action === 'uninstall') && status === 'completed') { + // Skip duplicate events for the same task id — the reconcile + // loop can synthesise a 404-fallback completed event after we've + // already handled the real one, which would re-trigger the + // heavy re-render + tab switch and visually flash the page. + const _taskId = event.detail.taskId || event.detail.id; + this._handledTaskIds = this._handledTaskIds || new Set(); + if (_taskId && this._handledTaskIds.has(_taskId)) return; + if (_taskId) this._handledTaskIds.add(_taskId); + + try { + // If this uninstall asked to delete its own task, do it now — + // the bash side skipped the in-flight task on purpose to + // avoid racing with the processor's final status write. + const taskId = event.detail.taskId || event.detail.id; + if (action === 'uninstall' && taskId && this._pendingTaskCleanup?.has(taskId)) { + this._pendingTaskCleanup.delete(taskId); + try { + if (window.tasksManager?.taskManager?.deleteTask) { + await window.tasksManager.taskManager.deleteTask(taskId, { force: true }); + } + // Re-render Tasks tab so the empty state ("No tasks found for X") shows. + if (window.tasksManager?.loadTasks) await window.tasksManager.loadTasks(); + if (window.tasksManager?.renderTasks) window.tasksManager.renderTasks(); + // If the user is parked on the Tasks tab and now there's + // nothing to look at, bounce them to Config. + if (window.appTabbedManager?.currentTab === 'tasks') { + window.appTabbedManager.switchTab('config'); + } + } catch (e) { console.error('post-uninstall task cleanup failed:', e); } + } + + this.clearCache(); + await this.reloadAppsData(); + if (window.serviceButtons) { + try { await window.serviceButtons.loadServices(); } catch (e) { console.error('loadServices failed:', e); } + } + + const currentUrl = new URL(window.location.href); + const currentAppFromUrl = currentUrl.searchParams.get('app') || currentUrl.searchParams.get(''); + const pathname = window.location.pathname; + const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/'); + const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/'); + + if (isAppsPage && !isAppDetailPage) { + const category = window.appsCategory || 'all'; + this.renderApps(category); + } else if (isAppDetailPage && currentAppFromUrl === appName) { + // Defer + isolate the heavy re-render so a throw inside + // displayConfigForm / port-manager init can't lock up the + // post-task UI cleanup. Fires on the next tick — gives the + // task spinners + button enables a chance to repaint first. + setTimeout(() => { + // _skipReload flag tells renderAppDetail not to re-fetch + // apps.json again (we already just did, line above). + this.renderAppDetail(appName, null, true, { skipReload: true }) + .catch(err => console.error('renderAppDetail failed:', err)); + }, 0); + } + + // After uninstall, bounce off the Tasks tab — there's nothing + // to watch any more. Mark the app as "recently uninstalled" + // so the 5s watchForTaskCreation poll doesn't bounce back. + if (action === 'uninstall' && isAppDetailPage && currentAppFromUrl === appName) { + window.appTabbedManager = window.appTabbedManager || null; + if (window.appTabbedManager) { + window.appTabbedManager._suppressTaskAutoSwitch = window.appTabbedManager._suppressTaskAutoSwitch || new Map(); + window.appTabbedManager._suppressTaskAutoSwitch.set(appName, Date.now() + 10_000); + setTimeout(() => { + if (window.appTabbedManager?.currentTab === 'tasks') { + window.appTabbedManager.switchTab('config'); + } + }, 50); + } + } + + if (typeof window.renderInstalledApps === 'function') { + window.renderInstalledApps(); + } + } catch (err) { + console.error('Post-task handler failed for', action, appName, ':', err); + } + } + }); + } + + clearCache() { + this.cache.clear(); + console.log('🗑️ Apps cache cleared'); + } + + async reloadAppsData() { + try { + // Reload global apps data + const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); + if (response.ok) { + const appsData = await response.json(); + window.apps = appsData.apps || []; + // console.log(`✅ Reloaded ${window.apps.length} apps`); + } + } catch (error) { + console.error('❌ Failed to reload apps data:', error); + } + } + + async loadApps(category = 'all') { + // Check cache first + if (this.cache.has(category)) { + return this.cache.get(category); + } + + try { + // Load apps data directly + const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`Failed to load apps.json: ${response.status}`); + } + + const appsData = await response.json(); + + // Filter apps by category + let filteredApps = appsData.apps || []; + + if (category === 'installed') { + filteredApps = filteredApps.filter(app => app.installed); + } else if (category !== 'all') { + // Apps may live in multiple categories (e.g. "Security,Recommended" + // in their .config). apps.json emits BOTH `categories[]` and a + // singular `category` for back-compat; prefer the array. + filteredApps = filteredApps.filter(app => { + if (Array.isArray(app.categories)) return app.categories.includes(category); + return app.category === category; + }); + } + + // Sort installed apps first + filteredApps.sort((a, b) => { + if (a.installed && !b.installed) return -1; + if (!a.installed && b.installed) return 1; + return 0; + }); + + // Cache the result + this.cache.set(category, filteredApps); + + return filteredApps; + } catch (error) { + console.error(`AppsManager: Error loading ${category} apps:`, error); + return []; + } + } + + async loadCategories() { + try { + const response = await fetch('/data/apps/apps-categories.json'); + if (!response.ok) { + throw new Error(`Failed to load apps-categories.json: ${response.status}`); + } + + const categoriesData = await response.json(); + + return categoriesData.categories || []; + } catch (error) { + console.error('AppsManager: Error loading categories:', error); + return []; + } + } + + async initialize() { + // Don't load data here - SPA handles it + // Just setup page based on URL + const path = window.location.pathname; + const searchParams = new URLSearchParams(window.location.search); + + if (path === '/app' || path.startsWith('/app/') || searchParams.has('app')) { + const appName = searchParams.get('app') || window.appName || ''; + this.showAppDetail(appName); + } else { + // Use the category parsed by SPA + let category = window.appsCategory || 'all'; + this.showAppsList(category); + } + } + + showAppsList(category) { + this.currentView = 'apps'; + this.currentApp = null; + + // Update URL only for specific categories, not for 'all' + if (category && category !== 'all') { + history.pushState({}, '', `/apps?=${category}`); + } + // For 'all' category, keep URL as /apps to avoid redirect loops + + // Switch to apps view + this.showView('apps'); + + // Render apps + this.renderApps(category); + + // Setup sidebar + this.setupSidebar(category); + } + + showAppDetail(appName, forceConfigTab = false) { + // console.log('🔍 showAppDetail called with:', { appName, forceConfigTab }); + //// // console.log(`AppsManager: Showing app detail: ${appName}`); + + // Don't proceed if appName is empty - redirect to apps list instead + if (!appName || appName.trim() === '') { + //// // console.log('AppsManager: Empty app name, redirecting to apps list'); + this.showAppsList('all'); + return; + } + + // Check if app has changed - only re-render header if app changed + const appChanged = this.currentApp !== appName; + + // Set current view first + this.currentView = 'app-detail'; + this.currentApp = appName; + + // Update URL to reflect current state + let targetTab; + if (forceConfigTab) { + // Force config tab for install/manage buttons + targetTab = 'config'; + // console.log('🔍 Forcing config tab due to forceConfigTab=true'); + } else { + // Preserve existing tab or default to config for direct navigation + const currentUrl = new URL(window.location.href); + targetTab = currentUrl.searchParams.get('tab') || 'config'; + // console.log('🔍 Preserving existing tab:', targetTab); + } + + const newUrl = `/app?=${appName}&tab=${targetTab}`; + // console.log('🔍 Setting URL to:', newUrl); + history.pushState({}, '', newUrl); + + // Update app-tabbed-manager BEFORE rendering the DOM. If renderAppDetail or + // any code it triggers calls switchTab → loadTabContent → restoreButtonState, + // we need this.currentApp to already be updated so restoreButtonState checks + // the right app for running tasks. + if (window.appTabbedManager) { + if (typeof window.appTabbedManager.setCurrentApp === 'function') { + window.appTabbedManager.setCurrentApp(appName); + } else { + window.appTabbedManager.currentApp = appName; + } + } + + // Switch to app detail view + this.showView('app-detail'); + + // Render app detail (async) - only re-render header if app changed + this.renderAppDetail(appName, null, appChanged); + + // Find app category and setup sidebar + const app = window.apps?.find(a => + a.name === appName || + a.id === appName || + a.slug === appName + ); + + if (app && app.category) { + this.setupSidebar(app.category); + } else { + this.setupSidebar('all'); + } + + const appCategory = app ? app.category : 'all'; + //// // console.log(`🎯 App category: "${appCategory}"`); + this.setupSidebar(appCategory); + } + + // Show app detail with config tab (for install/manage buttons) + showAppDetailWithConfig(appName) { + // console.log('🔍 showAppDetailWithConfig called with:', appName); + // console.log('🔍 Forcing config tab for button click'); + + // Check if there's a running task for this app — switch straight to the tasks + // tab if so, instead of landing on config (whose buttons would be disabled). + let targetTab = 'config'; + let runningTaskId = null; + if (window.appTabbedManager && typeof window.appTabbedManager.getRunningTaskForApp === 'function') { + const running = window.appTabbedManager.getRunningTaskForApp(appName); + if (running) { + targetTab = 'tasks'; + runningTaskId = running.taskId; + } + } + + // Set URL to target tab (config or tasks) + const newUrl = `/app?=${appName}&tab=${targetTab}`; + history.pushState({}, '', newUrl); + + // Update app-tabbed-manager. setCurrentApp clears stale disable state from + // whichever app the user came from before showing the new app's content. + if (window.appTabbedManager) { + if (typeof window.appTabbedManager.setCurrentApp === 'function') { + window.appTabbedManager.setCurrentApp(appName); + } else { + window.appTabbedManager.currentApp = appName; + } + + // Simulate clicking target tab functionally + // console.log('🔄 Simulating target tab click:', targetTab); + setTimeout(() => { + window.appTabbedManager.switchTab(targetTab); + // Highlight the running task if switching to tasks tab + if (targetTab === 'tasks' && runningTaskId && window.appTabbedManager.tasksManager) { + window.appTabbedManager.tasksManager.highlightedTaskId = runningTaskId; + window.appTabbedManager.tasksManager.renderTasks(); + } + }, 100); // Small delay to ensure app is loaded + } + + // Continue with normal app detail loading + this.showAppDetail(appName, true); + } + + showView(viewType) { + // Get both view containers + const appsView = document.getElementById('apps-view'); + const appDetailView = document.getElementById('app-detail-view'); + + if (viewType === 'apps') { + // Show apps view, hide app detail view + if (appsView) appsView.style.display = 'block'; + if (appDetailView) appDetailView.style.display = 'none'; + } else if (viewType === 'app-detail') { + // Show app detail view, hide apps view + if (appsView) appsView.style.display = 'none'; + if (appDetailView) appDetailView.style.display = 'block'; + } + } + + setupSidebar(activeCategory = 'all') { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) return; + + // Clear sidebar + const container = document.getElementById('dynamic-categories'); + if (!container) return; + + container.innerHTML = ''; + + // Hide loading + const loading = document.querySelector('.loading-categories'); + if (loading) loading.style.display = 'none'; + + // Add categories + this.addCategory('All', 'all'); + this.addCategory('Installed', 'installed'); + + // Add dynamic categories + if (window.sidebarCategories) { + const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : + Object.entries(window.sidebarCategories).map(([key, value]) => ({ id: key, ...value })); + + categoriesArray.forEach(cat => { + this.addCategory(cat.name, cat.id, cat.icon); + }); + } + + // Add back button for app detail + if (this.currentView === 'app-detail') { + this.addBackButton(); + } + + // Set active category + this.setActiveCategory(activeCategory); + } + + addCategory(name, id, icon) { + const container = document.getElementById('dynamic-categories'); + if (!container) return; + + const div = document.createElement('div'); + div.className = 'category'; + div.setAttribute('data-category', id); + + let iconHtml; + if (!icon && id === 'all') { + iconHtml = ''; + } else if (!icon && id === 'installed') { + iconHtml = ''; + } else { + let iconPath = icon || `/icons/categories/${id}.svg`; + if (!iconPath.startsWith('/')) iconPath = '/' + iconPath; + iconHtml = `${name}`; + } + + div.innerHTML = `${iconHtml} ${name}`; + + div.addEventListener('click', (e) => { + e.preventDefault(); + this.switchCategory(id); + }); + + container.appendChild(div); + } + + addBackButton() { + const container = document.getElementById('dynamic-categories'); + if (!container) return; + + const div = document.createElement('div'); + div.className = 'category'; + div.innerHTML = '← Back to Apps'; + div.addEventListener('click', () => { + this.showAppsList('all'); + }); + + container.appendChild(div); + } + + setActiveCategory(categoryId) { + document.querySelectorAll('.category').forEach(cat => { + cat.classList.remove('active'); + }); + + const active = document.querySelector(`[data-category="${categoryId}"]`); + if (active) active.classList.add('active'); + } + + switchCategory(categoryId) { + //// // console.log(`AppsManager: Switching to category: ${categoryId}`); + + // Update URL to reflect current state + if (categoryId === 'all') { + history.pushState({}, '', '/apps'); + } else { + history.pushState({}, '', `/apps?=${categoryId}`); + } + + // Direct view update without URL change to avoid conflicts + this.currentView = 'apps'; + this.currentApp = null; + + // Switch to apps view + this.showView('apps'); + + // Render apps for new category + this.renderApps(categoryId); + + // Update sidebar for new category + this.setupSidebar(categoryId); + } + + renderApps(category) { + const container = document.getElementById('apps-section'); + if (!container) return; + + container.innerHTML = ''; + + // Load and render apps + this.loadApps(category).then(apps => { + apps.forEach(app => { + const card = this.createAppCard(app); + container.appendChild(card); + }); + this.populateInlineServiceButtons(); + // Re-apply any active sidebar search so changing category + // doesn't reveal apps that should be filtered out. + if (this.appsSearchQuery) this.filterAppsByQuery(this.appsSearchQuery); + }).catch(error => { + console.error('Error rendering apps:', error); + }); + } + + // Client-side substring filter wired to the sidebar search box. + // Cards carry data-search (built in createAppCard) so this stays + // a single querySelectorAll + display toggle. + filterAppsByQuery(query) { + const q = (query || '').trim().toLowerCase(); + this.appsSearchQuery = q; + const wrap = document.querySelector('.apps-search'); + if (wrap) wrap.classList.toggle('has-value', !!q); + + const cards = document.querySelectorAll('#apps-section .app-card'); + cards.forEach(card => { + const hay = card.dataset.search || ''; + card.style.display = (!q || hay.includes(q)) ? '' : 'none'; + }); + } + + clearAppsSearch() { + const input = document.getElementById('apps-search-input'); + if (input) input.value = ''; + this.filterAppsByQuery(''); + if (input) input.focus(); + } + + async renderAppDetail(appName, preferredCategory = null, appChanged = true, opts = {}) { + //// // console.log(`🎯 renderAppDetail called with: "${appName}", appChanged: ${appChanged}`); + //// // console.log(`🎯 Available apps:`, window.apps?.map(a => ({ name: a.name, command: a.command }))); + + // Use global preferred category if not provided + if (!preferredCategory && window.preferredConfigCategory) { + preferredCategory = window.preferredConfigCategory; + // console.log('🎯 Using global preferred category:', preferredCategory); + } + + const container = document.getElementById('app-detail-view'); + if (!container) return; + + // Don't clear the entire container - update specific sections + // This preserves the original console-section styling + + // Get current installed status from DOM before reloading + const serviceButtonsContainer = document.getElementById('service-buttons-container'); + const wasInstalled = serviceButtonsContainer !== null; + + // Reload fresh app data if app has changed (ensures installed status is current after uninstall). + // Caller can pass skipReload when they've just refreshed (post-task listener). + if (appChanged && !opts.skipReload) { + await this.reloadAppsData(); + } + + // Find app data + const app = window.apps?.find(a => + a.name === appName || + a.command === `libreportal app install ${appName}` || + a.command.endsWith(` ${appName}`) + ); + //// // console.log(`🎯 Found app in renderAppDetail:`, app); + if (!app) { + container.innerHTML = '
App not found
'; + return; + } + + // Check if installed status changed (e.g., after uninstall) and force header re-render + const isNowInstalled = app.installed; + const installedStatusChanged = wasInstalled !== isNowInstalled; + const shouldRenderHeader = appChanged || installedStatusChanged; + //// // console.log(`🔍 Installed status: was=${wasInstalled}, now=${isNowInstalled}, changed=${installedStatusChanged}`); + + // Get app details - extract only the app name (before any dash) + const shortName = app.name.split(' - ')[0].trim(); // Only take text before the first dash + const cleanAppName = app.command.split(' ').pop(); + + document.title = `${shortName} - LibrePortal`; + + // Hide the Backups + Services tabs for not-yet-installed apps — there's + // nothing to back up and no docker compose services running yet. + const backupTab = document.querySelector('.main-tab-button[data-tab="backups"], .tab-button[data-tab="backups"]'); + const backupPane = document.getElementById('backups-tab'); + if (backupTab) backupTab.style.display = isNowInstalled ? '' : 'none'; + if (backupPane && !isNowInstalled) backupPane.classList.remove('active'); + + const servicesTab = document.querySelector('.main-tab-button[data-tab="services"], .tab-button[data-tab="services"]'); + const servicesPane = document.getElementById('services-tab'); + if (servicesTab) servicesTab.style.display = isNowInstalled ? '' : 'none'; + if (servicesPane && !isNowInstalled) servicesPane.classList.remove('active'); + + // Tools tab: hide for not-installed AND for installed-but-has-no-tools + // apps. Without the second check, clicking the visible tab triggers + // app-tabbed-manager's "no tools → bounce to config" path which + // looks like a broken redirect. + const toolsTab = document.querySelector('.main-tab-button[data-tab="tools"], .tab-button[data-tab="tools"]'); + const toolsPane = document.getElementById('tools-tab'); + const catalogEntry = window.toolsCatalog?.apps?.[cleanAppName]; + const hasTools = Array.isArray(catalogEntry?.tools) && catalogEntry.tools.length > 0; + const showToolsTab = isNowInstalled && hasTools; + if (toolsTab) toolsTab.style.display = showToolsTab ? '' : 'none'; + if (toolsPane && !showToolsTab) toolsPane.classList.remove('active'); + + const onHiddenTab = + (!isNowInstalled && (window.appTabbedManager?.currentTab === 'backups' + || window.appTabbedManager?.currentTab === 'services' + || window.appTabbedManager?.currentTab === 'tools')) + || (window.appTabbedManager?.currentTab === 'tools' && !showToolsTab); + if (onHiddenTab) { + window.appTabbedManager.switchTab('config'); + } + let icon = app.icon || 'icons/apps/default.svg'; + + // Ensure absolute path from root + if (!icon.startsWith('/')) { + icon = '/' + icon; + } + + const status = app.installed ? 'Installed' : 'Not Installed'; + const categoryName = this.getCategoryName(app.category); + const categoryIcon = this.getCategoryIcon(app.category); + + // Create tags matching app center style + const installedTag = app.installed + ? `${status}` + : `${status}`; + const categoryTag = ` ${categoryName}`; + // Render app header section (always define, but only update DOM if app changed) + const headerHTML = ` +
+
+ ${shortName} +
+
+

${shortName}

+

${app.description || 'No description available'}

+ ${app.longDescription ? `

${app.longDescription}

` : ''} +
+ ${categoryTag} + ${installedTag} +
+
+
+ ${app.installed ? ` +
+ +
+ ` : ''} + `; + + // Render config section with working app-config-original.js approach + // Use the working displayConfigForm from app-config-original.js + await this.displayConfigForm(app, preferredCategory); + + const configHTML = document.getElementById('config-section')?.innerHTML || ''; + + // Initialize port managers after config form is rendered + setTimeout(async () => { + await this.initializePortManagers(); + if (typeof ConfigOptions !== 'undefined') { + ConfigOptions.refreshGluetunCredentialVisibility?.(); + } + this.wireShowWhenListeners(); + this.wireConfigDirtyTracking(cleanAppName); + // Only update service buttons if app has changed or installed status changed + if (shouldRenderHeader) { + this.updateServiceButtonsSidebar(app, cleanAppName); + } + }, 100); + + // Render console section with original styling + const consoleHTML = ` +
+
+

Installation Console

+

Monitor the installation and configuration process

+
+ +
+
+ [${new Date().toLocaleTimeString()}] + Ready to install ${app.name} +
+
+ +
+ + +
+
+ `; + + // Update specific sections instead of overwriting entire container + // This preserves the original console-section styling + + // Only update app header if app has changed or installed status changed + if (shouldRenderHeader) { + const appHeader = document.getElementById('app-header'); + //// // console.log('app-header element found:', !!appHeader); + if (appHeader) { + appHeader.innerHTML = headerHTML; + // Explicitly remove service-buttons-container if app is not installed (prevents space) + if (!app.installed) { + const serviceButtonsContainer = document.getElementById('service-buttons-container'); + if (serviceButtonsContainer) { + serviceButtonsContainer.remove(); + } + } + //// // console.log('App header updated successfully'); + } + } + + // Update config section + const configSection = document.getElementById('config-section'); + //// // console.log('config-section element found:', !!configSection); + if (configSection) { + configSection.innerHTML = configHTML; + //// // console.log('Config section updated successfully, innerHTML length:', configHTML.length); + } else { + console.error('config-section element not found in DOM'); + } + + // Update console section (preserve original structure) + const consoleSection = container.querySelector('.console-section'); + if (consoleSection) { + // Update console output within the existing console-section + const messageLog = consoleSection.querySelector('#message-log'); + + if (messageLog) { + messageLog.innerHTML = ` +
+ [${new Date().toLocaleTimeString()}] + Ready to install ${app.name} +
+ `; + } + } + + // Initialize console to start at top + this.initializeConsole(); + + // Load actual app configuration if available + this.loadAppConfig(cleanAppName); + } + + async loadAppConfig(appName) { + //// // console.log(`AppsManager: Loading config for ${appName}...`); + + try { + // Get app data from global apps array (like original app-config-original.js) + const appData = window.apps?.find(a => a.name === appName || a.command.includes(appName)); + + if (appData && appData.config) { + //// // console.log(`AppsManager: Loaded config for ${appName}:`, appData.config); + + // Update form with actual configuration values from app.config + this.updateConfigForm(appName, appData.config); + } else { + //// // console.log(`AppsManager: No config found for ${appName}, showing default configuration`); + + // Show default configuration when no config exists + this.updateConfigForm(appName, { + CFG_APP_NAME: appData?.name || appName, + CFG_VERSION: appData?.version || '1.0.0', + CFG_PORT: '8080', + CFG_DOMAIN: '', + CFG_USERNAME: 'admin', + CFG_PASSWORD: '', + CFG_DEBUG: 'false', + CFG_LOG_LEVEL: 'INFO' + }); + } + } catch (error) { + //// // console.log(`AppsManager: Error loading config for ${appName}:`, error); + + // Get app data for defaults + const appData = window.apps?.find(a => a.name === appName || a.command.includes(appName)); + + // Show default configuration on error + this.updateConfigForm(appName, { + CFG_APP_NAME: appData?.name || appName, + CFG_VERSION: appData?.version || '1.0.0', + CFG_PORT: '8080', + CFG_DOMAIN: '', + CFG_USERNAME: 'admin', + CFG_PASSWORD: '', + CFG_DEBUG: 'false', + CFG_LOG_LEVEL: 'INFO' + }); + } + } + + updateConfigForm(appName, appConfig) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + const appData = window.apps?.find(a => a.name === appName || a.command?.includes(appName)); + + Object.entries(appConfig).forEach(([key, value]) => { + const field = form.querySelector(`[name="${key}"]`); + if (!field) return; + let nextValue = value; + if (key.endsWith('_NETWORK')) { + nextValue = this.applyContextualDefault('NETWORK', value, appData); + } + if (field.type === 'checkbox') { + field.checked = nextValue === 'true' || nextValue === 'yes'; + } else { + field.value = nextValue; + } + }); + } + + // Display configuration form (working method from app-config-original.js) + async displayConfigForm(appData, preferredCategory = null) { + //// // console.log('displayConfigForm called with:', appData, 'preferredCategory:', preferredCategory); + const configSection = document.getElementById('config-section'); + if (!configSection) { + return; + } + + const cleanAppName = appData.command.split(' ').pop(); + + const requiresKey = Object.keys(appData.config || {}).find(k => k.endsWith('_REQUIRES_SERVICE')); + const requiredService = requiresKey ? (appData.config[requiresKey] || '').trim() : ''; + if (requiredService && !this.checkServiceInstalled(requiredService) && !appData.installed) { + const slug = requiredService.toLowerCase(); + const serviceLabel = slug.charAt(0).toUpperCase() + slug.slice(1); + const iconUrl = `/icons/apps/${encodeURIComponent(slug)}.svg`; + configSection.innerHTML = ` +
+

🛠️ Configuration Settings

+

Configure ${this.escHtml(appData.name)} to match your requirements

+
+
+ +
+
${this.escHtml(serviceLabel)} required
+
${this.escHtml(serviceLabel)} needs to be installed before you can configure ${this.escHtml(appData.name)}.
+
+ +
+ `; + return; + } + + //// // console.log('Setting config form HTML for:', appData.name); + + // Generate simple tabbed interface with preferred category + //// // console.log('🎨 Generating config HTML with working approach...'); + const tabsContent = await this.generateSimpleTabsAndContent(appData, preferredCategory); + + const configHTML = ` +
+

🛠️ Configuration Settings

+

Configure ${appData.name} to match your requirements

+
+ +
+
+
+
+ ${tabsContent.tabsHTML} +
+ +
+ ${tabsContent.contentHTML} +
+
+
+ +
+ + + + + ${appData.installed && cleanAppName !== 'libreportal' ? ` + + ` : ''} + + ${appData.installed && cleanAppName === 'gluetun' ? ` + + ` : ''} +
+
+ `; + + configSection.innerHTML = configHTML; + + // Initialize tab functionality + this.initializeSimpleTabs(); + + // Enhance scrollbar dynamically + this.enhanceTabsScrollbar(); + + //// // console.log('Config form HTML set successfully'); + } + + // Generate simple tabs and content together (clean, reliable approach) + async generateSimpleTabsAndContent(appData, preferredCategory = null) { + //// // console.log('🏷️📄 generateSimpleTabsAndContent called'); + const categories = await this.getConfigCategories(); + const fieldMappings = await this.getFieldMappings(); + const appConfig = appData.config || {}; + + //// // console.log(`🏷️ Config categories loaded:`, categories); + //// // console.log(`🏷️ Field mappings loaded:`, fieldMappings); + //// // console.log(`🏷️ App config:`, appConfig); + //// // console.log(`🏷️ Preferred category:`, preferredCategory); + + //// // console.log('📂 Available categories:', Object.keys(categories)); + //// // console.log('🗂️ Available field mappings:', Object.keys(fieldMappings)); + //// // console.log('🔍 Looking for PORT_MANAGER in field mappings:', 'PORT_MANAGER' in fieldMappings); + if ('PORT_MANAGER' in fieldMappings) { + //// // console.log('🔍 PORT_MANAGER field config:', fieldMappings['PORT_MANAGER']); + } + + let tabsHTML = ''; + let contentHTML = ''; + let firstTab = null; + + // Sort categories by order + const sortedCategories = Object.entries(categories) + .sort(([,a], [,b]) => a.order - b.order); + + // Find first tab with fields + for (const [key, category] of sortedCategories) { + + const hasFields = Object.entries(fieldMappings).some(([fieldKey, fieldConfig]) => { + if (fieldConfig.category === key) { + const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig); + return cfgKey && appConfig.hasOwnProperty(cfgKey); + } + return false; + }); + + if (hasFields) { + firstTab = key; + break; + } + } + + // Use preferred category if available and valid, otherwise use firstTab + const activeTab = preferredCategory && categories[preferredCategory] ? preferredCategory : firstTab; + + // Generate tabs and content together + for (const [key, category] of sortedCategories) { + const hasFields = Object.entries(fieldMappings).some(([fieldKey, fieldConfig]) => { + if (fieldConfig.category === key) { + const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig); + return cfgKey && appConfig.hasOwnProperty(cfgKey); + } + return false; + }); + + if (hasFields) { + const isActive = key === activeTab ? 'active' : ''; + + // Generate tab button + tabsHTML += ` + + `; + + // Generate content panel + //// // console.log(`🔧 Generating content for category: ${key}`); + const categoryContent = await this.generateConfigFields(key, appData); + + contentHTML += ` +
+
+

${category.icon} ${category.name}

+

${category.description}

+
+
+ ${categoryContent} +
+
+ `; + } + } + + //// // console.log('✅ Tabs and content generated successfully'); + return { tabsHTML, contentHTML }; + } + + // Initialize tab functionality + initializeSimpleTabs() { + //// // console.log('Simple tabs initialized'); + } + + // Generate simple fields (working method from app-config-original.js) + async generateSimpleFields(categoryKey, appData) { + //// // console.log(`🔧 Generating fields for category: ${categoryKey}`); + const fieldMappings = await this.getFieldMappings(); + const appConfig = appData.config || {}; + let fieldsHTML = ''; + let hiddenFieldsHTML = ''; + + // Find fields that belong to this category + for (const [fieldKey, fieldConfig] of Object.entries(fieldMappings)) { + if (fieldConfig.category === categoryKey) { + //// // console.log(`🔧 Processing field: ${fieldKey} with config:`, fieldConfig); + const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig); + + // Skip generic mappings when a longer/more-specific one binds to the same cfgKey. + if (cfgKey) { + const moreSpecific = Object.keys(fieldMappings).some(otherKey => + otherKey !== fieldKey + && otherKey.length > fieldKey.length + && this.findMatchingCFGKey(otherKey, appConfig) === cfgKey + ); + if (moreSpecific) continue; + } + //// // console.log(`🔧 Found CFG key: ${cfgKey} with value:`, appConfig[cfgKey]); + + // Special debug for PORT_1 + if (fieldKey === 'PORT_1') { + //// // console.log(`🔧 PORT_1 DEBUG: fieldKey=${fieldKey}, cfgKey=${cfgKey}, hasValue=${!!appConfig[cfgKey]}, value="${appConfig[cfgKey]}"`); + //// // console.log(`🔧 PORT_1 DEBUG: All app config keys:`, Object.keys(appConfig)); + } + + // For advanced tab, only show advanced fields + if (categoryKey === 'advanced' && !fieldConfig.advanced) { + continue; // Skip non-advanced fields in advanced tab + } + + // For regular tabs, skip advanced fields + if (categoryKey !== 'advanced' && fieldConfig.advanced) { + continue; // Skip advanced fields in regular tabs + } + + // Skip fields gated by category allowlist when this app's category + // isn't in the list AND the override requirement isn't enabled. + if (Array.isArray(fieldConfig.categoryAllowlist)) { + const appCategory = String(appData?.category || '').toLowerCase(); + const inList = fieldConfig.categoryAllowlist.map(c => c.toLowerCase()).includes(appCategory); + const override = fieldConfig.requirementOverride + ? this.checkRequirementEnabled(fieldConfig.requirementOverride) + : false; + if (!inList && !override) continue; + } + + // Generic requiresService gating from field-mapping JSON. + if (fieldConfig.requiresService) { + if (!this.checkServiceInstalled(fieldConfig.requiresService)) { + const value = this.unmetDependencyValue(fieldConfig); + fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, + fieldConfig.disabledReason || `${fieldConfig.requiresService} needs to be installed.`); + continue; + } + } + + // Gate fields that depend on the global mail config being on. + // Used for per-app email-notification toggles so a user can't + // enable Email here without configuring SMTP under General first. + if (fieldConfig.requiresGlobalMail) { + const mailEnabled = await this.isGlobalMailEnabled(); + if (!mailEnabled) { + const value = this.unmetDependencyValue(fieldConfig); + fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, + fieldConfig.disabledReason || 'Configure mail in General settings first (CFG_MAIL_ENABLED=true).'); + continue; + } + } + + // Check conditional requirements for certain fields + if (fieldKey === 'AUTHELIA' || fieldKey === 'HEADSCALE' || fieldKey === 'WHITELIST') { + let serviceName; + let isServiceInstalled; + let disabledReason; + + if (fieldKey === 'AUTHELIA') { + serviceName = 'authelia'; + isServiceInstalled = this.checkServiceInstalled(serviceName); + disabledReason = 'Authelia needs to be installed'; + } else if (fieldKey === 'HEADSCALE') { + serviceName = 'headscale'; + isServiceInstalled = this.checkServiceInstalled(serviceName); + disabledReason = 'Headscale needs to be installed'; + } else if (fieldKey === 'WHITELIST') { + serviceName = 'traefik'; + isServiceInstalled = this.checkServiceInstalled(serviceName); + disabledReason = 'Traefik needs to be installed.'; + } + + if (!isServiceInstalled) { + // Force off-state so a stored "true" can't render checked when the dep is missing. + const value = this.unmetDependencyValue(fieldConfig); + fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason); + continue; + } + } + + // Get current value or use default + let fieldValue = cfgKey && appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); + fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData); + const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig); + if (fieldConfig.hideByDefault) { + hiddenFieldsHTML += fieldHTML; + } else { + fieldsHTML += fieldHTML; + } + } + } + + if (hiddenFieldsHTML) { + fieldsHTML += this.renderAdvancedToggleAndFields(hiddenFieldsHTML); + } + + if (!fieldsHTML) { + fieldsHTML = '
No configuration options available for this category.
'; + } + + return fieldsHTML; + } + + // Wrap each hidden field as a direct grid sibling tagged .advanced-field so + // they participate in the parent .panel-fields grid (continuing on the right + // of the toggle) rather than reflowing into a nested grid. + applyContextualDefault(fieldKey, value, appData) { + if (fieldKey === 'NETWORK' + && !appData?.installed + && (value === 'default' || value === '') + && this.checkServiceInstalled('gluetun')) { + return 'gluetun'; + } + return value; + } + + renderAdvancedToggleAndFields(hiddenFieldsHTML) { + const tagged = hiddenFieldsHTML.replace(/
+ + + Reveal less-common configuration options for power users. +
+ ${tagged} + `; + } + + // Generate field (working method from app-config-original.js) + async generateField(fieldKey, cfgKey, value, fieldConfig) { + const fieldId = fieldKey; // Use fieldKey to ensure unique IDs + const required = fieldConfig.required ? '*' : ''; + const helpIcon = fieldConfig.tooltip ? `?` : ''; + + let inputHTML = ''; + + // Special handling for DOMAIN fields - show domain dropdown + if (fieldKey === 'DOMAIN') { + //// // console.log('🎯 DOMAIN field detected, generating dropdown...'); + try { + const domainOptions = await this.getDomainOptions(); + //// // console.log('📊 Domain options received:', domainOptions); + let optionsHTML = ''; + + domainOptions.forEach(option => { + const isSelected = option.value === value.toString() ? 'selected' : ''; + optionsHTML += ``; + }); + + inputHTML = ``; + //// // console.log('✅ Domain dropdown generated successfully'); + } catch (error) { + console.error('❌ Error loading domain options, falling back to number input:', error); + // Fallback to regular number input if domain loading fails + inputHTML = ``; + } + } else if (fieldKey === 'GLUETUN_VPN_COUNTRIES') { + const selected = (typeof value === 'string' ? value : '').split(',').map(s => s.trim()).filter(Boolean); + const chips = selected.length + ? selected.map(c => `${this.countryFlagEmoji(c)}${c}`).join('') + : `Any`; + inputHTML = ` +
+
${chips}
+ + +
`; + } else { + // Regular field handling for all other types + // Auto-detect PORT fields and use port manager + if (fieldKey.startsWith('PORT_') || fieldConfig.type === 'port-manager') { + // Special handling for port manager - will be initialized after DOM is ready + //// // console.log(`🔌 Creating port-manager field: ${fieldKey} for app: ${this.getCurrentAppName()}`); + inputHTML = `
Loading port manager...
`; + } else { + switch (fieldConfig.type) { + case 'text': + inputHTML = ``; + break; + case 'password': { + const randomMatch = typeof value === 'string' && /^RANDOMIZEDPASSWORD\d+$/.test(value); + if (randomMatch) { + const placeholderToken = value; + inputHTML = ` +
+ +
+ + + +
+
`; + } else { + inputHTML = ` +
+ + +
`; + } + break; + } + case 'number': + inputHTML = ``; + break; + case 'select': + let optionsHTML = ''; + let selectOptions = fieldConfig.options; + if (!selectOptions && typeof ConfigOptions !== 'undefined' && ConfigOptions.isDropdownKey?.(cfgKey)) { + selectOptions = ConfigOptions.getSelectOptions(cfgKey); + } + // Fall back to default if stored value isn't in the option list. + let effectiveValue = value; + if (selectOptions && selectOptions.length > 0) { + const hasMatch = selectOptions.some(o => String(o.value) === String(value)); + if (!hasMatch) { + effectiveValue = (fieldConfig.default !== undefined && fieldConfig.default !== null) + ? fieldConfig.default + : selectOptions[0].value; + } + } + if (selectOptions) { + selectOptions.forEach(option => { + const isSelected = String(option.value) === String(effectiveValue) ? 'selected' : ''; + optionsHTML += ``; + }); + } + inputHTML = ``; + break; + case 'checkbox': + const isChecked = value === 'true' || value === true ? 'checked' : ''; + inputHTML = ` + + `; + break; + case 'textarea': + inputHTML = ``; + break; + default: + inputHTML = ``; + } + } + } + + // Generic conditional field: only render-visible when another field's + // current value matches. The post-render `wireShowWhenListeners` keeps + // visibility in sync as the watched field changes. Schema: + // showWhen: { "": "" } + // can be either a full CFG_ name or a bare suffix like + // "NOTIFY_EMAIL"; bare keys auto-resolve against the current field's + // app prefix so the same field-mapping is reusable across apps. + // For checkboxes the expected value is "true" or "false". + let showWhenAttrs = ''; + let showWhenStyle = ''; + if (fieldConfig.showWhen && typeof fieldConfig.showWhen === 'object') { + const entries = Object.entries(fieldConfig.showWhen); + if (entries.length > 0) { + let [watchKey, expected] = entries[0]; + // Auto-prefix bare keys with the current field's CFG__ prefix. + if (cfgKey && !String(watchKey).startsWith('CFG_')) { + const m = String(cfgKey).match(/^(CFG_[A-Z0-9]+_)/); + if (m) watchKey = `${m[1]}${watchKey}`; + } + const currentValue = this._readWatchedValue(watchKey); + const visible = String(currentValue) === String(expected); + showWhenAttrs = ` data-show-when-key="${watchKey}" data-show-when-equals="${String(expected)}"`; + if (!visible) showWhenStyle = ' style="display: none;"'; + } + } + + return ` +
+ + ${inputHTML} + ${fieldConfig.tooltip ? `${this.escHtml(fieldConfig.tooltip)}` : ''} +
+ `; + } + + // Best-effort lookup of a watched field's current value during render. + // Reads from the in-flight form (already-rendered fields above this one) + // OR from the cached app config so the initial visibility is right even + // for forward references. + _readWatchedValue(cfgKey) { + const live = document.querySelector(`[name="${cfgKey}"]`); + if (live) { + if (live.type === 'checkbox') return live.checked ? 'true' : 'false'; + return live.value; + } + const cached = this.currentAppConfig || {}; + if (Object.prototype.hasOwnProperty.call(cached, cfgKey)) { + const v = cached[cfgKey]; + if (typeof v === 'boolean') return v ? 'true' : 'false'; + return String(v); + } + return ''; + } + + // Hook change events on every watched CFG_KEY and toggle dependent + // .form-field[data-show-when-key=...] elements when the watched value + // changes. Called after the config form is rendered. + wireShowWhenListeners() { + const dependents = document.querySelectorAll('.form-field[data-show-when-key]'); + if (dependents.length === 0) return; + + // Build a map: watchedKey -> [{element, expected}] + const watch = new Map(); + dependents.forEach((el) => { + const key = el.getAttribute('data-show-when-key'); + const expected = el.getAttribute('data-show-when-equals'); + if (!key) return; + if (!watch.has(key)) watch.set(key, []); + watch.get(key).push({ element: el, expected }); + }); + + const evalKey = (key) => { + const entry = watch.get(key); + if (!entry) return; + const input = document.querySelector(`[name="${key}"]`); + let val = ''; + if (input) { + val = input.type === 'checkbox' ? (input.checked ? 'true' : 'false') : input.value; + } + entry.forEach(({ element, expected }) => { + element.style.display = String(val) === String(expected) ? '' : 'none'; + }); + }; + + watch.forEach((_v, key) => { + const input = document.querySelector(`[name="${key}"]`); + if (!input || input.dataset.showWhenWired === '1') return; + input.dataset.showWhenWired = '1'; + input.addEventListener('change', () => evalKey(key)); + input.addEventListener('input', () => evalKey(key)); + // Run once on init so any forward-reference defaults reconcile. + evalKey(key); + }); + + // showWhen dependents render as the grid cell immediately after their + // controller (generateConfigFields reorders them there), so revealing one + // drops the input in the slot right next to its toggle. + } + + // Generate configuration field HTML (from old file - needed for tab content) + // Are any CFG_DOMAIN_N configured? The per-app DOMAIN field is just an + // index into that list, so showing it when the list is empty is noise. + // Refetched on every form render so changes on the config page are + // reflected the next time an app's config tab opens. + async hasConfiguredDomains() { + try { + const res = await fetch('/data/config/generated/configs.json', { cache: 'no-store' }); + if (!res.ok) return false; + const json = await res.json(); + const flat = JSON.stringify(json); + for (let i = 1; i <= 9; i++) { + const m = flat.match(new RegExp(`"CFG_DOMAIN_${i}"\\s*:\\s*\\{[^}]*"value"\\s*:\\s*"([^"]*)"`)); + if (m && m[1].trim()) return true; + } + return false; + } catch { return false; } + } + + // Returns true if the user has switched on the global mail config. + // Used by `requiresGlobalMail` field gating so per-app email-notification + // toggles can refuse to enable until SMTP is configured once globally. + async isGlobalMailEnabled() { + try { + const res = await fetch('/data/config/generated/configs.json', { cache: 'no-store' }); + if (!res.ok) return false; + const json = await res.json(); + const v = json?.config?.CFG_MAIL_ENABLED?.value; + return String(v).toLowerCase() === 'true'; + } catch { return false; } + } + + async generateConfigFields(categoryKey, appData) { + const fieldMappings = await this.getFieldMappings(); + const appConfig = appData.config || {}; + const domainsAvailable = await this.hasConfiguredDomains(); + let fieldsHTML = ''; + let hiddenFieldsHTML = ''; + + // Collect every field that belongs to this category. + const categoryFields = []; + Object.entries(fieldMappings).forEach(([fieldKey, fieldConfig]) => { + if (fieldConfig.category !== categoryKey) return; + const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig); + + // Advanced fields only on the advanced tab, and vice versa. + if (categoryKey === 'advanced' && !fieldConfig.advanced) return; + if (categoryKey !== 'advanced' && fieldConfig.advanced) return; + + // Only show a field if this app actually has the CFG_ variable. + if (!cfgKey || !appConfig.hasOwnProperty(cfgKey)) return; + + // The DOMAIN selector is just an index into the domain list — hide it + // entirely when no CFG_DOMAIN_N is configured. + if (fieldKey === 'DOMAIN' && !domainsAvailable) return; + + // BACKUP gets priority -1 so "Enable Backups?" is always first; other + // inputs are 0, remaining checkboxes 1. + const isBackup = fieldKey === 'BACKUP'; + categoryFields.push({ + fieldKey, + fieldConfig, + cfgKey, + priority: isBackup ? -1 : (fieldConfig.type === 'checkbox' ? 1 : 0) + }); + }); + + categoryFields.sort((a, b) => a.priority - b.priority); + + // The sort above orders by type (inputs before checkboxes), which can + // separate a showWhen field from its controlling toggle. Reorder so each + // dependent sits immediately after its controller — then its conditional + // input reveals in the grid cell right next to the toggle. + const byCfgKey = new Map(categoryFields.map(f => [f.cfgKey, f])); + const resolveWatchKey = (entry) => { + const sw = entry.fieldConfig.showWhen; + if (!sw || typeof sw !== 'object') return null; + const swEntries = Object.entries(sw); + if (!swEntries.length) return null; + let [watchKey] = swEntries[0]; + if (!String(watchKey).startsWith('CFG_')) { + const m = String(entry.cfgKey).match(/^(CFG_[A-Z0-9]+_)/); + if (m) watchKey = `${m[1]}${watchKey}`; + } + return watchKey; + }; + + const ordered = []; + const placed = new Set(); + for (const entry of categoryFields) { + if (placed.has(entry.cfgKey)) continue; + // Dependents whose controller is in this category are placed alongside + // their controller below — skip them in this outer pass. + const watchKey = resolveWatchKey(entry); + if (watchKey && byCfgKey.has(watchKey)) continue; + + ordered.push(entry); + placed.add(entry.cfgKey); + for (const dep of categoryFields) { + if (placed.has(dep.cfgKey)) continue; + if (resolveWatchKey(dep) === entry.cfgKey) { + ordered.push(dep); + placed.add(dep.cfgKey); + } + } + } + // Safety net: anything still unplaced (e.g. a dependent whose controller + // lives in another category) keeps its original sorted position. + for (const entry of categoryFields) { + if (!placed.has(entry.cfgKey)) { ordered.push(entry); placed.add(entry.cfgKey); } + } + + for (const entry of ordered) { + const rendered = await this._renderCategoryField(entry, appData, appConfig); + if (!rendered) continue; + if (rendered.hidden) hiddenFieldsHTML += rendered.html; + else fieldsHTML += rendered.html; + } + + if (hiddenFieldsHTML) { + fieldsHTML += this.renderAdvancedToggleAndFields(hiddenFieldsHTML); + } + + if (!fieldsHTML) { + fieldsHTML = '
No configuration options available for this category.
'; + } + + return fieldsHTML; + } + + // Render a single collected category field: runs the dependency/service + // gating, then produces the .form-field HTML. Returns { html, hidden } or + // null when the field should be skipped entirely. + async _renderCategoryField(entry, appData, appConfig) { + const { fieldKey, fieldConfig, cfgKey } = entry; + + // Skip categoryAllowlist fields when this app's category isn't listed + // AND the override requirement isn't enabled. + if (Array.isArray(fieldConfig.categoryAllowlist)) { + const appCategory = String(appData?.category || '').toLowerCase(); + const inList = fieldConfig.categoryAllowlist.map(c => c.toLowerCase()).includes(appCategory); + const override = fieldConfig.requirementOverride + ? this.checkRequirementEnabled(fieldConfig.requirementOverride) + : false; + if (!inList && !override) return null; + } + + // Generic requiresService gating from the field-mapping JSON. + if (fieldConfig.requiresService && !this.checkServiceInstalled(fieldConfig.requiresService)) { + const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); + return { + html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, + fieldConfig.disabledReason || `${fieldConfig.requiresService} needs to be installed.`), + hidden: false + }; + } + + // requiresServices: ALL listed services must be installed (e.g. the + // MONITORING toggle needs both prometheus and grafana). + if (Array.isArray(fieldConfig.requiresServices)) { + const missing = fieldConfig.requiresServices.filter(s => !this.checkServiceInstalled(s)); + if (missing.length) { + const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); + return { + html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, + fieldConfig.disabledReason || `${missing.join(' + ')} needs to be installed.`), + hidden: false + }; + } + } + + // Legacy hardcoded service checks for fields not yet migrated. + if (fieldKey === 'AUTHELIA' || fieldKey === 'HEADSCALE' || fieldKey === 'WHITELIST') { + let serviceName, disabledReason; + if (fieldKey === 'AUTHELIA') { serviceName = 'authelia'; disabledReason = 'Authelia needs to be installed'; } + else if (fieldKey === 'HEADSCALE') { serviceName = 'headscale'; disabledReason = 'Headscale needs to be installed'; } + else { serviceName = 'traefik'; disabledReason = 'Traefik needs to be installed.'; } + + if (!this.checkServiceInstalled(serviceName)) { + const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); + return { html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason), hidden: false }; + } + } + + let fieldValue = appConfig[cfgKey] || (fieldConfig.default || ''); + fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData); + const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig); + return { html: fieldHTML, hidden: !!fieldConfig.hideByDefault }; + } + + // Generate configuration field HTML + generateConfigField(cfgKey, value, fieldConfig) { + const description = fieldConfig.description || ''; + let fieldHTML = ` +
+ + `; + + const type = fieldConfig.type || 'text'; + const options = fieldConfig.options; + + switch (type) { + case 'text': + fieldHTML += ``; + break; + case 'number': + fieldHTML += ``; + break; + case 'password': + fieldHTML += ` +
+ + +
`; + break; + case 'checkbox': + const checked = value === 'true' || value === 'yes' ? 'checked' : ''; + fieldHTML += ``; + break; + case 'select': + fieldHTML += ``; + break; + default: + fieldHTML += ``; + } + + fieldHTML += ` +
+ ${description ? `

${description}

` : ''} + + `; + + return fieldHTML; + } + + createAppCard(app) { + const card = document.createElement('div'); + card.className = 'app-card'; + if (app.installed) card.classList.add('installed'); + + // Searchable text for the sidebar search box. Combined name + + // description + long description + category, lowercased once here + // so filterAppsByQuery is a cheap substring match. + const searchHaystack = [ + app.name, + app.description, + app.longDescription, + app.category, + this.getCategoryName ? this.getCategoryName(app.category) : '' + ].filter(Boolean).join(' ').toLowerCase(); + card.dataset.search = searchHaystack; + + const appName = app.command.split(' ').pop(); + let icon = app.icon || 'icons/apps/default.svg'; + + // Ensure absolute path from root + if (!icon.startsWith('/')) { + icon = '/' + icon; + } + + const status = app.installed ? 'Installed' : 'Not Installed'; + + // Get category icon and name + const categoryIcon = this.getCategoryIcon(app.category); + const categoryName = this.getCategoryName(app.category); + + // Create rich tags like original + const descriptionTag = app.description ? ` ${app.description}` : ''; + const categoryTag = ` ${categoryName}`; + + // Format long description with period if missing + let formattedLongDescription = ''; + if (app.longDescription) { + formattedLongDescription = app.longDescription; + if (!formattedLongDescription.endsWith('.') && !formattedLongDescription.endsWith('?') && !formattedLongDescription.endsWith('!')) { + formattedLongDescription += '.'; + } + } + + // Service trigger icon (only for installed apps - visibility controlled after services load) + const serviceTrigger = app.installed ? ` + ` : ''; + + card.innerHTML = ` +
+
+ ${app.name} +
+
+
${app.name.split(' - ')[0].trim()}
+
+ ${descriptionTag} + ${categoryTag} + ${status} +
+
+
+ ${formattedLongDescription ? `
${formattedLongDescription}
` : ''} +
+ + ${serviceTrigger} +
+ `; + + return card; + } + + // Populate inline service trigger popups for installed apps + async populateInlineServiceButtons() { + if (!window.serviceButtons) return; + + if (window.serviceButtons.services.length === 0) { + await window.serviceButtons.loadServices(); + } + + const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http'; + + const popupContents = document.querySelectorAll('[id^="service-popup-content-"]'); + for (const content of popupContents) { + const appName = content.id.replace('service-popup-content-', ''); + const trigger = document.getElementById(`service-trigger-${appName}`); + + const appServices = window.serviceButtons.services.filter(s => s.app === appName && s.buttonEnabled === true); + if (appServices.length === 0) continue; + + // Multi-button render via the shared expandServiceLinks() helper. + const buttons = appServices.flatMap(s => { + const protectedClass = s.loginRequired ? ' protected' : ''; + const lockIcon = s.loginRequired + ? `` + : ''; + return window.expandServiceLinks(s).map(({ url, label }) => ` +
+ + + + + + ${label} + ${lockIcon} + + `); + }).filter(Boolean).join(''); + + if (buttons) { + content.innerHTML = buttons; + if (trigger) trigger.style.display = ''; + } + } + } + + getCategoryIcon(categoryId) { + if (!categoryId || categoryId === 'all') return null; + + // Convert sidebar categories object to array with id field + const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : + Object.entries(window.sidebarCategories || {}).map(([key, value]) => ({ id: key, ...value })); + + // Find category in categories array (case-insensitive) + const category = categoriesArray.find(cat => + cat.id === categoryId.toLowerCase() || + cat.name.toLowerCase() === categoryId.toLowerCase() + ); + + let iconPath = category ? category.icon : `/icons/categories/${categoryId}.svg`; + + // Ensure absolute path from root + if (iconPath && !iconPath.startsWith('/')) { + iconPath = '/' + iconPath; + } + + return iconPath; + } + + getCategoryName(categoryId) { + //// // console.log(`🏷️ Getting category name for: ${categoryId}`); + //// // console.log(`🏷️ window.sidebarCategories type: ${typeof window.sidebarCategories}`, window.sidebarCategories); + + if (!categoryId || categoryId === 'all') return 'All'; + + // Check if sidebar categories is available + if (!window.sidebarCategories) { + console.warn(`🏷️ window.sidebarCategories is not available, returning categoryId: ${categoryId}`); + return categoryId; + } + + // Convert sidebar categories object to array with id field + const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : + Object.entries(window.sidebarCategories || {}).map(([key, value]) => ({ id: key, ...value })); + //// // console.log(`🏷️ Categories array:`, categoriesArray); + + // Find category in categories array (case-insensitive) + const category = categoriesArray.find(cat => + cat.id === categoryId.toLowerCase() || + cat.name.toLowerCase() === categoryId.toLowerCase() + ); + //// // console.log(`🏷️ Found category:`, category); + + return category ? category.name : categoryId; + } + + // Show tab (working method from app-config-original.js) + showTab(tabKey) { + // Hide all panels + const allPanels = document.querySelectorAll('.tab-panel'); + allPanels.forEach(panel => panel.classList.remove('active')); + + // Remove active from all config category tabs (not main navigation tabs) + const allButtons = document.querySelectorAll('.tab-panel:has(.config-section) .tab-button, .config-section .tab-button'); + allButtons.forEach(button => button.classList.remove('active')); + + // Show selected panel + const targetPanel = document.getElementById(`panel-${tabKey}`); + if (targetPanel) { + targetPanel.classList.add('active'); + } + + // Add active to clicked config category button + const targetButton = document.querySelector(`.config-section [data-tab="${tabKey}"], .tab-panel:has(.config-section) [data-tab="${tabKey}"]`); + if (targetButton) { + targetButton.classList.add('active'); + } + } + + // Initialize simple tabs (working method from app-config-original.js) + initializeSimpleTabs() { + //// // console.log('Simple tabs initialized'); + } + + // Check if a service is installed + checkServiceInstalled(serviceName) { + if (!window.apps || window.apps.length === 0) { + return false; + } + + const serviceApp = window.apps.find(app => + app.command && app.command.endsWith(`libreportal app install ${serviceName}`) + ); + + return serviceApp && serviceApp.installed === true; + } + + checkRequirementEnabled(suffix) { + if (!suffix) return false; + const sysCfg = window.systemConfig || window.configs || {}; + const v = sysCfg[`CFG_REQUIREMENT_${suffix}`]; + return v === true || v === 'true'; + } + + // Get navigation button for installing required services + getNavigationButton(fieldKey) { + const servicePages = { + 'AUTHELIA': 'app.html?app=authelia', + 'HEADSCALE': 'app.html?app=headscale', + 'WHITELIST': 'app.html?app=traefik', + 'TRAEFIK': 'app.html?app=traefik' + }; + + let serviceName; + if (fieldKey === 'WHITELIST') { + serviceName = 'Traefik'; + } else if (fieldKey === 'AUTHELIA') { + serviceName = 'Authelia'; + } else if (fieldKey === 'HEADSCALE') { + serviceName = 'Headscale'; + } else { + serviceName = fieldKey.charAt(0) + fieldKey.slice(1).toLowerCase(); + } + + const pageUrl = servicePages[fieldKey] || '#'; + + return ` + + `; + } + + // Handle navigation with unsaved changes check + handleNavigation(url, serviceName) { + // For now, just navigate - could add unsaved changes detection later + window.location.href = url; + } + + // Generate disabled field with navigation button + serviceForField(fieldKey, fieldConfig) { + const map = { AUTHELIA: 'authelia', HEADSCALE: 'headscale', WHITELIST: 'traefik' }; + return (map[fieldKey] || fieldConfig.requiresService || '').toLowerCase(); + } + + generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason) { + const fieldId = fieldKey; + const slug = this.serviceForField(fieldKey, fieldConfig); + const serviceName = slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : ''; + const iconUrl = slug ? `/icons/apps/${encodeURIComponent(slug)}.svg` : '/icons/apps/default.svg'; + const isCheckbox = fieldConfig.type === 'checkbox'; + const hiddenInput = isCheckbox + ? `` + : ``; + + return ` +
+ ${hiddenInput} + +
+
${this.escHtml(fieldConfig.label)}
+
${this.escHtml(disabledReason)}
+
+ ${slug ? `` : ''} +
+ `; + } + + // First-install welcome modal — also openable from the app-header button. + async showInstallWelcome(appName, opts = {}) { + const slug = String(appName || '').toLowerCase(); + if (!slug) return; + const app = (window.apps || []).find(a => ((a.command || '').split(' ').pop() || '').toLowerCase() === slug); + if (!app) return; + + let services = []; + try { + const r = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); + if (r.ok) { + const d = await r.json(); + services = Array.isArray(d?.services) ? d.services.filter(s => s.app === slug) : []; + } + } catch (_) {} + + const traefikInstalled = this.checkServiceInstalled('traefik'); + const cfg = app.config || {}; + const upper = slug.toUpperCase(); + const isPublic = services.some(s => s.traefikManaged); + const isAuth = String(cfg[`CFG_${upper}_AUTHELIA`] || '').toLowerCase() === 'true' && this.checkServiceInstalled('authelia'); + const isVpn = String(cfg[`CFG_${upper}_NETWORK`] || '').toLowerCase() === 'gluetun'; + const anyTraefikLogin = services.some(s => s.traefikManaged && s.loginRequired); + + const badges = []; + if (isPublic) badges.push({ icon: '🌍', label: 'Public', cls: 'public' }); + if (services.some(s => s.traefikManaged) && traefikInstalled) badges.push({ icon: '🛡️', label: 'Traefik', cls: 'traefik' }); + if (isAuth) badges.push({ icon: '🔒', label: 'Authelia', cls: 'authelia' }); + if (isVpn) badges.push({ icon: '🌐', label: 'Gluetun VPN', cls: 'gluetun' }); + if (anyTraefikLogin && traefikInstalled) badges.push({ icon: '🔑', label: 'Login required', cls: 'login' }); + + const expand = typeof window.expandServiceLinks === 'function' ? window.expandServiceLinks : null; + const urls = []; + services.forEach(s => { + if (s.buttonEnabled === false) return; + const links = expand ? expand(s) : [{ url: s.externalURL, label: s.buttonText || s.name }]; + links.forEach(l => { if (l?.url) urls.push({ url: l.url, label: l.label || s.buttonText || s.name }); }); + }); + + const creds = []; + if (anyTraefikLogin && traefikInstalled) { + creds.push({ + title: 'Website Authentication (Traefik basic-auth)', + username: window.globalConfig?.CFG_TRAEFIK_USER || '(see CFG_TRAEFIK_USER)', + password: window.globalConfig?.CFG_TRAEFIK_PASS || '(see CFG_TRAEFIK_PASS)' + }); + } + // App-login keys are the app's OWN admin credentials, anchored to the + // app's CFG prefix. Without the anchor, loose suffix matches pull in + // notification-channel / upstream creds that aren't logins at all + // (e.g. CFG_GLUETUN_OPENVPN_PASSWORD). + const loginKey = (kind) => new RegExp(`^CFG_${upper}_(ADMIN_)?(${kind})$`); + const emailKeys = Object.keys(cfg).filter(k => loginKey('EMAIL').test(k)); + const userKeys = Object.keys(cfg).filter(k => loginKey('USER(NAME)?').test(k)); + const passKeys = Object.keys(cfg).filter(k => loginKey('PASSWORD').test(k)); + const emailVal = emailKeys[0] ? cfg[emailKeys[0]] : ''; + const userVal = userKeys[0] ? cfg[userKeys[0]] : ''; + const identifier = emailVal || userVal || 'admin'; + const userLabel = (emailVal || (typeof identifier === 'string' && identifier.includes('@'))) ? 'Email' : 'User'; + if (userKeys[0] || emailKeys[0] || passKeys[0]) { + creds.push({ + title: `${app.name.split(' - ')[0]} Login`, + username: identifier, + userLabel, + password: cfg[passKeys[0]] || '(not generated)' + }); + } + + const iconUrl = app.icon ? (app.icon.startsWith('/') ? app.icon : '/' + app.icon) : `/icons/apps/${slug}.svg`; + const shortName = app.name.split(' - ')[0]; + + const eoBadges = badges.map(b => ({ + icon: b.icon, label: b.label, + variant: ({public:'success', traefik:'info', authelia:'purple', gluetun:'warning', login:'danger'})[b.cls] + })); + + const bodyParts = []; + bodyParts.push(window.eoBadgeRow(eoBadges)); + if (urls.length) bodyParts.push(window.eoSection(`Open ${shortName}`, window.eoUrlList(urls))); + if (creds.length) bodyParts.push(window.eoSection('Login Details', window.eoCredList(creds))); + if (!urls.length && !creds.length) bodyParts.push(window.eoEmpty('All set up — head to the Services tab for details.')); + + window.openEoModal({ + id: 'install-welcome-modal', + size: 'sm', + icon: iconUrl, + iconAlt: shortName, + eyebrow: `🎉 ${opts.replay ? 'Welcome back to' : 'Installed'}`, + title: shortName, + desc: app.description || '', + body: bodyParts.join(''), + actions: [{ label: 'Done', variant: 'primary' }] + }); + + try { localStorage.setItem(`libreportal.welcomeShown.${slug}`, '1'); } catch (_) {} + } + + navigateToServiceApp(slug) { + if (typeof window.navigateToApp === 'function') return window.navigateToApp(slug); + if (window.librePortalSPA?.navigate) return window.librePortalSPA.navigate(`/app?=${slug}`); + if (typeof window.navigateToRoute === 'function') return window.navigateToRoute(`app?=${slug}`); + window.location.href = `/app?=${slug}`; + } + + // Find matching CFG_ key for a field (working method from app-config-original.js) + togglePasswordVisibility(fieldId) { + const input = document.getElementById(fieldId); + const icon = document.getElementById(`${fieldId}-icon`); + if (!input) return; + const showing = input.type === 'text'; + input.type = showing ? 'password' : 'text'; + if (icon) icon.textContent = showing ? '👁' : '🙈'; + } + + countryFlagEmoji(name) { + const map = { + 'Albania':'AL','Algeria':'DZ','Andorra':'AD','Angola':'AO','Argentina':'AR','Armenia':'AM','Australia':'AU','Austria':'AT','Azerbaijan':'AZ', + 'Bahamas':'BS','Bahrain':'BH','Bangladesh':'BD','Belarus':'BY','Belgium':'BE','Belize':'BZ','Bermuda':'BM','Bhutan':'BT','Bolivia':'BO', + 'Bosnia and Herzegovina':'BA','Brazil':'BR','Brunei':'BN','Brunei Darussalam':'BN','Bulgaria':'BG','Cambodia':'KH','Canada':'CA','Chile':'CL', + 'China':'CN','Colombia':'CO','Costa Rica':'CR','Croatia':'HR','Cyprus':'CY','Czech Republic':'CZ','Czechia':'CZ', + 'Denmark':'DK','Dominican Republic':'DO','Ecuador':'EC','Egypt':'EG','El Salvador':'SV','Estonia':'EE','Ethiopia':'ET', + 'Finland':'FI','France':'FR','Georgia':'GE','Germany':'DE','Ghana':'GH','Greece':'GR','Greenland':'GL','Guatemala':'GT', + 'Honduras':'HN','Hong Kong':'HK','Hungary':'HU','Iceland':'IS','India':'IN','Indonesia':'ID','Iran':'IR','Iraq':'IQ', + 'Ireland':'IE','Isle of Man':'IM','Israel':'IL','Italy':'IT','Jamaica':'JM','Japan':'JP','Jordan':'JO','Kazakhstan':'KZ', + 'Kenya':'KE','Kuwait':'KW','Kyrgyzstan':'KG','Laos':'LA','Latvia':'LV','Lebanon':'LB','Liechtenstein':'LI','Lithuania':'LT', + 'Luxembourg':'LU','Macao':'MO','Macau':'MO','North Macedonia':'MK','Macedonia':'MK','Madagascar':'MG','Malaysia':'MY','Malta':'MT', + 'Mexico':'MX','Moldova':'MD','Monaco':'MC','Mongolia':'MN','Montenegro':'ME','Morocco':'MA','Myanmar':'MM','Nepal':'NP', + 'Netherlands':'NL','New Zealand':'NZ','Nicaragua':'NI','Nigeria':'NG','Norway':'NO','Oman':'OM','Pakistan':'PK','Panama':'PA', + 'Papua New Guinea':'PG','Paraguay':'PY','Peru':'PE','Philippines':'PH','Poland':'PL','Portugal':'PT','Puerto Rico':'PR','Qatar':'QA', + 'Romania':'RO','Russia':'RU','Russian Federation':'RU','Saudi Arabia':'SA','Senegal':'SN','Serbia':'RS','Singapore':'SG', + 'Slovakia':'SK','Slovenia':'SI','South Africa':'ZA','South Korea':'KR','Korea, Republic of':'KR','Spain':'ES','Sri Lanka':'LK', + 'Sweden':'SE','Switzerland':'CH','Taiwan':'TW','Tajikistan':'TJ','Thailand':'TH','Trinidad and Tobago':'TT','Tunisia':'TN', + 'Turkey':'TR','Türkiye':'TR','Turkmenistan':'TM','Ukraine':'UA','United Arab Emirates':'AE','UAE':'AE','United Kingdom':'GB','UK':'GB', + 'United States':'US','USA':'US','United States of America':'US','Uruguay':'UY','Uzbekistan':'UZ','Venezuela':'VE','Vietnam':'VN','Viet Nam':'VN' + }; + const code = map[name]; + if (!code) return '🏳️'; + return String.fromCodePoint(...[...code].map(c => 0x1F1E6 + (c.charCodeAt(0) - 65))); + } + + openGluetunCountriesModal(fieldId) { + const hidden = document.getElementById(fieldId); + const chips = document.getElementById(`${fieldId}-chips`); + if (!hidden) return; + + const providerEl = (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunProviderEl) + ? ConfigOptions.findGluetunProviderEl() + : null; + const provider = providerEl ? providerEl.value : ''; + const providers = window.gluetunProviders || {}; + const countries = (provider && providers[provider] && Array.isArray(providers[provider].countries)) + ? [...providers[provider].countries].sort((a, b) => a.localeCompare(b)) + : []; + + const current = new Set((hidden.value || '').split(',').map(s => s.trim()).filter(Boolean)); + + const existing = document.getElementById('gluetun-countries-modal'); + if (existing) existing.remove(); + + const flag = (n) => this.countryFlagEmoji(n); + const renderChips = (list) => list.length + ? list.map(c => `${flag(c)}${c}`).join('') + : `Any`; + + const fallbackProviderIcon = `data:image/svg+xml;utf8,`; + const providerLabel = provider ? provider.replace(/\b\w/g, (c) => c.toUpperCase()) : '— none selected —'; + const iconManifest = window.gluetunProviderIcons || {}; + const providerIconUrl = (provider && iconManifest[provider]) || fallbackProviderIcon; + + const bodyHtml = ` +
+
+ ${provider} +
+
+

Provider

+

${providerLabel}

+
+
+
+
+ + + + + +
+
+ + +
+
+
+ ${countries.length === 0 + ? `

No country list available for this provider. Pick a provider first or wait for the snapshot to load.

` + : countries.map(c => ` + `).join('')} +
`; + + const m = window.openEoModal({ + id: 'gluetun-countries-modal', + title: '🌍 Select VPN Countries', + body: bodyHtml, + actions: [ + { label: 'Save', variant: 'primary', onClick: (modal) => { + const picked = Array.from(modal.contentEl.querySelectorAll('.gluetun-country-item input:checked')).map(cb => cb.value); + hidden.value = picked.join(','); + hidden.dispatchEvent(new Event('change', { bubbles: true })); + if (chips) chips.innerHTML = renderChips(picked); + modal.close(); + }}, + { label: 'Cancel', variant: 'secondary' } + ] + }); + const root = m.contentEl; + root.querySelector('.gluetun-country-search').addEventListener('input', (e) => { + const q = e.target.value.toLowerCase(); + root.querySelectorAll('.gluetun-country-item').forEach(item => { + const label = item.querySelector('.gluetun-country-name').textContent.toLowerCase(); + item.style.display = label.includes(q) ? '' : 'none'; + }); + }); + root.querySelector('.gluetun-country-all').addEventListener('click', () => { + root.querySelectorAll('.gluetun-country-item').forEach(item => { + if (item.style.display !== 'none') item.querySelector('input').checked = true; + }); + }); + root.querySelector('.gluetun-country-none').addEventListener('click', () => { + root.querySelectorAll('.gluetun-country-item input').forEach(cb => cb.checked = false); + }); + } + + async openGluetunRouteAppsModal() { + const existing = document.getElementById('gluetun-route-apps-modal'); + if (existing) existing.remove(); + + let allow = []; + try { + const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' }); + if (r.ok) allow = (await r.json()).categories || []; + } catch {} + const allowSet = new Set(allow.map((c) => String(c).toLowerCase())); + const overrideOn = (typeof ConfigOptions !== 'undefined') && this.checkRequirementEnabled('GLUETUN_FOR_ALL'); + const skip = new Set(['gluetun', 'libreportal', 'traefik', 'fail2ban']); + const apps = (window.apps || []) + .filter((a) => a.installed) + .map((a) => { + const slug = (a.command || '').split(' ').pop(); + return { ...a, slug }; + }) + .filter((a) => a.slug && !skip.has(a.slug)) + .filter((a) => overrideOn || allowSet.has(String(a.category || '').toLowerCase())) + .sort((a, b) => (a.name || a.slug).localeCompare(b.name || b.slug)); + + const bodyHtml = ` +

+ Tick an app to send its outbound traffic through the Gluetun VPN. Untick to restore the default network. + Each change re-runs that app's install task to apply the new compose. +

+ ${apps.length === 0 ? ` +
+ +
+

No eligible installed apps

+

+ Install an app from the curated categories first, or enable the + Gluetun For All Apps requirement to expose every app. +

+
+
+ ` : ` +
+ ${apps.map((a) => { + const cfgKey = `CFG_${a.slug.toUpperCase()}_NETWORK`; + const current = (a.config && a.config[cfgKey]) || 'default'; + const checked = current === 'gluetun' ? 'checked' : ''; + const icon = a.icon ? (a.icon.startsWith('/') ? a.icon : '/' + a.icon) : '/icons/apps/default.svg'; + return ` + `; + }).join('')} +
+ `}`; + + const m = window.openEoModal({ + id: 'gluetun-route-apps-modal', + title: '🛡️ Route apps through Gluetun', + body: bodyHtml, + actions: [ + { label: 'Apply', variant: 'primary', onClick: async (modal) => { + if (apps.length === 0) { modal.close(); return; } + const root = modal.contentEl; + const applyBtn = root.querySelectorAll('.eo-modal-footer .btn')[0]; + const changes = []; + root.querySelectorAll('.gluetun-country-item input[type=checkbox]').forEach((cb) => { + const desired = cb.checked ? 'gluetun' : 'default'; + if (desired !== cb.dataset.current) changes.push({ slug: cb.dataset.slug, value: desired }); + }); + if (changes.length === 0) { modal.close(); return; } + applyBtn.disabled = true; applyBtn.textContent = 'Applying…'; + try { + if (!window.tasksManager?.router) await this.loadTaskSystem?.(); + for (const { slug, value } of changes) { + const cfgKey = `CFG_${slug.toUpperCase()}_NETWORK`; + await window.tasksManager.router.routeAction('install', { appName: slug, config: { [cfgKey]: value } }); + } + this.addSuccessLog?.(`Queued ${changes.length} gluetun routing task(s).`); + modal.close(); + if (window.appTabbedManager) window.appTabbedManager.switchTab('tasks'); + } catch (err) { + applyBtn.disabled = false; applyBtn.textContent = 'Apply'; + console.error('Failed to queue gluetun routing tasks', err); + } + }}, + { label: 'Cancel', variant: 'secondary' } + ] + }); + if (apps.length === 0) { + const applyBtn = m.contentEl.querySelectorAll('.eo-modal-footer .btn')[0]; + if (applyBtn) applyBtn.disabled = true; + } + } + + openMullvadGenerateModal() { + const existing = document.getElementById('mullvad-generate-modal'); + if (existing) existing.remove(); + + const mullvadIcon = (window.gluetunProviderIcons && window.gluetunProviderIcons.mullvad) || '/icons/vpn/mullvad.svg'; + const bodyHtml = ` +

+ Enter your 16-digit Mullvad account number. A new WireGuard key will be generated locally + and registered with Mullvad — this consumes one of your 5 device slots. +

+
+ + +
+ `; + + const m = window.openEoModal({ + id: 'mullvad-generate-modal', + size: 'sm', + icon: mullvadIcon, + iconAlt: 'Mullvad', + eyebrow: 'Provider', + title: 'Generate Mullvad Config', + body: bodyHtml, + actions: [ + { label: 'Generate', variant: 'primary', onClick: async (modal) => { + const root = modal.contentEl; + const errEl = root.querySelector('.mullvad-error'); + const confirmBtn = root.querySelectorAll('.eo-modal-footer .btn')[0]; + const acctEl = root.querySelector('#mullvad-acct'); + const setError = (msg) => { errEl.textContent = msg || ''; errEl.style.display = msg ? '' : 'none'; }; + const account = (acctEl.value || '').replace(/\s+/g, ''); + if (!/^\d{16}$/.test(account)) { setError('Account number must be 16 digits.'); return; } + setError(''); + confirmBtn.disabled = true; confirmBtn.textContent = 'Generating…'; + try { + const res = await fetch('/api/gluetun/mullvad-wireguard', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accountNumber: account }) + }); + const data = await res.json(); + if (!res.ok || !data.success) { + setError(data.error || `Request failed (${res.status}).`); + confirmBtn.disabled = false; confirmBtn.textContent = 'Generate'; + return; + } + const findField = (suffix) => (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunFieldEl) ? ConfigOptions.findGluetunFieldEl(suffix) : null; + const setField = (suffix, value) => { + const el = findField(suffix); if (!el) return; + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }; + setField('WIREGUARD_PRIVATE_KEY', data.privateKey); + setField('WIREGUARD_ADDRESSES', data.addresses); + modal.close(); + } catch (err) { + setError(err.message || 'Network error.'); + confirmBtn.disabled = false; confirmBtn.textContent = 'Generate'; + } + }}, + { label: 'Cancel', variant: 'secondary' } + ] + }); + m.contentEl.querySelector('#mullvad-acct').focus(); + } + + setPasswordMode(fieldId, mode) { + const wrapper = document.querySelector(`.password-mode-wrapper[data-field-id="${fieldId}"]`); + const input = document.getElementById(fieldId); + const tokenInput = document.getElementById(`${fieldId}-token`); + if (!wrapper || !input || !tokenInput) return; + const key = wrapper.dataset.fieldKey; + + if (mode === 'random') { + input.dataset.previousCustom = input.value || ''; + input.value = ''; + input.readOnly = true; + input.type = 'password'; + input.setAttribute('placeholder', 'Will generate on save'); + input.removeAttribute('name'); + tokenInput.setAttribute('name', key); + const icon = document.getElementById(`${fieldId}-icon`); + if (icon) icon.textContent = '👁'; + } else { + input.readOnly = false; + input.removeAttribute('placeholder'); + input.value = input.dataset.previousCustom || ''; + input.setAttribute('name', key); + tokenInput.removeAttribute('name'); + input.focus(); + } + } + + // Off-value for a checkbox whose dependency isn't installed. + unmetDependencyValue(fieldConfig) { + return fieldConfig.type === 'checkbox' ? 'false' : ''; + } + + escHtml(s) { + return String(s ?? '') + .replace(/&/g, '&').replace(//g, '>'); + } + escAttr(s) { + return this.escHtml(s).replace(/"/g, '"').replace(/'/g, '''); + } + + findMatchingCFGKey(fieldKey, appConfig) { + // Try exact match first + const exactMatch = `CFG_${fieldKey}`; + if (appConfig.hasOwnProperty(exactMatch)) { + return exactMatch; + } + + // Try partial matches (more precise) + const keys = Object.keys(appConfig); + for (const cfgKey of keys) { + const cfgKeyWithoutPrefix = cfgKey.replace('CFG_', ''); + // Only match if the field key is a complete word within the cfg key + if (cfgKeyWithoutPrefix === fieldKey || + cfgKeyWithoutPrefix.endsWith('_' + fieldKey) || + cfgKeyWithoutPrefix.startsWith(fieldKey + '_')) { + return cfgKey; + } + } + + return null; + } + + // Helper methods to load config data (working methods from app-config-original.js) + async getConfigCategories() { + try { + // Load config categories (for app config tabs) + const response = await fetch('/data/apps/apps-config-categories.json'); + const data = await response.json(); + //// // console.log('✅ Loaded config categories from apps folder'); + return data.categories || data; // Return the actual data object + } catch (error) { + console.error('Error loading config categories:', error); + throw new Error('Failed to load config categories. Please check your configuration files.'); + } + } + + async getFieldMappings() { + try { + // Load from apps folder (static file) + const response = await fetch('data/apps/apps-field-mappings.json'); + const data = await response.json(); + //// // console.log('✅ Loaded field mappings from apps folder'); + return data.fields || data; + } catch (error) { + console.error('Error loading field mappings:', error); + throw new Error('Failed to load field mappings. Please check your configuration files.'); + } + } + + // Get domain options for DOMAIN field + async getDomainOptions() { + //// // console.log('🎯 Getting domain options...'); + + try { + //// // console.log('🔍 Starting domain fetch...'); + + // Try to load system config to get domain information + const response = await fetch('/data/config/generated/configs.json'); + //// // console.log('📡 Config response status:', response.status); + + if (!response.ok) { + console.warn('Could not load system config for domains, returning empty list'); + return [ + { value: '1', label: 'No domains configured - Configure domains in Network settings first' } + ]; + } + + const configData = await response.json(); + //// // console.log('📄 Full config data:', configData); + //// // console.log('🔧 Config keys available:', Object.keys(configData)); + + const config = configData.config || {}; + //// // console.log('⚙️ Config object:', config); + //// // console.log('🔑 Config keys:', Object.keys(config)); + + const domains = []; + + // Check CFG_DOMAIN_1 through CFG_DOMAIN_9 + for (let i = 1; i <= 9; i++) { + const domainKey = `CFG_DOMAIN_${i}`; + const domainConfig = config[domainKey]; + + //// // console.log(`🌐 Checking ${domainKey}:`, domainConfig, 'type:', typeof domainConfig); + + // Check if domainConfig has a value property and it's a non-empty string + let domainValue = ''; + if (domainConfig && typeof domainConfig === 'object' && domainConfig.value) { + domainValue = domainConfig.value; + } else if (typeof domainConfig === 'string') { + domainValue = domainConfig; + } + + //// // console.log(`🔤 Extracted domain value: "${domainValue}" type: ${typeof domainValue}`); + + // Only add domains that have actual content (non-empty string) + if (typeof domainValue === 'string' && domainValue.trim() !== '') { + //// // console.log(`✅ Adding domain: ${domainValue.trim()}`); + domains.push({ + number: i, + domain: domainValue.trim(), + key: domainKey + }); + } else { + //// // console.log(`⏭️ Skipping empty domain ${domainKey}`); + } + } + + //// // console.log('✅ Found configured domains:', domains); + + if (domains.length === 0) { + //// // console.log('⚠️ No domains found, returning fallback option'); + return [ + { value: '1', label: 'No domains configured - Configure domains in Network settings first' } + ]; + } + + // Create options with just domain names + const options = domains.map(domain => ({ + value: domain.number.toString(), + label: domain.domain + })); + + //// // console.log('✅ Generated domain options:', options); + return options; + + } catch (error) { + console.error('❌ Error fetching domains:', error); + return [ + { value: '1', label: 'Error loading domains - Check console for details' } + ]; + } + } + + // Get current app name + getCurrentAppName() { + // Try to get from current app data + if (this.currentApp) { + return this.currentApp; + } + + // Try to get from URL + const urlParams = new URLSearchParams(window.location.search); + const appName = urlParams.get('app'); + if (appName) { + return appName; + } + + // Try to get from current path + const pathParts = window.location.pathname.split('/'); + const appIndex = pathParts.indexOf('app'); + if (appIndex !== -1 && pathParts[appIndex + 1]) { + return pathParts[appIndex + 1]; + } + + return 'unknown'; + } + + // Initialize port managers after DOM is ready + async initializePortManagers() { + //// // console.log('🔌 Looking for port manager containers...'); + const portContainers = document.querySelectorAll('.port-manager-container'); + //// // console.log(`🔌 Found ${portContainers.length} port manager containers`); + + // Group port containers by app + const appPortContainers = {}; + for (const container of portContainers) { + const appName = container.dataset.appName; + if (!appPortContainers[appName]) { + appPortContainers[appName] = []; + } + appPortContainers[appName].push(container); + } + + // Create one consolidated port manager per app + for (const [appName, containers] of Object.entries(appPortContainers)) { + //// // console.log(`🔌 Creating consolidated port manager for app: ${appName} with ${containers.length} port fields`); + + try { + // Get all port configurations for this app + const appConfig = this.getCurrentAppConfig(); + const allPortConfigs = this.getAllPortConfigs(appConfig, appName); + + // Create consolidated port manager + const portManager = new PortManager(); + const html = portManager.generateHTML(appName, allPortConfigs); + + // Replace the first container with the consolidated port manager + const firstContainer = containers[0]; + firstContainer.innerHTML = html; + + // Hide other port containers (PORT_2, PORT_3, etc.) and their labels + for (let i = 1; i < containers.length; i++) { + const container = containers[i]; + const formField = container.closest('.form-field'); + if (formField) { + formField.style.display = 'none'; + } else { + container.style.display = 'none'; + } + } + + // Hide labels and help text for the first port container + this.hidePortFieldLabels(containers[0]); + + // Initialize port manager with services + await portManager.initialize(appName); + //// // console.log(`🔌 Consolidated port manager initialized successfully for ${appName}`); + } catch (error) { + console.error(`Error initializing consolidated port manager for ${appName}:`, error); + containers[0].innerHTML = `
Failed to initialize port manager: ${error.message}
`; + } + } + } + + // Hide labels and help text for port field containers + hidePortFieldLabels(container) { + const formField = container.closest('.form-field'); + if (formField) { + // Hide the label + const label = formField.querySelector('label.form-label'); + if (label) { + label.style.display = 'none'; + } + + // Hide the help text + const helpText = formField.querySelector('small.form-help'); + if (helpText) { + helpText.style.display = 'none'; + } + + // Hide any help icons + const helpIcons = formField.querySelectorAll('.help-icon'); + helpIcons.forEach(icon => { + icon.style.display = 'none'; + }); + } + } + + // Get all port configurations for an app + getAllPortConfigs(appConfig, appName) { + const portConfigs = []; + const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`; + Object.keys(appConfig).forEach(key => { + if (key.startsWith(portPrefix)) { + const configValue = appConfig[key]; + if (configValue && configValue.trim() !== '') { + portConfigs.push(configValue); + } + } + }); + // Return as array — one CFG__PORT_N value per element. The + // port manager iterates this directly so commas inside fields + // (multi-button labels / paths) stay meaningful and hand-editable. + return portConfigs; + } + + // Get current app configuration + getCurrentAppConfig() { + //// // console.log(`🔧 getCurrentAppConfig DEBUG: this.currentApp = "${this.currentApp}"`); + //// // console.log(`🔧 getCurrentAppConfig DEBUG: window.apps =`, window.apps ? `${window.apps.length} apps` : 'undefined'); + + if (window.apps && this.currentApp) { + const target = String(this.currentApp).toLowerCase(); + const app = window.apps.find(a => { + const slug = (a.command || '').split(' ').pop(); + return slug.toLowerCase() === target; + }); + return app?.config || {}; + } + return {}; + } + /** + * Collect configuration from form and format as pipe-separated string + */ + collectConfigFromForm(appName) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) { + console.warn(`ℹ️ No config form found for ${appName}, proceeding with no config override`); + return ''; + } + + const configPairs = []; + const inputs = form.querySelectorAll('input, select, textarea'); + + inputs.forEach(input => { + const name = input.name; + if (!name || !name.startsWith('CFG_')) return; + + // Skip the port-manager's aggregate hidden input — it's UI-only state + // (combined value of all ports for the live editor) and isn't a real key + // in any .config file. The actual per-port keys (CFG__PORT_1 …) are + // separate hidden inputs that we DO want to collect. + if (name.endsWith('_PORT_MANAGER')) return; + + let value; + if (input.type === 'checkbox') { + value = input.checked ? 'true' : 'false'; + } else { + value = input.value.trim(); + if (!value) return; + } + + // Encode `|` characters inside values so the bash splitter (IFS='|') doesn't + // fragment values that legitimately contain a pipe — port configs are the + // main case, format: `service|name|ext:int|access|...`. The bash side decodes + // `%7C` back to `|` after splitting. + const encodedValue = value.replace(/\|/g, '%7C'); + configPairs.push(`${name}=${encodedValue}`); + }); + + // `|` between pairs — `,` shows up in real config values (domain lists etc). + const collectedConfig = configPairs.join('|'); + console.log(`📋 Collected config for ${appName}:`, collectedConfig || '(empty - using defaults from apps.json)'); + return collectedConfig; + } + + // ----- Unsaved config-change tracking ------------------------------------- + // The app config panel is pure DOM until the user hits Apply/Update. These + // helpers track when a field differs from its rendered (saved) value, show a + // sticky bar offering Apply/Discard, and register an SPA nav guard so leaving + // the page with unsaved edits prompts first. + + // Snapshot of CFG_ field values, keyed by input name. Mirrors the filter in + // collectConfigFromForm so the two agree on what counts as a config field. + _readConfigFieldState(form) { + const state = {}; + form.querySelectorAll('input, select, textarea').forEach((input) => { + const name = input.name; + if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return; + state[name] = (input.type === 'checkbox') ? (input.checked ? 'true' : 'false') : input.value; + }); + return state; + } + + _getDirtyConfigFields() { + if (!this._dirtyAppName || !this._configSnapshot) return []; + const form = document.getElementById(`app-form-${this._dirtyAppName}`); + if (!form) return []; + const current = this._readConfigFieldState(form); + return Object.keys(current).filter((name) => current[name] !== (this._configSnapshot[name] ?? '')); + } + + _isConfigDirty() { + return this._getDirtyConfigFields().length > 0; + } + + // Called once per config-panel render: snapshots the saved state, wires the + // change listener + sticky bar, and (re)registers the nav guard. Only tracks + // installed apps — for a fresh install the Install button is already the + // "apply" action, so a dirty bar would just be noise. + wireConfigDirtyTracking(appName) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + const app = (window.apps || []).find((a) => + (a.command || '').endsWith(` ${appName}`) || + (a.name && a.name.toLowerCase() === appName.toLowerCase()) + ); + if (!app || !app.installed) { + this._clearConfigDirty(); + return; + } + + this._dirtyAppName = appName; + this._configSnapshot = this._readConfigFieldState(form); + + if (form.dataset.dirtyWired !== '1') { + form.dataset.dirtyWired = '1'; + const onEdit = () => this._refreshDirtyBar(); + form.addEventListener('input', onEdit); + form.addEventListener('change', onEdit); + } + + this._ensureDirtyBar(appName, form); + + // beforeunload covers tab close / refresh / external nav — the browser + // shows its own generic prompt. Registered once for the page lifetime. + if (!this._beforeUnloadWired) { + this._beforeUnloadWired = true; + window.addEventListener('beforeunload', (e) => { + if (this._isConfigDirty()) { e.preventDefault(); e.returnValue = ''; } + }); + } + + // SPA route changes route through this guard (see spa.js navigate()). + window.__appConfigNavGuard = (targetPath) => this._appConfigNavGuard(targetPath); + + this._refreshDirtyBar(); + } + + // Build (or rebuild) the sticky bar at the bottom of the config form. + _ensureDirtyBar(appName, form) { + const stale = document.getElementById('config-dirty-bar'); + if (stale) stale.remove(); + + const bar = document.createElement('div'); + bar.id = 'config-dirty-bar'; + bar.className = 'config-dirty-bar'; + bar.style.display = 'none'; + bar.innerHTML = ` + + + + + + + + + + + + `; + // Sits in normal flow between the config content and the action buttons. + const actions = form.querySelector('.config-actions'); + if (actions) { + form.insertBefore(bar, actions); + } else { + form.appendChild(bar); + } + + bar.querySelector('.config-dirty-discard').addEventListener('click', () => this._discardConfigChanges()); + bar.querySelector('.config-dirty-apply').addEventListener('click', () => this.installApp(appName)); + } + + _refreshDirtyBar() { + const bar = document.getElementById('config-dirty-bar'); + if (!bar) return; + const count = this._getDirtyConfigFields().length; + if (count === 0) { + bar.style.display = 'none'; + return; + } + const label = bar.querySelector('.config-dirty-count'); + if (label) label.textContent = `${count} unsaved change${count === 1 ? '' : 's'}`; + bar.style.display = 'flex'; + } + + // Revert every field to its snapshot value, then re-fire change/input so + // dependent UI (showWhen visibility, etc.) reconciles. + _discardConfigChanges() { + if (!this._dirtyAppName || !this._configSnapshot) return; + const form = document.getElementById(`app-form-${this._dirtyAppName}`); + if (!form) return; + form.querySelectorAll('input, select, textarea').forEach((input) => { + const name = input.name; + if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return; + if (!(name in this._configSnapshot)) return; + const orig = this._configSnapshot[name]; + if (input.type === 'checkbox') { + input.checked = (orig === 'true'); + } else { + input.value = orig; + } + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + this._refreshDirtyBar(); + } + + // Drop the dirty state without touching the form — used when the changes are + // being applied (the form is about to be replaced) or discarded on leave. + _clearConfigDirty() { + this._configSnapshot = null; + this._dirtyAppName = null; + window.__appConfigNavGuard = null; + const bar = document.getElementById('config-dirty-bar'); + if (bar) bar.style.display = 'none'; + } + + // SPA nav guard body — returns 'proceed' | 'stay'. 'apply' kicks off the + // normal apply flow and stays put (apply navigates to the tasks view itself). + async _appConfigNavGuard() { + if (!this._isConfigDirty()) return 'proceed'; + const appName = this._dirtyAppName; + const decision = await this._confirmLeaveUnsaved(appName); + if (decision === 'apply') { + this.installApp(appName); + return 'stay'; + } + if (decision === 'discard') { + this._clearConfigDirty(); + return 'proceed'; + } + return 'stay'; + } + + // Apply / Discard / Stay prompt. Resolves with the chosen action; closing + // via the X or backdrop resolves 'stay' (the safe default). + _confirmLeaveUnsaved(appName) { + let displayName = appName; + const app = (window.apps || []).find((a) => + (a.command || '').endsWith(` ${appName}`) || + (a.name && a.name.toLowerCase() === appName.toLowerCase()) + ); + if (app && app.name) displayName = app.name.split(' - ')[0].trim(); + + return new Promise((resolve) => { + let decided = false; + const finish = (val, modal) => { + if (decided) return; + decided = true; + if (modal) modal.close(); + resolve(val); + }; + window.openEoModal({ + id: 'config-unsaved-modal', + size: 'sm', + eyebrow: '⚠ Unsaved changes', + title: displayName, + desc: 'You have configuration changes that haven’t been applied.', + body: ` +
+
+

Apply before you go?

+

Apply runs the update now. Discard throws the edits away. Stay keeps you on this page.

+
+
`, + actions: [ + { label: 'Apply', variant: 'primary', onClick: (m) => finish('apply', m) }, + { label: 'Discard', variant: 'secondary', onClick: (m) => finish('discard', m) }, + { label: 'Stay', variant: 'secondary', onClick: (m) => finish('stay', m) } + ], + onClose: () => { if (!decided) { decided = true; resolve('stay'); } } + }); + }); + } + + // Update service buttons sidebar with service data from apps-services.json + async updateServiceButtonsSidebar(app, appName) { + if (!window.serviceButtons) return; + + try { + // Update sidebar using the new ServiceButtons API + await window.serviceButtons.updateSidebar(appName); + } catch (error) { + console.error('Error updating service buttons sidebar:', error); + } + } + + // Show service popup on hover + async showServicePopup(event, appName) { + if (!window.serviceButtons) return; + + const popup = document.getElementById(`service-popup-${appName}`); + if (!popup) return; + + try { + // Load services if not already loaded + if (window.serviceButtons.services.length === 0) { + await window.serviceButtons.loadServices(); + } + + // Generate buttons HTML + const buttonsHTML = await window.serviceButtons.generateButtonsHTML(appName); + const content = popup.querySelector('.service-popup-content'); + if (content) { + content.innerHTML = buttonsHTML; + } + + // Show popup + popup.style.display = 'block'; + } catch (error) { + console.error('Error showing service popup:', error); + } + } + + // Hide service popup + hideServicePopup() { + const popups = document.querySelectorAll('.service-popup'); + popups.forEach(popup => { + popup.style.display = 'none'; + }); + } + + async installApp(appName) { + const installedApp = (window.apps || []).find(a => + (a.command || '').endsWith(` ${appName}`) || a.name === appName + ); + const isInstalled = !!(installedApp && installedApp.installed); + + if (isInstalled) { + this.showUpdateConfirmModal(appName); + return; + } + + if (await this.shouldRecommendGluetun(appName, installedApp)) { + this.showGluetunRecommendModal(appName, installedApp); + return; + } + + return this.executeInstall(appName, false); + } + + async shouldRecommendGluetun(appName, appData) { + if (appName === 'gluetun') return false; + if (this.checkServiceInstalled('gluetun')) return false; + const overrideOn = this.checkRequirementEnabled?.('GLUETUN_FOR_ALL'); + if (overrideOn) return true; + if (!this._gluetunEligiblePromise) { + this._gluetunEligiblePromise = (async () => { + try { + const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' }); + if (!r.ok) return new Set(); + const j = await r.json(); + return new Set((j.categories || []).map((c) => String(c).toLowerCase())); + } catch { return new Set(); } + })(); + } + const allow = await this._gluetunEligiblePromise; + const cat = String(appData?.category || '').toLowerCase(); + return allow.has(cat); + } + + showGluetunRecommendModal(appName, appData) { + const existing = document.getElementById('gluetun-recommend-modal'); + if (existing) existing.remove(); + + const displayName = appData?.name?.split(' - ')[0]?.trim() || appName; + let icon = appData?.icon || `/icons/apps/${appName}.svg`; + if (icon && !icon.startsWith('/')) icon = '/' + icon; + + const bodyHtml = ` +
+
+ + + +
+
+

Apps in this category usually benefit from VPN routing

+

Without Gluetun, this app's outbound traffic uses your real public IP — exposing activity to ISPs, copyright trackers, and the destination services.

+
+
`; + + window.openEoModal({ + id: 'gluetun-recommend-modal', + size: 'sm', + icon, + iconAlt: displayName, + eyebrow: 'About to install', + title: displayName, + desc: 'VPN routing is recommended for this app.', + body: bodyHtml, + actions: [ + { label: 'Install Gluetun first', variant: 'primary', onClick: (modal) => { + modal.close(); + this.showAppDetailWithConfig('gluetun'); + }}, + { label: 'Continue without VPN', variant: 'secondary', onClick: (modal) => { + modal.close(); + this.executeInstall(appName, false); + }} + ] + }); + } + + // Per-app required-field map keyed by lowercased app slug. Add entries + // here as new apps grow required inputs. Each value is { keys, message } + // where `keys` is the CFG_* names that must be non-empty. + getRequiredConfigKeys(appName) { + const slug = (appName || '').toLowerCase(); + const map = { + traefik: ['CFG_TRAEFIK_EMAIL'] + }; + return map[slug] || []; + } + + validateRequiredConfig(appName) { + const required = this.getRequiredConfigKeys(appName); + if (required.length === 0) return { ok: true, missing: [] }; + const missing = []; + for (const key of required) { + const input = document.getElementById(`config-${key}`); + if (!input) continue; + const v = (input.value || '').trim(); + if (!v || v === 'changeme' || v === 'changeme.com') missing.push({ key, input }); + } + return { ok: missing.length === 0, missing }; + } + + flashRequiredFields(missing) { + if (!missing.length) return; + if (window.appTabbedManager && typeof window.appTabbedManager.switchTab === 'function') { + window.appTabbedManager.switchTab('config'); + } + missing.forEach(({ input }, idx) => { + input.classList.add('field-required-error'); + input.setAttribute('title', 'Required'); + const clear = () => { + input.classList.remove('field-required-error'); + input.removeAttribute('title'); + input.removeEventListener('input', clear); + input.removeEventListener('change', clear); + }; + input.addEventListener('input', clear); + input.addEventListener('change', clear); + if (idx === 0) { + setTimeout(() => { + input.scrollIntoView({ behavior: 'smooth', block: 'center' }); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }, 200); + } + }); + } + + async executeInstall(appName, resetNetwork) { + const validation = this.validateRequiredConfig(appName); + if (!validation.ok) { + this.flashRequiredFields(validation.missing); + const labels = validation.missing.map(({ key }) => + key.replace(/^CFG_[A-Z0-9]+_/, '').replace(/_/g, ' ').toLowerCase() + ).join(', '); + const sys = (typeof window.ensureNotificationSystem === 'function') + ? window.ensureNotificationSystem() + : window.notificationSystem; + if (sys && typeof sys.show === 'function') { + sys.show(`Missing required field${validation.missing.length > 1 ? 's' : ''}: ${labels}`, 'error'); + } + return; + } + + // Immediately disable buttons using appTabbedManager for consistency + if (window.appTabbedManager) { + window.appTabbedManager.disableAppButtons(appName, 'install'); + } else { + this.disableInstallButton(appName, 'install'); + } + + // Initialize task system if not available + if (!window.tasksManager || !window.tasksManager.router) { + try { + await this.loadTaskSystem(); + } catch (error) { + console.error(`❌ Failed to initialize task system:`, error); + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableInstallButton(appName); + } + } + } + + if (window.tasksManager && window.tasksManager.router) { + try { + // Collect configuration from form + const config = this.collectConfigFromForm(appName); + + // Create installation task + const task = await window.tasksManager.router.routeAction('install', { + appName: appName, + config: config, + resetNetwork: resetNetwork + }); + + // Changes are being applied — clear the unsaved-changes state so the + // switch to the tasks view isn't caught by the leave-confirm guard. + this._clearConfigDirty(); + + // Show success message and switch to tasks + this.addSuccessLog(`Installation task created for ${appName}. Switching to tasks view...`); + + // Switch to tasks view to show the installation progress with auto-loaded task + setTimeout(() => { + if (window.appTabbedManager) { + // Switch to tasks tab within current app page + window.appTabbedManager.switchTab('tasks'); + // Auto-expand the created task + setTimeout(() => { + if (task && window.appTabbedManager.tasksManager) { + window.appTabbedManager.tasksManager.highlightedTaskId = task.id; + window.appTabbedManager.tasksManager.renderTasks(); + } + }, 500); + } else if (window.librePortalSPA) { + // Fallback: navigate to app with tasks tab + const taskUrl = task ? `/app?=${appName}&tab=tasks&task=${task.id}` : `/app?=${appName}&tab=tasks`; + window.librePortalSPA.navigateTo(taskUrl); + } else if (window.navigateToRoute) { + window.navigateToRoute(`app?=${appName}&tab=tasks${task ? `&task=${task.id}` : ''}`); + } + }, 1000); + + } catch (error) { + this.addErrorLog(`Failed to create installation task: ${error.message}`); + // Re-enable buttons on error + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableInstallButton(appName); + } + } + } else { + // Fallback to original simulation if task system not available + //// // console.log(`⚠️ Task system not available, using fallback for ${appName}...`); + //// // console.log(`🔍 Debug info:`, { + //tasksManager: !!window.tasksManager, + //router: !!(window.tasksManager && window.tasksManager.router), + //windowTasksManager: window.tasksManager + //}); + + this.addInfoLog(`Starting installation of ${appName}...`); + + // Simulate installation process + setTimeout(() => { + this.addSuccessLog(`Installation completed successfully!`); + // Re-enable buttons after simulation completes + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableInstallButton(appName); + } + }, 2000); + } + } + + /** + * Initialize task system on demand + */ + async loadTaskSystem() { + try { + //// // console.log(`🔧 Loading task system components...`); + + // Only load scripts if they're not already loaded + const scripts = [ + { name: 'TaskManager', src: '/js/components/task/task-manager.js' }, + { name: 'TaskCommands', src: '/js/components/task/task-commands.js' }, + { name: 'TaskActions', src: '/js/components/task/task-actions.js' }, + { name: 'TaskRouter', src: '/js/components/task/task-router.js' }, + { name: 'TasksManager', src: '/js/components/tasks/tasks-manager.js' } + ]; + + for (const script of scripts) { + if (!window[script.name]) { + //// // console.log(`📦 Loading ${script.name}...`); + await this.loadScript(script.src); + } else { + //// // console.log(`✅ ${script.name} already loaded`); + } + } + + // Initialize tasks manager if not already initialized + if (window.TasksManager && !window.tasksManager) { + //// // console.log(`🔧 Initializing TasksManager instance...`); + try { + window.tasksManager = new TasksManager(); + //// // console.log(`✅ TasksManager constructor completed`); + //// // console.log(`🔧 TasksManager instance:`, window.tasksManager); + + if (typeof window.tasksManager.init === 'function') { + //// // console.log(`🔧 Calling TasksManager.init()...`); + await window.tasksManager.init(); + //// // console.log(`✅ TasksManager.init() completed`); + } else { + //// // console.log(`⚠️ TasksManager.init() is not a function`); + } + } catch (error) { + console.error(`❌ Failed to initialize TasksManager:`, error); + throw error; + } + } else if (window.tasksManager) { + //// // console.log(`✅ TasksManager instance already exists`); + } else { + //// // console.log(`❌ TasksManager class not available`); + } + + //// // console.log(`✅ Task system components loaded and initialized`); + } catch (error) { + console.error(`❌ Failed to load task system:`, error); + throw error; + } + } + + /** + * Load script helper + */ + loadScript(src) { + return new Promise((resolve, reject) => { + // Check if script is already loaded + if (document.querySelector(`script[src="${src}"]`)) { + resolve(); + return; + } + + const script = document.createElement('script'); + script.src = src; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + // Terminal logging functions with old styling + addLogMessage(message, type = 'info') { + const messageLog = document.getElementById('message-log'); + if (!messageLog) return; + + const timestamp = new Date().toLocaleTimeString(); + const messageLine = document.createElement('div'); + messageLine.className = `log-entry ${type}`; + messageLine.innerHTML = `[${timestamp}] ${message}`; + + messageLog.appendChild(messageLine); + // Only scroll to bottom if user hasn't scrolled up + if (messageLog.scrollTop >= messageLog.scrollHeight - messageLog.clientHeight - 50) { + messageLog.scrollTop = messageLog.scrollHeight; + } + } + + addSuccessLog(message) { + this.addLogMessage(message, 'success'); + } + + addErrorLog(message) { + this.addLogMessage(message, 'error'); + } + + addWarningLog(message) { + this.addLogMessage(message, 'warning'); + } + + addInfoLog(message) { + this.addLogMessage(message, 'info'); + } + + clearConsole() { + const messageLog = document.getElementById('message-log'); + if (!messageLog) return; + + messageLog.innerHTML = '
[' + new Date().toLocaleTimeString() + '] Console cleared...
'; + messageLog.scrollTop = 0; // Force to top + } + + initializeConsole() { + const messageLog = document.getElementById('message-log'); + if (messageLog) { + messageLog.scrollTop = 0; // Force scroll to top + messageLog.innerHTML = ''; // Clear any existing content + // Add initial message after a tiny delay to ensure it renders at top + setTimeout(() => { + this.addInfoLog('Application configuration loaded successfully'); + }, 100); + } + } + + // Public entry point bound to the "Uninstall App" button. Doesn't kick + // anything off itself — it only opens the confirmation modal so the user + // has to explicitly approve a destructive action (matches the create + // backup / update modals' UX). Actual work happens in executeUninstall. + uninstallApp(appName) { + this.showUninstallConfirmModal(appName); + } + + async executeUninstall(appName, deleteImage = false, deleteTasks = false) { + // Track the task id this call spawns so the post-completion handler + // can delete it (the bash side skips the in-flight task to avoid a + // race with the processor still writing its status). + this._pendingTaskCleanup = this._pendingTaskCleanup || new Map(); + // Immediately disable buttons using appTabbedManager for consistency + if (window.appTabbedManager) { + window.appTabbedManager.disableAppButtons(appName, 'uninstall'); + } else { + // Fallback to our own button disabling if appTabbedManager not available + this.disableUninstallButton(appName, 'uninstall'); + } + + // Check if tasks system is available, initialize if needed + //// // console.log(`🔍 Checking task system availability for uninstall...`); + //// // console.log(`🔍 window.tasksManager:`, !!window.tasksManager); + //// // console.log(`🔍 window.tasksManager.router:`, !!(window.tasksManager && window.tasksManager.router)); + + // Initialize task system if not available + if (!window.tasksManager || !window.tasksManager.router) { + //// // console.log(`🔧 Initializing task system...`); + try { + // Load task system components + await this.loadTaskSystem(); + //// // console.log(`✅ Task system initialized successfully`); + } catch (error) { + console.error(`❌ Failed to initialize task system:`, error); + // Re-enable buttons on error + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableUninstallButton(appName); + } + // Continue with fallback if initialization fails + } + } + + if (window.tasksManager && window.tasksManager.router) { + //// // console.log(`🚀 Uninstalling ${appName} via task system...`); + + try { + // Create uninstallation task + const task = await window.tasksManager.router.routeAction('uninstall', { + appName: appName, + deleteImage: deleteImage, + deleteTasks: deleteTasks + }); + if (deleteTasks && task && task.id) { + this._pendingTaskCleanup.set(task.id, appName); + } + + // Show success message and switch to tasks + this.addSuccessLog(`Uninstallation task created for ${appName}. Switching to tasks view...`); + + // Switch to tasks view to show the uninstallation progress with auto-loaded task + setTimeout(() => { + if (window.appTabbedManager) { + // Switch to tasks tab within current app page + window.appTabbedManager.switchTab('tasks'); + // Auto-expand the created task + setTimeout(() => { + if (task && window.appTabbedManager.tasksManager) { + window.appTabbedManager.tasksManager.highlightedTaskId = task.id; + window.appTabbedManager.tasksManager.renderTasks(); + } + }, 500); + } else if (window.librePortalSPA) { + // Fallback: navigate to app with tasks tab + const taskUrl = task ? `/app?=${appName}&tab=tasks&task=${task.id}` : `/app?=${appName}&tab=tasks`; + // console.log(`🔄 Navigating to app tasks with uninstall task: ${task?.id}`); + window.librePortalSPA.navigateTo(taskUrl); + } else if (window.navigateToRoute) { + window.navigateToRoute(`app?=${appName}&tab=tasks${task ? `&task=${task.id}` : ''}`); + } + }, 1000); + + } catch (error) { + this.addErrorLog(`Failed to create uninstallation task: ${error.message}`); + // Re-enable buttons on error + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableUninstallButton(appName); + } + } + } else { + // Fallback to original simulation if task system not available + //// // console.log(`⚠️ Task system not available, using fallback for ${appName}...`); + + this.addInfoLog(`Starting uninstallation of ${appName}...`); + + // Simulate uninstallation process + setTimeout(() => { + this.addSuccessLog(`Uninstallation completed successfully!`); + // Re-enable buttons after simulation completes + if (window.appTabbedManager) { + window.appTabbedManager.enableAppButtons(appName); + } else { + this.enableUninstallButton(appName); + } + }, 1500); + } + } + + // Enhance scrollbar dynamically for tabs-list + enhanceTabsScrollbar() { + const tabsList = document.querySelector('.tabs-list'); + if (tabsList) { + // Check if scrolling is needed + const isScrollable = tabsList.scrollWidth > tabsList.clientWidth; + + if (isScrollable) { + // Add data attribute for enhanced styling + tabsList.setAttribute('data-scrollable', 'true'); + //// // console.log('✅ Enhanced tabs scrollbar for scrollable content'); + } else { + // Remove attribute if not scrollable + tabsList.removeAttribute('data-scrollable'); + //// // console.log('📝 Tabs list not scrollable, using default styling'); + } + + // Monitor for content changes + const observer = new MutationObserver(() => { + setTimeout(() => this.enhanceTabsScrollbar(), 100); + }); + + observer.observe(tabsList, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style'] + }); + } + } + + // Helper methods for button state management + disableInstallButton(appName, action) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + // Find the install/update button + const button = form.querySelector('.btn-install, .btn-manage'); + if (!button) return; + + // Disable button and add spinner + button.disabled = true; + button.classList.add('disabled', 'task-running'); + + // Add loading spinner if not already present + if (!button.querySelector('.spinner')) { + const originalContent = button.innerHTML; + button.dataset.originalContent = originalContent; + + // Extract text content from original HTML (remove icons/SVGs) + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = originalContent; + const textContent = tempDiv.textContent || tempDiv.innerText || originalContent; + + button.innerHTML = ` + + ${textContent.trim()} + `; + } + + // console.log(`🔍 Install button disabled for ${appName} during ${action}`); + } + + enableInstallButton(appName) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + // Find the install/update button + const button = form.querySelector('.btn-install, .btn-manage'); + if (!button) return; + + // Re-enable button and restore original content + button.disabled = false; + button.classList.remove('disabled', 'task-running'); + + // Restore original content if it was saved + if (button.dataset.originalContent) { + button.innerHTML = button.dataset.originalContent; + delete button.dataset.originalContent; + } + + // Remove any spinners + const spinners = button.querySelectorAll('.spinner'); + spinners.forEach(spinner => spinner.remove()); + + // console.log(`🔍 Install button enabled for ${appName}`); + } + + disableUninstallButton(appName, action) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + // Find the uninstall button + const button = form.querySelector('.btn-uninstall'); + if (!button) return; + + // Disable button and add spinner + button.disabled = true; + button.classList.add('disabled', 'task-running'); + + // Add loading spinner if not already present + if (!button.querySelector('.spinner')) { + const originalContent = button.innerHTML; + button.dataset.originalContent = originalContent; + + // Extract text content from original HTML (remove icons/SVGs) + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = originalContent; + const textContent = tempDiv.textContent || tempDiv.innerText || originalContent; + + button.innerHTML = ` + + ${textContent.trim()} + `; + } + + // console.log(`🔍 Uninstall button disabled for ${appName} during ${action}`); + } + + enableUninstallButton(appName) { + const form = document.getElementById(`app-form-${appName}`); + if (!form) return; + + // Find the uninstall button + const button = form.querySelector('.btn-uninstall'); + if (!button) return; + + // Re-enable button and restore original content + button.disabled = false; + button.classList.remove('disabled', 'task-running'); + + // Restore original content if it was saved + if (button.dataset.originalContent) { + button.innerHTML = button.dataset.originalContent; + delete button.dataset.originalContent; + } + + // Remove any spinners + const spinners = button.querySelectorAll('.spinner'); + spinners.forEach(spinner => spinner.remove()); + + // console.log(`🔍 Uninstall button enabled for ${appName}`); + } + + // Static methods for global access + static showAppsList(category = null) { + if (window.appsManager) { + window.appsManager.showAppsList(category); + } + } + + static showAppDetail(appName) { + if (window.appsManager) { + window.appsManager.showAppDetail(appName); + } + } + + showUpdateConfirmModal(appName) { + let displayName = appName; + let icon = `/icons/apps/${appName}.svg`; + if (window.apps) { + const app = window.apps.find(a => + (a.command || '').endsWith(` ${appName}`) || + (a.name && a.name.toLowerCase() === appName.toLowerCase()) + ); + if (app) { + displayName = app.name; + if (app.icon) icon = app.icon.startsWith('/') ? app.icon : '/' + app.icon; + } + } + + const bodyHtml = ` +
+
+ + + + + +
+
+

Container will restart

+

The configuration will be reapplied and the container restarted to pick up changes.

+
+
+ `; + + window.openEoModal({ + id: 'update-confirm-modal', + size: 'sm', + icon, + iconAlt: displayName, + eyebrow: 'Apply Configuration', + title: displayName, + desc: 'Reapply config and restart the container.', + body: bodyHtml, + actions: [ + { label: 'Update', variant: 'primary', onClick: (modal) => { + const cb = modal.contentEl.querySelector('#update-reset-network'); + const resetNetwork = !!(cb && cb.checked); + modal.close(); + this.executeInstall(appName, resetNetwork); + }}, + { label: 'Cancel', variant: 'secondary' } + ] + }); + } + + // Confirmation modal for the destructive "Uninstall App" action. + showUninstallConfirmModal(appName) { + let displayName = appName; + let icon = `/icons/apps/${appName}.svg`; + let app = null; + if (window.apps) { + app = window.apps.find(a => + (a.command || '').endsWith(` ${appName}`) || + (a.name && a.name.toLowerCase() === appName.toLowerCase()) + ); + if (app) { + displayName = app.name; + if (app.icon) icon = app.icon.startsWith('/') ? app.icon : '/' + app.icon; + } + } + + // Cascade warning: if the app declares related apps (via CFG__RELATED_APPS) + // and any of them are currently installed, the uninstall will take them down + // too — surface that up-front so the user isn't surprised. + const upper = appName.toUpperCase().replace(/-/g, '_'); + const relatedRaw = app?.config?.[`CFG_${upper}_RELATED_APPS`]?.value + || app?.config?.[`CFG_${upper}_RELATED_APPS`] + || ''; + const relatedNames = String(relatedRaw) + .split(',').map(s => s.trim()).filter(Boolean) + .map(slug => { + const rel = (window.apps || []).find(a => + (a.command || '').endsWith(` ${slug}`) || + (a.name && a.name.toLowerCase() === slug.toLowerCase()) + ); + return rel && rel.installed ? (rel.name || slug) : null; + }) + .filter(Boolean); + const cascadeBlock = relatedNames.length + ? `
+
+

Cascade removal

+

Will also uninstall: ${relatedNames.map(n => `${n}`).join(', ')}.

+
+
` + : ''; + + const bodyHtml = ` + ${cascadeBlock} +
+
+ + + + + +
+
+

This cannot be undone

+

The container will be stopped and its data removed.

+
+
+ + `; + + window.openEoModal({ + id: 'uninstall-confirm-modal', + size: 'sm', + icon, + iconAlt: displayName, + eyebrow: 'Uninstall', + title: displayName, + desc: 'Confirm to remove this application.', + body: bodyHtml, + actions: [ + { label: 'Uninstall', variant: 'danger', onClick: (modal) => { + const cb1 = modal.contentEl.querySelector('#uninstall-delete-image'); + const cb2 = modal.contentEl.querySelector('#uninstall-delete-tasks'); + const deleteImage = !!(cb1 && cb1.checked); + const deleteTasks = !!(cb2 && cb2.checked); + modal.close(); + this.executeUninstall(appName, deleteImage, deleteTasks); + }}, + { label: 'Cancel', variant: 'secondary' } + ] + }); + } +} + +// Service Buttons Manager - handles service URL buttons from port config + apps-services.json +class ServiceButtons { + constructor() { + this.services = []; + } + + // Load services from apps-services.json + async loadServices() { + try { + const response = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); + const data = await response.json(); + this.services = data.services || []; + return this.services; + } catch (error) { + console.error('Error loading services:', error); + return []; + } + } + + // Parse port configuration from app config + parsePortConfig(appName) { + const ports = []; + // Get app config from window.apps if available + const app = window.apps?.find(a => a.name === appName || a.command.includes(appName)); + if (!app || !app.config) return ports; + + const appConfig = app.config; + const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`; + + Object.keys(appConfig).forEach(key => { + if (key.startsWith(portPrefix)) { + const configValue = appConfig[key]; + if (configValue && configValue.trim() !== '') { + const parts = configValue.split('|'); + if (parts.length >= 8) { + const isNineCol = parts.length >= 9; + ports.push({ + service: parts[0], + name: parts[1], + external: parts[2].split(':')[0], + internal: parts[2].split(':')[1], + access: parts[3], + protocol: parts[4], + loginRequired: isNineCol ? parts[5] === 'true' : false, + traefikManaged: isNineCol ? parts[6] === 'true' : parts[5] === 'true', + buttonEnabled: isNineCol ? parts[7] === 'true' : parts[6] === 'true', + buttonText: isNineCol ? parts[8] : parts[7] + }); + } + } + } + }); + + return ports; + } + + // Get services for a specific app from config, then fill in real IPs/ports from apps-services.json + async getServicesForApp(appName) { + const portConfig = this.parsePortConfig(appName); + // console.log(`📦 Port config for ${appName}:`, portConfig); + const portServices = portConfig.filter(p => p.buttonEnabled); + // console.log(`✅ Enabled services for ${appName}:`, portServices); + + if (portServices.length === 0) return []; + + // Load services from apps-services.json if not already loaded + if (this.services.length === 0) { + await this.loadServices(); + } + // console.log(`🌐 Loaded ${this.services.length} services from apps-services.json`); + + // Merge port config with real data from apps-services.json + return portServices.map(portService => { + // Find matching service in apps-services.json + const serviceData = this.services.find(s => + s.app === appName && + s.name === portService.name + ); + + // console.log(`🔗 Matching service for ${portService.name}:`, serviceData); + + const merged = { + ...portService, + serviceIP: serviceData?.serviceIP || '', + externalPort: serviceData?.externalPort || portService.external, + internalPort: serviceData?.internalPort || portService.internal, + serverIP: serviceData?.serverIP || '', + externalURL: serviceData?.externalURL || '', + internalURL: serviceData?.internalURL || '' + }; + // console.log(`🎯 Merged service data:`, merged); + return merged; + }); + } + + getServiceIcon(serviceName) { + const icons = { + 'webui': '🌐', + 'ssh': '🔒', + 'dns': '🌍', + 'api': '🔌', + 'admin': '⚙️', + 'dashboard': '📊', + 'default': '🔗' + }; + return icons[serviceName.toLowerCase()] || icons['default']; + } + + // Generate HTML for service buttons + async generateButtonsHTML(appName) { + const appServices = await this.getServicesForApp(appName); + + if (appServices.length === 0) { + return ''; + } + + return appServices.map(service => { + // Use externalURL if available, otherwise construct from serverIP and port + let url; + const proto = ['http', 'https'].includes((service.protocol || '').toLowerCase()) ? service.protocol.toLowerCase() : 'http'; + if (service.externalURL) { + url = service.externalURL; + } else if (service.serverIP && service.externalPort) { + url = `${proto}://${service.serverIP}:${service.externalPort}`; + } else if (service.externalPort && service.externalPort !== 'random') { + url = `${proto}://localhost:${service.externalPort}`; + } else { + return ''; + } + + const icon = this.getServiceIcon(service.name); + const protectedClass = service.loginRequired ? ' protected' : ''; + const lockIcon = service.loginRequired ? this.lockIconSVG('Login required for this URL — credentials in Config → General → Logins.') : ''; + + return ` + + ${icon} + ${service.buttonText} + ${lockIcon} + + + `; + }).join(''); + } + + lockIconSVG(title) { + return ` + + `; + } + + // Update service buttons in app-header + async updateSidebar(appName) { + const buttonsContainer = document.getElementById('service-buttons-container'); + if (!buttonsContainer) return; + + if (this.services.length === 0) { + await this.loadServices(); + } + + // Use apps-services.json as primary source — filter by app and buttonEnabled flag + const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http'; + const appServices = this.services.filter(s => s.app === appName && s.buttonEnabled === true); + + // Welcome chip — first slot, before the URL buttons. + const welcomeBtn = ` + `; + + if (appServices.length === 0) { + buttonsContainer.innerHTML = welcomeBtn; + buttonsContainer.style.display = 'flex'; + return; + } + + // Multi-button render via the shared expandServiceLinks() helper. + buttonsContainer.innerHTML = welcomeBtn + appServices.flatMap(s => { + const protectedClass = s.loginRequired ? ' protected' : ''; + const lockIcon = s.loginRequired ? this.lockIconSVG('Login required for this URL — credentials in Config → General → Logins.') : ''; + return window.expandServiceLinks(s).map(({ url, label }) => ` + + + + + + + ${label} + ${lockIcon} + + + `); + }).filter(Boolean).join(''); + + buttonsContainer.style.display = 'flex'; + } +} + +// Global instance +window.appsManager = new AppsManager(); +window.serviceButtons = new ServiceButtons(); + +// Toggle service trigger popup — one open at a time, click-outside to close +window.toggleServiceTrigger = (appName) => { + const clicked = document.getElementById(`service-trigger-${appName}`); + if (!clicked) return; + const isOpen = clicked.classList.contains('open'); + // Close all open triggers + document.querySelectorAll('.service-trigger.open').forEach(el => el.classList.remove('open')); + if (!isOpen) clicked.classList.add('open'); +}; + +document.addEventListener('click', () => { + document.querySelectorAll('.service-trigger.open').forEach(el => el.classList.remove('open')); +}); + +document.addEventListener('change', (e) => { + if (!e.target?.classList?.contains('advanced-fields-checkbox')) return; + const panel = e.target.closest('.panel-fields') || e.target.closest('.config-section') || document; + panel.querySelectorAll('.advanced-field').forEach(el => { + el.classList.toggle('is-hidden', !e.target.checked); + }); +}); diff --git a/containers/libreportal/frontend/js/components/app/port-manager.js b/containers/libreportal/frontend/js/components/app/port-manager.js new file mode 100755 index 0000000..87ab288 --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/port-manager.js @@ -0,0 +1,821 @@ +// Port Manager Component - Handles port configuration for apps +class PortManager { + constructor() { + this.ports = []; + this.availableServices = []; + this.appName = null; + } + + // Parse port configuration string into array of port objects + parsePortConfig(configInput) { + // Accepts either a single CFG__PORT_N string OR an array of + // them (preferred). The array form avoids the comma-join/split + // round-trip that would shred multi-button button_text/url_path + // values like "Speedtest,Results" and "/,/results/stats.php". + let portEntries; + if (Array.isArray(configInput)) { + portEntries = configInput.filter(s => s && String(s).trim() !== ''); + } else { + if (!configInput || String(configInput).trim() === '') return []; + portEntries = String(configInput).split(','); + } + const ports = []; + + portEntries.forEach(entry => { + const parts = entry.trim().split('|'); + // 12-col: parent|name|ext:int|access|proto|login|traefik|webui|label|url_path|subdomain|recommended + // 10-col: parent|name|ext:int|access|proto|login|traefik|webui|label|url_path + // 9-col: parent|name|ext:int|access|proto|login|traefik|webui|label + // 8-col: parent|name|ext:int|access|proto|traefik|webui|label (legacy, login defaults to false) + if (parts.length >= 8) { + const portMapping = parts[2].split(':'); + const external = portMapping[0] || ''; + const internal = portMapping[1] || ''; + + const isTenCol = parts.length >= 10; + const isNineCol = parts.length >= 9; + const isTwelveCol = parts.length >= 12; + // Recommended defaults to the webui flag when not stored on the row — + // matches the panel's "primary list" expectation for apps that haven't + // been migrated yet. + const buttonEnabled = isNineCol ? (parts[7] === 'true') : (parts[6] === 'true'); + ports.push({ + service: parts[0] || '', + name: parts[1] || '', + external: external, + internal: internal, + access: parts[3] || 'private', + protocol: parts[4] || 'tcp', + login_required: isNineCol ? (parts[5] === 'true') : false, + traefik_managed: isNineCol ? (parts[6] === 'true') : (parts[5] === 'true'), + button_enabled: buttonEnabled, + button_text: isNineCol ? (parts[8] || '') : (parts[7] || ''), + url_path: isTenCol ? (parts[9] || '') : '', + subdomain: isTwelveCol ? (parts[10] || '') : '', + recommended: isTwelveCol ? (parts[11] === 'true') : buttonEnabled + }); + } + }); + + return ports; + } + + // Generate port configuration string from array of port objects. + // Always emits the 12-col format (adds subdomain + recommended on top of the + // existing 10-col login_required + url_path schema) so saves forward-migrate + // any 8/9/10-col legacy entries automatically. + generatePortConfig(ports) { + return ports.map(port => { + const portMapping = `${port.external}:${port.internal}`; + const login = port.login_required ? 'true' : 'false'; + const urlPath = port.url_path || ''; + const subdomain = port.subdomain || ''; + const recommended = port.recommended ? 'true' : 'false'; + return `${port.service}|${port.name}|${portMapping}|${port.access}|${port.protocol}|${login}|${port.traefik_managed}|${port.button_enabled}|${port.button_text}|${urlPath}|${subdomain}|${recommended}`; + }).join(','); + } + + // Get available services for an app + async getAvailableServices(appName) { + //console.log(`🔌 PortManager: Getting services for app: ${appName}`); + try { + // Load apps data to get services for this app (with cache busting) + const timestamp = Date.now(); + const response = await fetch(`/data/apps/generated/apps.json?t=${timestamp}`); + if (!response.ok) { + //console.log(`🔌 Failed to fetch apps.json: ${response.status}`); + return []; + } + + const appsData = await response.json(); + //console.log(`🔌 Apps data loaded:`, appsData); + //console.log(`🔌 Available app names:`, appsData.apps.map(app => app.name)); + + let app = appsData.apps.find(a => a.name === appName); + //console.log(`🔌 Found app for ${appName}:`, app); + + // Try fuzzy matching if exact match fails + if (!app) { + const fuzzyApp = appsData.apps.find(a => + a.name.toLowerCase().includes(appName.toLowerCase()) || + appName.toLowerCase().includes(a.name.toLowerCase()) + ); + //console.log(`🔌 Fuzzy match for ${appName}:`, fuzzyApp); + if (fuzzyApp) { + app = fuzzyApp; + } + } + + if (app && app.services) { + //console.log(`🔌 Services found for ${appName}:`, app.services); + return app.services; + } + + //console.log(`🔌 No services found for ${appName}`); + return []; + } catch (error) { + console.error('Error loading services:', error); + return []; + } + } + + // Validate port configuration + validatePort(portData, allPorts) { + const errors = []; + + // Check required fields + if (!portData.service) errors.push('Service is required'); + if (!portData.internal) errors.push('Internal port is required'); + + // Check port format + if (portData.external && !portData.external.match(/^(random|\d+(:\d+)?)$/)) { + errors.push('External port must be "random" or in format "8080" or "8080:8081"'); + } + + if (portData.internal && !portData.internal.match(/^\d+$/)) { + errors.push('Internal port must be a valid port number'); + } + + // Check for conflicts (excluding current port) + const conflicts = allPorts.filter((port, index) => { + return port.internal === portData.internal && index !== allPorts.indexOf(portData); + }); + + if (conflicts.length > 0) { + errors.push(`Internal port ${portData.internal} conflicts with another service`); + } + + return errors; + } + + // Add new port + addPort() { + const newPort = { + service: '', + external: 'random', + internal: '', + access: 'private', + protocol: 'tcp', + url_accessible: false, + traefik_managed: true, + login_required: false, + label: '', + autoMatched: false // Track if service was auto-matched + }; + + // Auto-select service if only one is available + if (this.availableServices.length === 1) { + newPort.service = this.availableServices[0]; + newPort.autoMatched = true; + } + + this.ports.push(newPort); + return newPort; + } + + // Remove port with confirmation + async removePort(index) { + const port = this.ports[index]; + if (!port) return false; + + // Get app name from the port manager + const appName = this.appName || document.querySelector('.port-manager')?.dataset.app; + // Use internal port if available, otherwise show port number (index + 1) + const portDisplay = port.internal ? `#${port.internal}` : `#${index + 1}`; + + // Get app title from the apps data for better display + let appTitle = appName; // fallback to app name + try { + const response = await fetch(`/data/apps/generated/apps.json?t=${Date.now()}`); + if (response.ok) { + const appsData = await response.json(); + const app = appsData.apps.find(a => a.name === appName) || + appsData.apps.find(a => a.name.toLowerCase().includes(appName.toLowerCase())); + if (app) { + appTitle = app.title || appName; + } + } + } catch (error) { + //console.log('Could not fetch app title, using app name'); + } + + // Show confirmation dialog + const confirmed = await this.showConfirmation( + 'Remove Port', + `Are you sure you want to remove port ${portDisplay} from ${appTitle}?`, + 'Remove' + ); + + if (confirmed) { + this.ports.splice(index, 1); + return true; + } + + return false; + } + + // Show confirmation dialog + showConfirmation(title, message, confirmText) { + return new Promise((resolve) => { + // Create modal overlay + const modal = document.createElement('div'); + modal.className = 'port-manager-modal-overlay'; + modal.innerHTML = ` +
+
+

${title}

+ +
+
+

${message}

+
+ +
+ + `; + + // Add to page + document.body.appendChild(modal); + + // Handle events + const close = () => { + document.body.removeChild(modal); + resolve(false); + }; + + const confirm = () => { + document.body.removeChild(modal); + resolve(true); + }; + + modal.querySelector('.port-manager-modal-close').addEventListener('click', close); + modal.querySelector('.port-manager-cancel').addEventListener('click', close); + modal.querySelector('.port-manager-confirm').addEventListener('click', confirm); + modal.addEventListener('click', (e) => { + if (e.target === modal) close(); + }); + }); + } + + // Generate HTML for port manager + generateHTML(appName, configValue) { + // Parse existing configuration + this.ports = this.parsePortConfig(configValue); + + let html = ` +
+
+

Port Configuration

+
+ + +
+
+
+ `; + + // Generate port cards (simplified without individual headers) + this.ports.forEach((port, index) => { + html += this.generateSimplePortCard(port, index); + }); + + html += ` +
+ +
+ `; + + return html; + } + + // Generate simplified HTML for individual port card (with header) + generateSimplePortCard(port, index) { + return ` +
+
+
Port ${index + 1}
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ `; + } + + // Generate HTML for individual port card + generatePortCard(port, index) { + return ` +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ `; + } + + // Initialize port manager after HTML is generated + async initialize(appName) { + this.appName = appName; + this.availableServices = await this.getAvailableServices(appName); + + //console.log(`🔌 PortManager: Available services for ${appName}:`, this.availableServices); + //console.log(`🔌 PortManager: Number of services: ${this.availableServices.length}`); + + // Force full-width layout for port manager containers + this.forceFullWidthLayout(); + + // Populate service dropdowns with auto-matching + const serviceSelects = document.querySelectorAll('.port-service'); + serviceSelects.forEach(select => { + const index = parseInt(select.dataset.index); + const currentService = this.ports[index]?.service || ''; + + //console.log(`🔌 PortManager: Port ${index} current service: "${currentService}"`); + + // Clear existing options + select.innerHTML = ''; + + // Add service options + this.availableServices.forEach(service => { + const option = document.createElement('option'); + option.value = service; + option.textContent = service; + + // Auto-matching logic + let shouldAutoSelect = false; + let isAutoMatched = false; + + if (this.availableServices.length === 1 && !currentService) { + // Case 1: Only one service and no current service - auto-select + shouldAutoSelect = true; + isAutoMatched = true; + //console.log(`🔌 PortManager: Auto-selecting single service "${service}" for port ${index}`); + } else if (this.availableServices.length === 1 && currentService && currentService !== service) { + // Case 2: Only one service but current service doesn't match - auto-match + shouldAutoSelect = true; + isAutoMatched = true; + //console.log(`🔌 PortManager: Auto-matching service "${service}" (was "${currentService}") for port ${index}`); + } else if (this.availableServices.length === 1 && currentService === service) { + // Case 3: Single service and current service matches - still show auto-match indicator + shouldAutoSelect = true; + isAutoMatched = true; + //console.log(`🔌 PortManager: Single service matches "${service}" for port ${index} - showing auto-match indicator`); + } else if (this.availableServices.length > 1 && currentService === service) { + // Case 4: Multiple services and current service matches - normal selection + shouldAutoSelect = true; + isAutoMatched = false; + //console.log(`🔌 PortManager: Normal selection of service "${service}" for port ${index}`); + } + + if (shouldAutoSelect) { + option.selected = true; + // Update the port data to reflect the selected service + if (this.ports[index]) { + this.ports[index].service = service; + // Mark if this was auto-matched + this.ports[index].autoMatched = isAutoMatched; + } + } + + select.appendChild(option); + }); + + // Add visual indicator for auto-matched services + if (this.ports[index]?.autoMatched) { + select.style.borderColor = '#28a745'; // Green border for auto-matched + select.style.boxShadow = '0 0 0 2px rgba(40, 167, 69, 0.3)'; + + // Show the auto-match indicator next to the help icon + const autoMatchIndicator = select.parentElement.querySelector('.auto-match-indicator'); + if (autoMatchIndicator) { + autoMatchIndicator.style.display = 'inline-flex'; + } + } + }); + + // Add event listeners + this.attachEventListeners(); + + // Update all port fields after auto-selection + this.updateAllPortFields(); + } + + // Force full-width layout for port manager containers + forceFullWidthLayout() { + const portContainers = document.querySelectorAll('.form-field[id^="PORT_"]'); + portContainers.forEach(container => { + container.style.gridColumn = '1 / -1'; + container.style.width = '100%'; + }); + } + + // Attach event listeners + attachEventListeners() { + // Add port button + const addBtn = document.querySelector('.add-port-btn'); + if (addBtn) { + addBtn.addEventListener('click', () => { + const newPort = this.addPort(); + this.refreshPortList(); + }); + } + + // Remove port buttons + document.querySelectorAll('.remove-port-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + const index = parseInt(e.currentTarget.dataset.index); + const removed = await this.removePort(index); + if (removed) { + this.refreshPortList(); + } + }); + }); + + // Show-advanced toggle — flips `show-advanced` class on the port-manager root; + // CSS hides .port-field-advanced when the class isn't present. + document.querySelectorAll('.port-manager-show-advanced').forEach(cb => { + const mgr = cb.closest('.port-manager'); + const apply = () => { if (mgr) mgr.classList.toggle('show-advanced', cb.checked); }; + cb.addEventListener('change', apply); + apply(); + }); + + // Port field changes. Selector must include EVERY rendered class — fields not + // listed here silently no-op when the user edits them, so port name and button + // text edits were never persisted into the hidden CFG_*_PORT_N fields. + document.querySelectorAll( + '.port-service, .port-name, .port-external, .port-internal, ' + + '.port-access, .port-protocol, .port-traefik, ' + + '.port-button-enabled, .port-button-text, .port-url-path, ' + + '.port-recommended, .port-subdomain' + ).forEach(field => { + field.addEventListener('change', () => this.updatePortData(field)); + field.addEventListener('input', () => this.updatePortData(field)); + }); + } + + // Update port data when field changes + updatePortData(field) { + const index = parseInt(field.dataset.index); + const port = this.ports[index]; + if (!port) return; + + // Match by classList rather than === on className so additional classes added + // elsewhere (e.g. validation styles) don't break the switch. + if (field.classList.contains('port-service')) { + port.service = field.value; + } else if (field.classList.contains('port-name')) { + port.name = field.value; + } else if (field.classList.contains('port-external')) { + port.external = field.value; + } else if (field.classList.contains('port-internal')) { + port.internal = field.value; + } else if (field.classList.contains('port-access')) { + port.access = field.value; + const portCard = field.closest('.port-card'); + if (portCard) portCard.setAttribute('data-access', field.value); + } else if (field.classList.contains('port-protocol')) { + port.protocol = field.value; + } else if (field.classList.contains('port-traefik')) { + // + + + `; + }; + + return ` +
+

🛡️ Traefik Routing

+

Toggle which app ports Traefik routes. Each change applied below reinstalls the affected app so the new traefik.enable label takes effect.

+
+ +
+
+

Recommended ${primary.length}

+ Ports flagged recommended=true in their PORT config. +
+
+ ${primary.length ? primary.map(renderRow).join('') : '
No recommended ports — install an app whose webui port is recommended.
'} +
+
+ +
+
+

Advanced ${advanced.length}

+ +
+
+ ${advanced.length ? advanced.map(renderRow).join('') : '
No additional TCP ports across installed apps.
'} +
+
+ +
+ No pending changes. + +
+ `; + } + + _wire(root) { + root.querySelectorAll('.routing-traefik').forEach(cb => { + cb.addEventListener('change', () => this._trackChange(cb)); + }); + const showAdv = root.querySelector('#routing-show-advanced'); + const advTable = root.querySelector('.routing-advanced-table'); + if (showAdv && advTable) { + const sync = () => { advTable.classList.toggle('routing-advanced-open', showAdv.checked); }; + showAdv.addEventListener('change', sync); + sync(); + } + root.querySelector('.routing-apply')?.addEventListener('click', () => this._apply()); + } + + _trackChange(cb) { + const row = cb.closest('.routing-row'); + if (!row) return; + const key = `${row.dataset.app}|${row.dataset.idx}`; + // If the new value matches the original (clicked twice), drop the entry. + const orig = row.querySelector('.routing-traefik').defaultChecked; + if (cb.checked === orig) this.changes.delete(key); + else this.changes.set(key, cb.checked); + + const root = document.getElementById('routing-list'); + const hint = root.querySelector('#routing-apply-hint'); + const applyBtn = root.querySelector('.routing-apply'); + const n = this.changes.size; + if (n === 0) { + hint.textContent = 'No pending changes.'; + applyBtn.disabled = true; + } else { + const apps = new Set([...this.changes.keys()].map(k => k.split('|')[0])); + hint.textContent = `${n} change${n === 1 ? '' : 's'} across ${apps.size} app${apps.size === 1 ? '' : 's'} will reinstall.`; + applyBtn.disabled = false; + } + } + + async _apply() { + if (this.changes.size === 0) return; + if (!window.tasksManager || !window.tasksManager.router) { + console.error('Tasks router not available'); + return; + } + const apps = window.apps || []; + const byApp = new Map(); + apps.forEach(a => byApp.set((a.command || '').split(' ').pop(), a)); + + // Group changes by app so each app reinstalls once with all its port edits. + const grouped = new Map(); + for (const [key, newTraefik] of this.changes) { + const [slug, idxStr] = key.split('|'); + const idx = parseInt(idxStr, 10); + const app = byApp.get(slug); + if (!app) continue; + const cfgKey = `CFG_${slug.toUpperCase()}_PORT_${idx}`; + const raw = String(app.config[cfgKey] || ''); + const parts = raw.split('|'); + if (parts.length < 8) continue; + // Field 7 is traefik in 9+col, field 6 in 8-col legacy. + if (parts.length >= 9) parts[6] = newTraefik ? 'true' : 'false'; + else parts[5] = newTraefik ? 'true' : 'false'; + const newValue = parts.join('|'); + if (!grouped.has(slug)) grouped.set(slug, {}); + grouped.get(slug)[cfgKey] = newValue; + } + + const slugs = [...grouped.keys()]; + for (const slug of slugs) { + try { + await window.tasksManager.router.routeAction('install', { + appName: slug, + config: grouped.get(slug) + }); + } catch (e) { + console.error(`Routing apply failed for ${slug}:`, e); + } + } + + this.changes.clear(); + if (window.appTabbedManager && typeof window.appTabbedManager.switchTab === 'function') { + window.appTabbedManager.switchTab('tasks'); + } + } +} + +function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} +function escapeAttr(s) { return escapeHtml(s); } + +window.routingManager = new RoutingManager(); diff --git a/containers/libreportal/frontend/js/components/app/services-manager.js b/containers/libreportal/frontend/js/components/app/services-manager.js new file mode 100644 index 0000000..723d710 --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/services-manager.js @@ -0,0 +1,507 @@ +// Services tab on the app detail page. +// +// Each row renders a single docker compose service with: +// - colored status dot (running / stopped / unknown) +// - service name + container name +// - port chips and an "Open" button when a public URL exists +// - "Up 2 hours" runtime text +// - restart button (creates a task in the existing task system) +// - expandable live log tail (SSE backed) +// +// Data sources, layered: +// 1. /data/apps/generated/apps-services.json — canonical list of services +// and their URLs/ports per app (already maintained by the WebUI updater). +// 2. /api/apps//services/status — live state from `docker ps`. +// +// The `apps-services.json` file has one row per port. We dedupe by +// serviceName so a service with multiple ports renders as one row with +// multiple port chips. + +class ServicesManager { + constructor() { + this.currentApp = null; + this.refreshTimer = null; + this.openLogStreams = new Map(); // serviceName -> { es, container } + this.servicesIndex = null; // app -> serviceName -> { ports[], urls[] } + } + + // Entrypoint called by app-tabbed-manager. + async load(appName) { + this.currentApp = appName; + this._stopAllLogs(); + const list = document.getElementById('services-list'); + if (!list) return; + + const title = this._titleBlock(appName); + + list.innerHTML = ` + ${title} +
+
+ Loading services… +
`; + + try { + const [aggregated, status] = await Promise.all([ + this._loadAggregated(appName), + this._fetchStatus(appName) + ]); + + const merged = this._merge(aggregated, status); + + if (merged.length === 0) { + list.innerHTML = ` + ${title} +
+ +

No running compose services found for ${escapeHtml(appName)}.

+

If the app is stopped, start it from the topbar; services will appear here once Docker reports them.

+
`; + return; + } + + list.innerHTML = ` + ${title} +
+ ${merged.map(svc => this._renderRow(svc)).join('')} +
`; + this._wireActions(list); + this._startRefreshLoop(); + } catch (err) { + console.error('Services load error', err); + list.innerHTML = ` + ${title} +
+ ⚠️ +

Failed to load services: ${escapeHtml(err.message || String(err))}

+
`; + } + } + + _titleBlock(appName) { + const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + return ` +
+

⚡ Services

+

Inspect, restart and tail logs for the docker compose services that make up ${escapeHtml(display)}

+
`; + } + + // Called when leaving the Services tab. Tear down timers and SSE. + unload() { + this._stopRefreshLoop(); + this._stopAllLogs(); + } + + // ------------------------------------------------------------------ + // Data loading + // ------------------------------------------------------------------ + + async _loadAggregated(appName) { + let raw = { apps: [] }; + try { + const resp = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); + if (resp.ok) raw = await resp.json(); + } catch { /* file may not exist on a brand-new install */ } + + const rows = Array.isArray(raw.apps) ? raw.apps : []; + const byService = new Map(); + for (const row of rows) { + if (row.app !== appName) continue; + const key = row.serviceName; + if (!byService.has(key)) { + byService.set(key, { + serviceName: row.serviceName, + serviceIP: row.serviceIP, + ports: [], + openUrl: null, + openLabel: null, + // One Open button per entry in this list. Populated from the + // generator's `links[]` array, which itself comes from the + // comma-separated label/path pairs in CFG__PORT_N. + openLinks: [] + }); + } + const entry = byService.get(key); + if (row.externalPort && row.internalPort) { + entry.ports.push({ + name: row.name, + external: row.externalPort, + internal: row.internalPort, + access: row.access, + protocol: row.protocol + }); + } + // Pick the first enabled URL as the row's primary "Open" target + // (kept for back-compat with anything reading openUrl/openLabel). + if (row.buttonEnabled && (row.externalURL || row.internalURL) && !entry.openUrl) { + entry.openUrl = row.externalURL || row.internalURL; + entry.openLabel = row.buttonText || 'Open'; + } + // Multi-button: append every link from this row, dedup'd by URL. + if (row.buttonEnabled && Array.isArray(row.links)) { + for (const link of row.links) { + const url = link.externalURL || link.internalURL; + if (!url) continue; + if (entry.openLinks.some(l => l.url === url)) continue; + entry.openLinks.push({ url, label: link.label || row.buttonText || 'Open' }); + } + } + } + return [...byService.values()].sort(this._compareServices); + } + + _compareServices(a, b) { + const aPrimary = /-service$/.test(a.serviceName) ? 0 : 1; + const bPrimary = /-service$/.test(b.serviceName) ? 0 : 1; + if (aPrimary !== bPrimary) return aPrimary - bPrimary; + return a.serviceName.localeCompare(b.serviceName); + } + + async _fetchStatus(appName) { + const resp = await fetch(`/api/apps/${encodeURIComponent(appName)}/services/status`, { cache: 'no-store' }); + if (!resp.ok) { + // Surface the backend's reason instead of silently empty-arraying. The + // most common cause is the docker socket mount being :ro, which blocks + // connect() — the resulting EACCES used to disappear into a blank tab. + const body = await resp.text().catch(() => ''); + let detail = `HTTP ${resp.status}`; + try { detail = JSON.parse(body).error || detail; } catch { /* not JSON */ } + throw new Error(`Status fetch failed: ${detail}`); + } + return await resp.json(); + } + + _merge(aggregated, status) { + const byName = new Map(status.map(s => [s.serviceName, s])); + const out = aggregated.map(svc => { + const live = byName.get(svc.serviceName) || {}; + byName.delete(svc.serviceName); + return { + ...svc, + state: live.state || 'unknown', + statusText: live.statusText || 'Container not found', + containerName: live.containerName || '' + }; + }); + // Any docker-reported services we didn't know about (e.g. an + // ephemeral helper container) — surface them too with no port info. + for (const live of byName.values()) { + out.push({ + serviceName: live.serviceName, + serviceIP: '', + ports: [], + openUrl: null, + openLabel: null, + state: live.state, + statusText: live.statusText, + containerName: live.containerName + }); + } + return out.sort(this._compareServices); + } + + // ------------------------------------------------------------------ + // Rendering + // ------------------------------------------------------------------ + + _renderRow(svc) { + const state = (svc.state || 'unknown').toLowerCase(); + const stateClass = `status-${state}`; + + // Mirror the task-list .task-info layout: status pill on the left, + // title, then chips. Ports collapse into the info row instead of + // sitting next to the action buttons so it reads the same as + // "duration"/"time" chips on a task row. + const portChips = svc.ports.map(p => ` + ${escapeHtml(p.external)}${escapeHtml(p.internal)}${escapeHtml(p.protocol || '')}`).join(''); + + const ipChip = svc.serviceIP + ? `${escapeHtml(svc.serviceIP)}` + : ''; + + // Multi-button render via the same expandServiceLinks() helper the + // other UI surfaces use, so all four button locations stay in sync + // (Services tab, app-header, apps-list popup, dashboard hover). + // Pre-merged svc.openLinks (built from per-row links[] in _loadAggregated) + // is preferred when present so the user gets a deduped list across + // multiple ports of the same service. + const linkArrowSvg = ``; + const renderOpenBtn = (url, label) => + `${linkArrowSvg}${escapeHtml(label || 'Open')}`; + const linksToRender = (Array.isArray(svc.openLinks) && svc.openLinks.length > 0) + ? svc.openLinks + : (svc.openUrl ? [{ url: svc.openUrl, label: svc.openLabel || 'Open' }] : []); + const openBtn = linksToRender.map(l => renderOpenBtn(l.url, l.label)).join(''); + + const iconUrl = this.currentApp ? `/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : ''; + const iconHtml = iconUrl + ? `${escapeHtml(this.currentApp || '')}` + : ''; + + return ` +
+
+
+ ${iconHtml} + ${escapeHtml(svc.serviceName)} + ${escapeHtml(state.toUpperCase())} + ${escapeHtml(svc.statusText)} + ${portChips} + ${ipChip} +
+
+ ${openBtn} + + +
+
+
+
+
Service: ${escapeHtml(svc.serviceName)}
+ ${svc.containerName ? `
Container: ${escapeHtml(svc.containerName)}
` : ''} + ${svc.serviceIP ? `
IP: ${escapeHtml(svc.serviceIP)}
` : ''} +
State: ${escapeHtml(state)}
+
Status: ${escapeHtml(svc.statusText)}
+
+
+
+ +
+
+
`; + } + + // ------------------------------------------------------------------ + // Actions + // ------------------------------------------------------------------ + + _wireActions(root) { + if (root.dataset.wired === '1') return; + root.dataset.wired = '1'; + root.addEventListener('click', async (ev) => { + const btn = ev.target.closest('[data-action]'); + if (!btn) return; + const item = btn.closest('.service-item'); + if (!item) return; + const serviceName = item.dataset.service; + const action = btn.dataset.action; + + if (action === 'restart') { + await this._restartService(serviceName, btn); + } else if (action === 'toggle-logs') { + this._toggleLogs(item, serviceName); + } else if (action === 'resume-logs') { + this._resumeLogs(item, serviceName); + } + }); + } + + async _restartService(serviceName, btn) { + if (!this.currentApp) return; + btn.disabled = true; + btn.classList.add('is-running'); + try { + const resp = await fetch( + `/api/apps/${encodeURIComponent(this.currentApp)}/services/${encodeURIComponent(serviceName)}/restart`, + { method: 'POST', headers: { 'Content-Type': 'application/json' } } + ); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || `HTTP ${resp.status}`); + } + // Background task processor picks it up — refresh status shortly. + setTimeout(() => this._refreshStatusOnly(), 2500); + setTimeout(() => this._refreshStatusOnly(), 7000); + } catch (e) { + alert(`Restart failed: ${e.message}`); + } finally { + setTimeout(() => { + btn.disabled = false; + btn.classList.remove('is-running'); + }, 1500); + } + } + + _toggleLogs(item, serviceName) { + // The task-list uses a .task-details-open class (not the `hidden` + // attribute) because .task-details has `display: none` baked in. + const details = item.querySelector('.task-details'); + const output = item.querySelector('.service-log-output'); + if (!details || !output) return; + + const isOpen = details.classList.contains('task-details-open'); + if (isOpen) { + details.classList.remove('task-details-open'); + this._closeLogStream(serviceName); + this._hideLogOverlay(output); + return; + } + + details.classList.add('task-details-open'); + output.textContent = ''; + this._hideLogOverlay(output); + output.dataset.stream = 'connecting'; + this._openLogStream(serviceName, output); + } + + _openLogStream(serviceName, outputEl) { + if (!this.currentApp) return; + this._closeLogStream(serviceName); + + const url = `/api/apps/${encodeURIComponent(this.currentApp)}/services/${encodeURIComponent(serviceName)}/logs?tail=200`; + const es = new EventSource(url); + + es.addEventListener('ready', () => { + outputEl.dataset.stream = 'live'; + }); + es.addEventListener('log', (ev) => { + try { + const data = JSON.parse(ev.data); + const text = (data.lines || []).join('\n') + '\n'; + const wasAtBottom = isScrolledToBottom(outputEl); + outputEl.appendChild(document.createTextNode(text)); + // Cap buffer at ~1000 lines to keep the DOM cheap. + const lines = outputEl.textContent.split('\n'); + if (lines.length > 1000) { + outputEl.textContent = lines.slice(-1000).join('\n'); + } + if (wasAtBottom) outputEl.scrollTop = outputEl.scrollHeight; + } catch { /* ignore malformed event */ } + }); + es.addEventListener('error', () => { + // EventSource auto-reconnects; reflect connection state. + outputEl.dataset.stream = 'disconnected'; + }); + es.addEventListener('end', (ev) => { + let detail = {}; + try { detail = JSON.parse(ev.data || '{}'); } catch { /* ignore */ } + outputEl.dataset.stream = 'closed'; + this._closeLogStream(serviceName); + // Server-side timeouts surface as `end` events with a reason — pop + // the Resume overlay so the user can re-open the stream with one + // click. The displayed log buffer is preserved. + if (detail.reason === 'idle-timeout' || detail.reason === 'max-duration') { + this._showLogOverlay(outputEl, detail); + } + }); + + this.openLogStreams.set(serviceName, { es }); + } + + _showLogOverlay(outputEl, detail) { + const wrap = outputEl.closest('.task-logs'); + const overlay = wrap?.querySelector('.service-log-overlay'); + const msg = overlay?.querySelector('.service-log-overlay-msg'); + if (!overlay || !msg) return; + const minutes = detail.limitMinutes ? Math.round(detail.limitMinutes) : ''; + if (detail.reason === 'idle-timeout') { + msg.textContent = `Stream paused — no log activity for ${minutes} minute${minutes === 1 ? '' : 's'}.`; + } else { + msg.textContent = `Stream stopped — reached the ${minutes}-minute cap.`; + } + overlay.style.display = 'flex'; + } + + _hideLogOverlay(outputEl) { + const wrap = outputEl.closest('.task-logs'); + const overlay = wrap?.querySelector('.service-log-overlay'); + if (overlay) overlay.style.display = 'none'; + } + + _resumeLogs(item, serviceName) { + const output = item.querySelector('.service-log-output'); + if (!output) return; + this._hideLogOverlay(output); + output.dataset.stream = 'connecting'; + this._openLogStream(serviceName, output); + } + + _closeLogStream(serviceName) { + const entry = this.openLogStreams.get(serviceName); + if (!entry) return; + try { entry.es.close(); } catch { /* already closed */ } + this.openLogStreams.delete(serviceName); + } + + _stopAllLogs() { + for (const [name] of this.openLogStreams) this._closeLogStream(name); + } + + // ------------------------------------------------------------------ + // Status refresh loop (only updates dots/text, not full re-render) + // ------------------------------------------------------------------ + + _startRefreshLoop() { + this._stopRefreshLoop(); + this.refreshTimer = setInterval(() => this._refreshStatusOnly(), 10_000); + } + + _stopRefreshLoop() { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + } + + async _refreshStatusOnly() { + if (!this.currentApp) return; + if (!document.getElementById('services-tab')?.classList.contains('active')) return; + let status; + try { status = await this._fetchStatus(this.currentApp); } + catch { return; } + + for (const live of status) { + const item = document.querySelector(`.service-item[data-service="${cssEscape(live.serviceName)}"]`); + if (!item) continue; + const state = (live.state || 'unknown').toLowerCase(); + item.dataset.state = state; + const dot = item.querySelector('.service-dot'); + if (dot) { + dot.className = `service-dot service-dot-${state}`; + dot.title = live.statusText || ''; + } + const txt = item.querySelector('.service-status-text'); + if (txt) txt.textContent = live.statusText || ''; + } + } +} + +// Tiny helpers ---------------------------------------------------------- + +function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function cssEscape(s) { + if (window.CSS && CSS.escape) return CSS.escape(s); + return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c); +} + +function isScrolledToBottom(el) { + return el.scrollHeight - el.clientHeight - el.scrollTop < 4; +} + +// Singleton — app-tabbed-manager calls `window.servicesManager.load(app)`. +window.servicesManager = new ServicesManager(); diff --git a/containers/libreportal/frontend/js/components/app/tools-manager.js b/containers/libreportal/frontend/js/components/app/tools-manager.js new file mode 100644 index 0000000..f0b9ef0 --- /dev/null +++ b/containers/libreportal/frontend/js/components/app/tools-manager.js @@ -0,0 +1,872 @@ +// Tools tab on the app detail page. +// +// Each app may declare per-app actions ("tools") in +// containers//.tools.json +// served by GET /api/apps//tools. Each tool is rendered as a card +// with a button. Clicking the button either runs the tool directly (no +// fields, no confirm) or opens a generic modal that collects the tool's +// inputs. +// +// On submit we dispatch through the existing TaskRouter: +// TaskRouter.routeAction('tool', { appName, toolName, toolArgs }) +// which builds: libreportal app tool '' +// — same task pipeline as install/restart/etc., so the Tasks tab and +// log streaming come for free. +// +// Manifest schema (containers//.tools.json): +// { +// "tools": [ +// { +// "id": "", // matches the case in Tool() +// "label": "Refresh providers", +// "description": "...", // optional, shown on card + modal +// "icon": "🔄", // optional emoji +// "destructive": false, // optional, renders red button +// "confirm": "Are you sure?", // optional, forces a modal +// "fields": [ +// { "name": "duration", +// "label": "Duration (s)", +// "type": "number", // text|password|number|select|checkbox|textarea +// "default": 5, +// "placeholder": "5", +// "required": true, +// "min": 1, "max": 60 }, +// { "name": "mode", +// "label": "Mode", +// "type": "select", +// "options": [ +// { "value": "fast", "label": "Fast" }, +// { "value": "slow", "label": "Slow" } +// ] } +// ] +// } +// ] +// } + +class ToolsManager { + constructor() { + this.currentApp = null; + this.tools = []; + // Cache of manifest results per app so prepare() doesn't re-fetch on + // every tab switch. Cleared by reset(). + this._manifestCache = new Map(); + + // Re-check Tools tab visibility on focus + task completion. + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (!document.hidden && this.currentApp) this._revalidateCurrent(); + }); + } + if (typeof window !== 'undefined') { + window.addEventListener('taskCompleted', (ev) => { + if (this.currentApp) this._revalidateCurrent(); + this._maybeOpenUserListModal(ev?.detail); + }); + } + } + + // When a list_users task completes, fetch its log, parse EZ_USER\t lines, + // and open an interactive modal where each row has action buttons. + async _maybeOpenUserListModal(detail) { + if (!detail || detail.status !== 'completed') return; + const cmd = String(detail.command || detail.task?.command || ''); + const m = cmd.match(/libreportal app tool (\S+) list_users\b/); + if (!m) return; + const appName = m[1]; + const taskId = detail.taskId || detail.id || detail.task?.id; + if (!taskId) return; + + let logText = ''; + try { + const r = await fetch(`/read-file?path=tasks/${encodeURIComponent(taskId)}.log`, { cache: 'no-store' }); + if (r.ok) logText = await r.text(); + } catch (_) {} + + const users = []; + logText.split(/\r?\n/).forEach(line => { + const i = line.indexOf('EZ_USER\t'); + if (i < 0) return; + const parts = line.slice(i + 8).split('\t'); + const [email = '', username = '', roles = ''] = parts; + if (!email && !username) return; + users.push({ email: email.trim(), username: username.trim(), roles: roles.trim() }); + }); + + this._openUserListModal(appName, users); + } + + _openUserListModal(appName, users) { + const tools = (window.toolsCatalog?.apps?.[appName]?.tools) || []; + const resetTool = tools.find(t => t.id === 'reset_password'); + const deleteTool = tools.find(t => t.id === 'delete_user'); + const adminTool = tools.find(t => t.id === 'set_admin'); + const appLabel = (window.getAppDisplayName ? window.getAppDisplayName(appName) : appName); + const iconUrl = `/icons/apps/${encodeURIComponent(appName)}.svg`; + + // Reopen this exact modal (used as returnTo when a row action's + // sub-modal is cancelled — keeps the user in flow instead of + // dumping them out to the Tools tab). + const reopen = () => this._openUserListModal(appName, users); + + const rowsHtml = users.length + ? users.map((u, idx) => { + const isAdmin = /admin/i.test(u.roles || ''); + return ` +
+
+
${escapeHtml(u.email || u.username || '—')}
+ ${u.username && u.username !== u.email ? `
${escapeHtml(u.username)}
` : ''} + ${u.roles && u.roles !== '—' ? `
${escapeHtml(u.roles)}
` : ''} +
+
+ ${resetTool ? `` : ''} + ${adminTool ? `` : ''} + ${deleteTool ? `` : ''} +
+
`; + }).join('') + : window.eoEmpty('No users found.'); + + const mod = window.openEoModal({ + id: 'user-list-modal', + size: 'sm', + icon: iconUrl, + iconAlt: appLabel, + eyebrow: 'Users', + title: `${appLabel} Accounts`, + desc: users.length ? `${users.length} account${users.length === 1 ? '' : 's'}` : '', + body: `
${rowsHtml}
`, + actions: [{ label: 'Close', variant: 'secondary' }] + }); + + const fillIdentifier = (tool, idValue) => { + const prefill = {}; + if (tool.fields?.some(f => f.name === 'email')) prefill.email = idValue; + if (tool.fields?.some(f => f.name === 'username')) prefill.username = idValue; + return prefill; + }; + + mod.contentEl.querySelectorAll('.user-row-btn').forEach(btn => { + btn.addEventListener('click', () => { + const u = users[parseInt(btn.dataset.idx, 10)]; + const idValue = u.email || u.username; + const act = btn.dataset.act; + const tool = act === 'reset' ? resetTool : act === 'delete' ? deleteTool : adminTool; + if (!tool) return; + const prefill = fillIdentifier(tool, idValue); + // For toggle-admin we also pre-set the boolean. + if (act === 'admin' && tool.fields?.some(f => f.name === 'admin')) { + prefill.admin = !(/admin/i.test(u.roles || '')); + } + mod.close(); + this._activate(tool, { prefill, returnTo: reopen }); + }); + }); + } + + async _revalidateCurrent() { + const app = this.currentApp; + if (!app) return; + this._manifestCache.delete(app); + this._aggregatePromise = null; + try { await this.prepare(app); } catch (_) { /* swallow */ } + } + + // Called when the app changes. Fetches the manifest once and toggles the + // Tools tab buttons' visibility. Apps with no tools never expose the tab. + async prepare(appName) { + if (!appName) { + this._toggleTabVisibility(false); + return { tools: [] }; + } + let result = this._manifestCache.get(appName); + if (!result) { + result = await this._fetchManifest(appName); + // Only cache non-empty success results. An empty list usually means + // apps-tools.json was briefly stale (mid-regen during install) — if + // we cache that, the tab stays hidden until full page reload. By + // also dropping the aggregate promise we force the next prepare() + // call to refetch the JSON fresh. + if (!result.error && result.tools.length > 0) { + this._manifestCache.set(appName, result); + } else { + this._aggregatePromise = null; + } + } + // Tools act on a live container — only expose the tab when the + // app is installed. apps-manager.js already does this on render + // for backup/services/tools, but prepare() can re-fire after that + // (visibilitychange, taskCompleted, app switches), so re-check + // here too — otherwise we'd silently re-show the tab for a + // not-installed app whenever we revalidate. + const installed = this._isAppInstalled(appName); + this._toggleTabVisibility(installed && result.tools.length > 0); + return result; + } + + _isAppInstalled(appName) { + if (!appName) return false; + const target = String(appName).toLowerCase(); + const entry = (window.apps || []).find(a => + ((a.command || '').split(' ').pop() || '').toLowerCase() === target + ); + return !!entry?.installed; + } + + // Drop cached manifests so the next prepare() re-fetches. Called when + // an app's compose/install changes — currently a no-op caller side, but + // keeps the door open for cache invalidation. + reset() { + this._manifestCache.clear(); + this._aggregatePromise = null; + } + + // The whole apps-tools.json aggregate, fetched once per page load. + // Generated by scripts/webui/data/generators/apps/webui_tools.sh. + // Also populates window.toolsCatalog so other components (e.g. + // tasks-manager.js's formatCommandForUser) can look up tool labels + // without re-fetching. + async _loadAggregate() { + if (this._aggregatePromise) return this._aggregatePromise; + this._aggregatePromise = (async () => { + try { + const resp = await fetch('/data/apps/generated/apps-tools.json', { cache: 'no-store' }); + if (!resp.ok) return { apps: {}, error: `HTTP ${resp.status}` }; + const data = await resp.json(); + const apps = data && typeof data.apps === 'object' ? data.apps : {}; + window.toolsCatalog = { apps }; + return { apps }; + } catch (err) { + return { apps: {}, error: err.message || String(err) }; + } + })(); + return this._aggregatePromise; + } + + async _fetchManifest(appName) { + const agg = await this._loadAggregate(); + if (agg.error) return { tools: [], error: agg.error }; + const entry = agg.apps[appName]; + return { tools: Array.isArray(entry?.tools) ? entry.tools : [] }; + } + + _toggleTabVisibility(show) { + const buttons = document.querySelectorAll('[data-tab="tools"]'); + buttons.forEach(btn => { + btn.style.display = show ? '' : 'none'; + }); + } + + async load(appName) { + this.currentApp = appName; + const list = document.getElementById('tools-list'); + if (!list) return; + + list.innerHTML = ` + ${this._titleBlock(appName)} +
+
+ Loading tools… +
`; + + const result = await this.prepare(appName); + this.tools = this._sortTools(result.tools); + + if (result.error) { + list.innerHTML = ` + ${this._titleBlock(appName)} +
+ ⚠️ +

Couldn't load tools right now.

+
`; + return; + } + + if (this.tools.length === 0) { + // Tab should already be hidden by prepare(); render a soft message + // anyway in case the user got here via a deep link. + list.innerHTML = ` + ${this._titleBlock(appName)} +
+ 🧰 +

This app has no tools.

+
`; + return; + } + + const grouped = this._groupByCategory(this.tools); + if (grouped.size > 1) { + const cats = [...grouped.keys()]; + const tabsHtml = cats.map((cat, i) => ` + `).join(''); + const panesHtml = cats.map((cat, i) => ` +
+ ${grouped.get(cat).map(t => this._renderRow(t)).join('')} +
`).join(''); + list.innerHTML = ` + ${this._titleBlock(appName)} +
${tabsHtml}
+ ${panesHtml}`; + this._wireTabs(list); + } else { + list.innerHTML = ` + ${this._titleBlock(appName)} +
+ ${this.tools.map(t => this._renderRow(t)).join('')} +
`; + } + this._wireActions(list); + } + + // Bucket tools by their `category` field into an insertion-ordered map. + // Tools without a category land in "general". Categories appear in the + // order they're first seen (matches authoring order in webui_tools.sh). + _groupByCategory(tools) { + const out = new Map(); + for (const t of tools) { + const cat = (typeof t.category === 'string' && t.category.trim()) ? t.category.trim() : 'general'; + if (!out.has(cat)) out.set(cat, []); + out.get(cat).push(t); + } + return out; + } + + _categoryLabel(cat) { + return String(cat).replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + } + + _wireTabs(root) { + const bar = root.querySelector('.tools-tab-bar'); + if (!bar) return; + bar.addEventListener('click', (ev) => { + const btn = ev.target.closest('.tools-tab'); + if (!btn) return; + const cat = btn.dataset.cat; + bar.querySelectorAll('.tools-tab').forEach(b => b.classList.toggle('active', b === btn)); + root.querySelectorAll('.tools-cat-pane').forEach(p => p.classList.toggle('active', p.dataset.cat === cat)); + }); + } + + unload() { /* no timers/streams */ } + + _titleBlock(appName) { + const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + return ` +
+

🧰 Tools

+

Run app-specific actions for ${escapeHtml(display)} — each tool creates a task.

+
`; + } + + _renderRow(tool) { + const icon = tool.icon || '⚙️'; + const desc = tool.description ? `

${escapeHtml(tool.description)}

` : ''; + const btnClass = tool.destructive ? 'tool-run-btn destructive' : 'tool-run-btn'; + + return ` +
+
+
+ ${escapeHtml(icon)} + ${escapeHtml(tool.label || tool.id)} +
+ ${desc} +
+
+ +
+
`; + } + + _wireActions(root) { + if (root.dataset.wired === '1') return; + root.dataset.wired = '1'; + root.addEventListener('click', (ev) => { + const btn = ev.target.closest('[data-action="run"]'); + if (!btn) return; + const item = btn.closest('.tool-item'); + if (!item) return; + const toolId = item.dataset.toolId; + const tool = this.tools.find(t => t.id === toolId); + if (!tool) return; + this._activate(tool); + }); + } + + // Tools render in this order: explicit `order` field if present, + // otherwise the default precedence map (least → most destructive), + // otherwise their authoring order from webui_tools.sh. + _sortTools(tools) { + if (!Array.isArray(tools) || tools.length < 2) return tools || []; + const defaults = { + reset_password: 10, + apply_dns_updater: 15, + manage_shortcuts: 20, + refresh_providers: 20, + create_account: 30, + list_users: 40, + set_admin: 50, + delete_user: 90, + }; + const weight = (t, idx) => { + if (typeof t.order === 'number') return t.order; + if (defaults[t.id] !== undefined) return defaults[t.id]; + return 1000 + idx; + }; + return tools.map((t, i) => [weight(t, i), i, t]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]) + .map(x => x[2]); + } + + _activate(tool, opts) { + const hasFields = Array.isArray(tool.fields) && tool.fields.length > 0; + if (!hasFields && !tool.confirm) { + this._dispatch(tool, ''); + return; + } + this._openModal(tool, opts); + } + + _openModal(tool, opts) { + const fields = Array.isArray(tool.fields) ? tool.fields : []; + const prefill = (opts && opts.prefill) || {}; + const returnTo = opts && opts.returnTo; + let submitted = false; + const appIconUrl = this.currentApp ? `/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : ''; + const bodyHtml = ` + ${tool.confirm ? `
${escapeHtml(tool.confirm)}
` : ''} +
+ ${fields.map(f => this._renderField(prefill[f.name] !== undefined ? { ...f, default: prefill[f.name] } : f)).join('')} +
`; + + const widePicker = fields.some(f => f.type === 'app_urls_multi' || f.type === 'installed_apps_multi'); + const m = window.openEoModal({ + id: 'tool-run-modal', + className: 'tool-modal', + size: widePicker ? 'md' : 'sm', + icon: appIconUrl || undefined, + iconAlt: this.currentApp || '', + title: tool.label || tool.id, + endIcon: tool.icon || undefined, + body: bodyHtml, + actions: [ + { label: 'Run', variant: 'primary', onClick: (modal) => { + const args = this._collectFormArgs(modal.contentEl, fields); + if (args === null) return; + submitted = true; + modal.close(); + this._dispatch(tool, args); + }}, + { label: 'Cancel', variant: 'secondary', onClick: (modal) => modal.close() } + ], + onClose: () => { + if (!submitted && typeof returnTo === 'function') { + try { returnTo(); } catch (e) { console.error(e); } + } + } + }); + const modal = m.contentEl; + + // Wire the installed_apps_multi / app_urls_multi search + bulk + // select buttons. For app_urls_multi we also hide whole group + // headers when none of their rows match the search. + modal.querySelectorAll('.installed-apps-multi').forEach((root) => { + const isUrlMode = root.classList.contains('app-urls-multi'); + const search = root.querySelector('.installed-apps-search'); + const all = root.querySelector('.installed-apps-all'); + const none = root.querySelector('.installed-apps-none'); + const filterRows = (q) => { + if (!isUrlMode) { + root.querySelectorAll('.installed-apps-item').forEach((item) => { + const text = item.querySelector('.installed-apps-name')?.textContent.toLowerCase() || ''; + item.style.display = text.includes(q) ? '' : 'none'; + }); + return; + } + root.querySelectorAll('.app-url-row').forEach((item) => { + const label = (item.querySelector('.app-url-label')?.textContent || '').toLowerCase(); + item.style.display = (!q || label.includes(q)) ? '' : 'none'; + }); + }; + if (search) search.addEventListener('input', (e) => filterRows(e.target.value.toLowerCase())); + if (all) all.addEventListener('click', () => { + root.querySelectorAll('.installed-apps-item').forEach((item) => { + if (item.offsetParent !== null) item.querySelector('input').checked = true; + }); + }); + if (none) none.addEventListener('click', () => { + root.querySelectorAll('.installed-apps-item input').forEach((cb) => cb.checked = false); + }); + }); + + // Async-fill app_urls_multi rows from apps-services.json after + // mount so the modal opens instantly and rows fade in. + this._hydrateAppUrlsMulti(modal); + return m; + } + + _renderField(field) { + const name = String(field.name || ''); + const label = escapeHtml(field.label || name); + const id = `tool-field-${name}`; + const required = field.required ? 'required' : ''; + const placeholder = field.placeholder ? `placeholder="${escapeHtml(field.placeholder)}"` : ''; + const def = field.default !== undefined ? String(field.default) : ''; + + const wrap = (inner) => ` +
+ + ${inner} +
`; + + switch (field.type) { + case 'password': + return wrap(``); + case 'number': { + const min = field.min !== undefined ? `min="${Number(field.min)}"` : ''; + const max = field.max !== undefined ? `max="${Number(field.max)}"` : ''; + return wrap(``); + } + case 'checkbox': { + const checked = (def === 'true' || def === '1' || field.default === true) ? 'checked' : ''; + return ` + `; + } + case 'select': { + const opts = Array.isArray(field.options) ? field.options : []; + const optsHtml = opts.map(o => { + const v = escapeHtml(String(o.value)); + const l = escapeHtml(String(o.label || o.value)); + const sel = String(o.value) === def ? 'selected' : ''; + return ``; + }).join(''); + return wrap(``); + } + case 'textarea': + return wrap(``); + case 'installed_apps_multi': + return wrap(this._renderInstalledAppsMulti(field, name)); + case 'app_urls_multi': + // Label rendered inside the picker's container as a title bar + // so the field reads as a single visual unit (no floating + // disconnected label above the box). Skip wrap(). + return `
${this._renderAppUrlsMulti(field, name, label, !!field.required)}
`; + case 'text': + default: + return wrap(``); + } + } + + // Multi-select list of installed apps, styled to mirror the gluetun + // country picker: search box + select-all/clear + compact rows. + // Collected as CSV by _collectFormArgs. + _renderInstalledAppsMulti(field, name) { + const apps = (window.apps || []).filter(a => a && a.installed); + const currentAppEntry = (window.apps || []).find(a => + ((a.command || '').split(' ').pop()) === this.currentApp + ); + const cfgKey = field.prefillFromCfgKey + || `CFG_${(this.currentApp || '').toUpperCase()}_${(name || '').toUpperCase()}`; + const currentCsv = (currentAppEntry && currentAppEntry.config && currentAppEntry.config[cfgKey]) || ''; + const selected = new Set(currentCsv.split(',').map(s => s.trim()).filter(Boolean)); + const exclude = new Set(Array.isArray(field.excludeApps) ? field.excludeApps : [this.currentApp]); + + const items = apps + .map(a => ({ app: a, slug: (a.command || '').split(' ').pop() })) + .filter(({ slug }) => slug && !exclude.has(slug)) + .sort((x, y) => (x.app.name || x.slug).localeCompare(y.app.name || y.slug)) + .map(({ app, slug }) => { + const checked = selected.has(slug) ? 'checked' : ''; + const iconUrl = `/icons/apps/${encodeURIComponent(slug)}.svg`; + const displayName = escapeHtml(app.name || slug); + return ` + `; + }).join(''); + + if (!items) { + return `
No other installed apps to choose from.
`; + } + + return ` +
+
+
+ + + + + +
+
+ + +
+
+
${items}
+
`; + } + + // URL-level multi-select. Same data source as the WebUI URL buttons + // (apps-services.json + window.expandServiceLinks), so what the user + // picks here matches what they'd see hovering an app on the + // dashboard. Each row is one URL, grouped under its parent app. + // Stable id per URL: ":". Pre-checked from + // a CSV in CFG__ (override via prefillFromCfgKey). + _renderAppUrlsMulti(field, name, labelHtml, required) { + const cfgKey = field.prefillFromCfgKey + || `CFG_${(this.currentApp || '').toUpperCase()}_${(name || '').toUpperCase()}`; + const currentAppEntry = (window.apps || []).find(a => + ((a.command || '').split(' ').pop()) === this.currentApp + ); + const currentCsv = (currentAppEntry && currentAppEntry.config && currentAppEntry.config[cfgKey]) || ''; + const selected = new Set(currentCsv.split(',').map(s => s.trim()).filter(Boolean)); + const exclude = new Set(Array.isArray(field.excludeApps) ? field.excludeApps : [this.currentApp]); + + const titleHtml = labelHtml + ? `
${labelHtml}${required ? ' *' : ''}
` + : ''; + + return ` +
+
+ ${titleHtml} +
+
+ + + + + +
+
+ + +
+
+
+
Loading services…
+
+
+
`; + } + + // Async hydration for the app_urls_multi field — fetch + // apps-services.json and render rows. Called after the modal has + // been mounted (see open() below). + async _hydrateAppUrlsMulti(modal) { + const roots = modal.querySelectorAll('.app-urls-multi'); + if (roots.length === 0) return; + + // Re-fetch apps.json so the pre-checked state reflects the + // latest CFG_* values (a previous tool save may have updated + // them after window.apps was first loaded). Cheap and avoids + // a "ticked nothing even though I just saved" UX bug. + try { + const appsResp = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); + if (appsResp.ok) { + const appsData = await appsResp.json(); + if (Array.isArray(appsData?.apps)) window.apps = appsData.apps; + } + } catch (_) { /* keep stale window.apps if fetch fails */ } + + // Recompute data-prefill from the fresh window.apps. The render + // pass encoded it from whatever data was current at modal-open + // time; if the user saved shortcuts in a different tab/session + // we'd otherwise show stale ticks here. + roots.forEach((root) => { + const cfgKey = root.dataset.cfgKey; + const appSlug = root.dataset.appSlug; + if (!cfgKey || !appSlug) return; + const entry = (window.apps || []).find(a => + ((a.command || '').split(' ').pop()) === appSlug + ); + const csv = (entry && entry.config && entry.config[cfgKey]) || ''; + const fresh = csv.split(',').map(s => s.trim()).filter(Boolean); + root.dataset.prefill = JSON.stringify(fresh); + }); + + let services = []; + try { + const resp = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); + if (resp.ok) { + const data = await resp.json(); + services = Array.isArray(data?.services) ? data.services : []; + } + } catch (e) { /* fall through, render empty state */ } + + const expand = typeof window.expandServiceLinks === 'function' + ? window.expandServiceLinks + : ((s) => [{ url: s.externalURL || `http://localhost:${s.externalPort}`, label: s.buttonText || s.name }]); + + const displayName = (slug) => (window.getAppDisplayName ? window.getAppDisplayName(slug) : slug); + + roots.forEach((root) => { + const name = root.dataset.field; + const selected = new Set(JSON.parse(root.dataset.prefill || '[]')); + const exclude = new Set(JSON.parse(root.dataset.exclude || '[]')); + const list = root.querySelector('.app-urls-list'); + + const byApp = new Map(); + for (const svc of services) { + if (!svc || !svc.app || exclude.has(svc.app)) continue; + if (svc.buttonEnabled === false) continue; + const links = expand(svc); + if (!links || links.length === 0) continue; + if (!byApp.has(svc.app)) byApp.set(svc.app, []); + links.forEach((lnk, idx) => { + if (!lnk?.url) return; + byApp.get(svc.app).push({ + id: `${svc.name}:${idx}`, + label: lnk.label || svc.buttonText || svc.name, + url: lnk.url, + traefik: !!svc.traefikManaged, + locked: !!svc.loginRequired + }); + }); + } + + if (byApp.size === 0) { + list.innerHTML = `
No service URLs available yet — install some apps first.
`; + return; + } + + // Flat task-style rows: one URL per row, app icon + name + URL + // inline. Sorted by app name then URL label so related entries + // sit together without needing per-app group cards. + const flat = []; + [...byApp.keys()].sort((a, b) => displayName(a).localeCompare(displayName(b))).forEach(slug => { + byApp.get(slug).forEach(u => flat.push({ slug, ...u })); + }); + + list.innerHTML = flat.map(u => { + const checked = selected.has(u.id) ? 'checked' : ''; + const iconUrl = `/icons/apps/${encodeURIComponent(u.slug)}.svg`; + const appLabel = displayName(u.slug); + const showButton = u.label && u.label !== appLabel; + const fullLabel = showButton + ? `${escapeHtml(appLabel)} ${escapeHtml(u.label)}` + : escapeHtml(appLabel); + return ` + `; + }).join(''); + }); + } + + // Collect form values into a pipe-encoded args string for the bash side. + // Returns null if a required field is missing. + _collectFormArgs(modal, fields) { + const pairs = []; + for (const field of fields) { + const name = String(field.name || ''); + if (!name) continue; + // installed_apps_multi has no [name=name] element directly; it + // collects from [name=name+'__opt'] checkboxes below. Other field + // types have a single matching element to validate against. + const isMultiPickerType = field.type === 'installed_apps_multi' || field.type === 'app_urls_multi'; + const el = modal.querySelector(`[name="${cssEscape(name)}"]`) + || (isMultiPickerType ? modal.querySelector(`.installed-apps-multi[data-field="${cssEscape(name)}"]`) : null); + if (!el) continue; + let value; + if (field.type === 'checkbox') { + value = el.checked ? 'true' : 'false'; + } else if (isMultiPickerType) { + const checked = modal.querySelectorAll(`[name="${cssEscape(name + '__opt')}"]:checked`); + value = Array.from(checked).map(c => c.value).join(','); + } else { + value = el.value; + } + if (field.required && (value === '' || value === undefined || value === null)) { + if (window.notificationSystem) { + window.notificationSystem.show(`Missing field
${escapeHtml(field.label || name)} is required.`, 'warning'); + } else { + alert(`${field.label || name} is required.`); + } + return null; + } + // Strip pipes/newlines from values — they're our delimiters. + const safe = String(value).replace(/\|/g, '%7C').replace(/[\r\n]/g, ' '); + pairs.push(`${name}=${safe}`); + } + return pairs.join('|'); + } + + async _dispatch(tool, toolArgs) { + if (!this.currentApp) return; + if (!window.tasksManager || !window.tasksManager.router) { + console.error('TasksManager router not available'); + if (window.notificationSystem) { + window.notificationSystem.error('Task system is not ready yet — try again in a moment.'); + } + return; + } + try { + const task = await window.tasksManager.router.routeAction('tool', { + appName: this.currentApp, + toolName: tool.id, + toolArgs, + toolLabel: tool.label || tool.id + }); + + // Mirror the install flow: jump to the Tasks tab and auto-expand + // the new task so the user sees its log streaming in. + setTimeout(() => { + if (window.appTabbedManager) { + window.appTabbedManager.switchTab('tasks'); + setTimeout(() => { + if (task && window.appTabbedManager.tasksManager) { + window.appTabbedManager.tasksManager.highlightedTaskId = task.id; + window.appTabbedManager.tasksManager.renderTasks(); + } + }, 300); + } + }, 200); + } catch (err) { + console.error('Tool dispatch failed', err); + if (window.notificationSystem) { + window.notificationSystem.error(`Tool failed: ${err.message}`); + } + } + } +} + +// Tiny helpers ---------------------------------------------------------- + +function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function cssEscape(s) { + if (window.CSS && CSS.escape) return CSS.escape(s); + return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c); +} + +window.toolsManager = new ToolsManager(); diff --git a/containers/libreportal/frontend/js/components/backup/backup-app-card.js b/containers/libreportal/frontend/js/components/backup/backup-app-card.js new file mode 100644 index 0000000..25dad6a --- /dev/null +++ b/containers/libreportal/frontend/js/components/backup/backup-app-card.js @@ -0,0 +1,173 @@ +// Per-app backup card — used inside the app detail "Backups" tab. +// Lightweight view that lists the app's snapshots across all enabled repos and +// offers Backup Now + per-snapshot restore. For full management (delete, +// migrate, schedule overrides) the user follows the link to /backup. + +class BackupAppCard { + constructor(appName) { + this.appName = appName; + this.snapshotsByLoc = {}; + this.locationsByIdx = {}; + this.appStatus = null; + this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; + this.bindDelegated(); + } + + bindDelegated() { + if (window.__backupAppCardBound) return; + window.__backupAppCardBound = true; + + document.addEventListener('click', (e) => { + const card = window.backupAppCard; + if (!card) return; + + if (e.target.closest('#backup-app-card-backup-btn')) { + card.backupNow(); + return; + } + + const restoreBtn = e.target.closest('[data-action="restore-app-snapshot"]'); + if (restoreBtn) { + card.restoreSnapshot(restoreBtn.dataset.loc, restoreBtn.dataset.snapshot); + } + }); + } + + async render() { + const statusEl = document.getElementById('backup-app-card-status'); + const snapsEl = document.getElementById('backup-app-card-snapshots'); + if (!statusEl || !snapsEl) return; + + statusEl.textContent = 'Loading…'; + snapsEl.innerHTML = ''; + + await this.loadData(); + + const allSnaps = this.flattenSnapshots(); + if (!allSnaps.length) { + statusEl.innerHTML = ` No snapshots yet`; + snapsEl.innerHTML = `
No snapshots found for ${this.escape(this.appName)}. Click "Backup now" to create the first one.
`; + return; + } + + const latest = allSnaps[0]; + const locCount = Object.keys(this.snapshotsByLoc).length; + statusEl.innerHTML = ` + + Latest backup ${this.formatRelative(latest.time)} + ${allSnaps.length} total across ${locCount} location${locCount === 1 ? '' : 's'} + `; + + snapsEl.innerHTML = ` + + + + + + + + + + + ${allSnaps.slice(0, 15).map(s => ` + + + + + + + `).join('')} + +
LocationWhenID
${this.escape(s.locName)}${this.formatRelative(s.time)}${this.escape(s.id)} + +
+ `; + } + + async loadData() { + const ts = Date.now(); + const statusUrl = `/data/backup/generated/apps/${encodeURIComponent(this.appName)}.json?t=${ts}`; + const locationsUrl = `/data/backup/generated/locations.json?t=${ts}`; + + const [appStatus, locationsJson] = await Promise.all([ + this.fetchJson(statusUrl), + this.fetchJson(locationsUrl) + ]); + this.appStatus = appStatus; + this.snapshotsByLoc = {}; + this.locationsByIdx = {}; + + if (locationsJson?.locations?.length) { + locationsJson.locations.forEach(l => { this.locationsByIdx[l.idx] = l; }); + const enabled = locationsJson.locations.filter(l => l.enabled); + await Promise.all(enabled.map(async (l) => { + const data = await this.fetchJson(`/data/backup/generated/snapshots_${l.idx}.json?t=${ts}`); + if (data?.snapshots) this.snapshotsByLoc[l.idx] = data.snapshots; + })); + } + } + + flattenSnapshots() { + const out = []; + Object.entries(this.snapshotsByLoc).forEach(([locIdx, snaps]) => { + (snaps || []).forEach(s => { + const tags = s.tags || []; + const isApp = tags.includes(`app=${this.appName}`); + if (!isApp) return; + out.push({ + locIdx, + locName: this.locationsByIdx[locIdx]?.name || `Location ${locIdx}`, + time: s.time, + id: s.short_id || (s.id || '').slice(0, 8) + }); + }); + }); + out.sort((a, b) => String(b.time).localeCompare(String(a.time))); + return out; + } + + async fetchJson(url) { + try { + const r = await fetch(url); + if (!r.ok) return null; + return await r.json(); + } catch { return null; } + } + + async backupNow() { + if (!this.taskManager) return; + await this.taskManager.createTask(`libreportal backup app create ${this.appName}`, 'backup', this.appName); + setTimeout(() => this.render(), 1500); + } + + async restoreSnapshot(locIdx, snapshot) { + const locName = this.locationsByIdx[locIdx]?.name || `Location ${locIdx}`; + if (!confirm(`Restore ${this.appName} from backup ${snapshot} at ${locName}? The app will be stopped, its folder wiped, the backup restored in place, then the app started again.`)) return; + if (!this.taskManager) return; + await this.taskManager.createTask(`libreportal restore app start ${this.appName} ${snapshot} ${locIdx}`, 'restore', this.appName); + } + + escape(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[c]); + } + + formatRelative(iso) { + if (!iso) return '—'; + const t = new Date(iso).getTime(); + if (!t) return iso; + const diff = Math.max(0, Date.now() - t); + const s = Math.floor(diff / 1000); + if (s < 60) return 'just now'; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); + } +} + +window.BackupAppCard = BackupAppCard; diff --git a/containers/libreportal/frontend/js/components/backup/backup-page.js b/containers/libreportal/frontend/js/components/backup/backup-page.js new file mode 100644 index 0000000..eb72132 --- /dev/null +++ b/containers/libreportal/frontend/js/components/backup/backup-page.js @@ -0,0 +1,1533 @@ +// Backup page controller — restic-engine UI. +// Reads JSON snapshots written by scripts/webui/data/generators/backup/* and +// dispatches actions back into the task system (which calls bash CLI). + +// Retention presets — pick the persona that matches you. Each maps to the +// five underlying restic --keep-* values. "Custom" reveals the raw fields. +const BACKUP_RETENTION_PRESETS = { + 'inherit-global': { last: '', daily: '', weekly: '', monthly: '', yearly: '' }, + 'self-hosting': { last: '', daily: '30', weekly: '', monthly: '', yearly: '' }, + 'personal': { last: '', daily: '30', weekly: '', monthly: '6', yearly: '' }, + 'enterprise': { last: '', daily: '30', weekly: '', monthly: '12', yearly: '5' } +}; + +const BACKUP_RETENTION_PRESET_META = { + 'inherit-global': { label: 'Inherit global retention (default)', hint: 'Use whatever the Configuration tab specifies. Pick something else here only when this location needs a different policy.' }, + 'self-hosting': { label: 'Self-hosting (default)', hint: '30 days of daily backups. Plenty for a homelab — covers accidental deletes and app screw-ups.' }, + 'personal': { label: 'Personal', hint: '30 days of daily backups plus 6 monthly snapshots. Good for personal data where "what did this look like last summer" matters.' }, + 'enterprise': { label: 'Enterprise', hint: '30 daily + 12 monthly + 5 yearly. Compliance-style retention with multi-year history.' }, + 'custom': { label: 'Custom…', hint: 'Define each retention tier yourself.' } +}; + +// Per-location field metadata. Configs.json doesn't carry titles for +// CFG_BACKUP_LOC_N_* (locations are dynamic), so we provide them inline. +// ConfigShared.generateField uses TITLE + key-based widget heuristics; the +// regexes in config-options.js / config-shared.js already cover _TYPE, +// _KEEP_*, _SECRET_KEY, _ACCOUNT_KEY for the right widgets. +const BACKUP_LOC_FIELD_DEFS = { + NAME: { title: 'Friendly name', description: 'Shown in lists and on the dashboard.' }, + ENABLED: { title: 'Enabled', description: 'Push backups to this location.' }, + ENGINE: { title: 'Engine', description: 'Backup engine used at this location.' }, + TYPE: { title: 'Type', description: 'Backend the engine uses to talk to this location.' }, + PATH_MODE: { title: 'Path', description: 'Automatic puts the repo at /docker/backups/. Pick Custom to use a specific path (e.g. an attached drive or a NAS mount).' }, + PATH: { title: 'Custom path', description: 'Filesystem path on this server. Used only when Path is set to Custom.' }, + URI: { title: 'Repository URI (override)', description: 'Custom restic URI — leave blank to build from the fields below.' }, + SSH_USER: { title: 'SSH user', description: '' }, + SSH_HOST: { title: 'SSH host', description: '' }, + SSH_PORT: { title: 'SSH port', description: '' }, + SSH_PATH: { title: 'SSH remote path', description: 'Path on the remote host where the repo lives.' }, + SSH_AUTH: { title: 'SSH authentication', description: 'Key auth uses ~/.ssh/id_rsa on this host. Password mode pipes via sshpass — restic + borg only; kopia requires keys.' }, + SSH_PASS: { title: 'SSH password', description: 'Used only when SSH authentication is set to Password.' }, + S3_ACCESS_KEY: { title: 'S3 access key', description: '' }, + S3_SECRET_KEY: { title: 'S3 secret', description: '' }, + B2_ACCOUNT_ID: { title: 'B2 account ID', description: '' }, + B2_ACCOUNT_KEY: { title: 'B2 account key', description: '' }, + APPEND_ONLY: { title: 'Append-only', description: 'Ransomware-safe — refuse forget/prune for this location even if LibrePortal itself is compromised. Trades off automatic retention cleanup.' }, + CUSTOM_RETENTION: { title: 'Use custom retention', description: 'Otherwise this location inherits the global retention.' }, + KEEP_LAST: { title: 'Keep last', description: 'Snapshots to always retain.' }, + KEEP_DAILY: { title: 'Keep daily', description: 'One snapshot per day for this many days.' }, + KEEP_WEEKLY: { title: 'Keep weekly', description: 'One snapshot per week for this many weeks.' }, + KEEP_MONTHLY: { title: 'Keep monthly', description: 'One snapshot per month for this many months.' }, + KEEP_YEARLY: { title: 'Keep yearly', description: 'One snapshot per year for this many years.' } +}; + +const BACKUP_LOC_FIELDS_BY_TYPE = { + local: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'PATH_MODE', 'PATH', 'APPEND_ONLY'], + sftp: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'SSH_USER', 'SSH_HOST', 'SSH_PORT', 'SSH_PATH', 'SSH_AUTH', 'SSH_PASS', 'URI', 'APPEND_ONLY'], + rest: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'], + s3: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'S3_ACCESS_KEY', 'S3_SECRET_KEY', 'APPEND_ONLY'], + b2: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'B2_ACCOUNT_ID', 'B2_ACCOUNT_KEY', 'APPEND_ONLY'], + gs: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'], + azure: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'], + rclone: ['NAME', 'ENABLED', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'] +}; + +function backupRetentionDetectPreset(values, includeInherit = false) { + const norm = (v) => (v == null ? '' : String(v).trim()); + for (const [key, p] of Object.entries(BACKUP_RETENTION_PRESETS)) { + if (key === 'inherit-global' && !includeInherit) continue; + if (norm(values.last) === norm(p.last) && + norm(values.daily) === norm(p.daily) && + norm(values.weekly) === norm(p.weekly) && + norm(values.monthly) === norm(p.monthly) && + norm(values.yearly) === norm(p.yearly)) { + return key; + } + } + return 'custom'; +} + +class BackupPage { + constructor() { + this.currentTab = 'dashboard'; + this.dashboard = null; + this.locations = null; + this.snapshotsByLoc = {}; + this.expandedLocs = new Set(); + this.engines = []; // [{id,name,supported_types}, ...] — fetched once + this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; + this.eventBound = false; + } + + async init() { + this.currentTab = this.parseTabFromUrl() || this.currentTab; + this.applyActiveTabUi(this.currentTab); + this.bindEvents(); + await this.refreshAll(); + this.render(); + this.updatePageHeader(); + this.updatePrimaryAction(); + } + + /* Read the active tab slug from window.location, supporting both + /backup?=dashboard (the legacy libreportal ?= form used on /config) + and /backup?backup=dashboard (standard query string) so links from + either source resolve correctly. */ + parseTabFromUrl() { + const allowed = new Set(['dashboard', 'backups', 'locations', 'configuration']); + const search = window.location.search || ''; + const legacy = search.match(/\?=([^&]+)/); + if (legacy && allowed.has(legacy[1])) return legacy[1]; + const params = new URLSearchParams(search); + const q = params.get('backup') || params.get('tab'); + if (q && allowed.has(q)) return q; + return null; + } + + /* Toggle the sidebar .active class + panel visibility without going + through switchTab's URL-update path (used on initial render and + browser back/forward). */ + applyActiveTabUi(tab) { + document.querySelectorAll('.backup-layout .sidebar .category[data-backup-tab]').forEach(b => { + b.classList.toggle('active', b.dataset.backupTab === tab); + }); + document.querySelectorAll('.backup-tabpanel').forEach(p => { + p.classList.toggle('active', p.id === `backup-panel-${tab}`); + }); + } + + bindEvents() { + if (this.eventBound) return; + this.eventBound = true; + + // Browser back/forward is handled by the SPA's popstate listener — + // pushTabToUrl includes a `route` field in state so the SPA's + // handler picks it up and re-runs handleBackup, which re-parses + // the URL via parseTabFromUrl() at init time. + + document.addEventListener('click', (e) => { + const tabBtn = e.target.closest('.backup-layout .sidebar .category[data-backup-tab]'); + if (tabBtn) { + this.switchTab(tabBtn.dataset.backupTab); + return; + } + + if (e.target.closest('#backup-refresh-btn')) { + this.refreshAll().then(() => this.render()); + return; + } + + if (e.target.closest('#backup-primary-action')) { + this.handlePrimaryAction(); + return; + } + + const restoreBtn = e.target.closest('[data-action="restore-snapshot"]'); + if (restoreBtn) { + this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot); + return; + } + + const deleteBtn = e.target.closest('[data-action="delete-snapshot"]'); + if (deleteBtn) { + this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot); + return; + } + + const locHeader = e.target.closest('[data-action="toggle-location"]'); + if (locHeader) { + this.toggleLocationExpand(parseInt(locHeader.dataset.loc, 10)); + return; + } + + const locSave = e.target.closest('[data-action="save-location"]'); + if (locSave) { + this.saveInlineLocation(parseInt(locSave.dataset.loc, 10)); + return; + } + + const locDelete = e.target.closest('[data-action="delete-location"]'); + if (locDelete) { + this.deleteInlineLocation(parseInt(locDelete.dataset.loc, 10)); + return; + } + + if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) { + this.closeAllModals(); + return; + } + + if (e.target.closest('#backup-restore-confirm')) { this.confirmRestore(); return; } + if (e.target.closest('#backup-delete-confirm')) { this.confirmDelete(); return; } + if (e.target.closest('#backup-add-location-confirm')) { this.confirmAddLocation(); return; } + const engineBtn = e.target.closest('[data-action="open-engine-details"]'); + if (engineBtn) { this.openEngineDetailsModal(engineBtn); return; } + + const exportBtn = e.target.closest('[data-action="export-passwords"]'); + if (exportBtn) { this.exportRepositoryPasswords(exportBtn); return; } + + const saveBtn = e.target.closest('[data-backup-save]'); + if (saveBtn) { + this.saveSection(saveBtn.dataset.backupSave); + return; + } + }); + + document.addEventListener('input', (e) => { + if (e.target.id === 'backup-snapshot-filter' || e.target.id === 'backup-snapshot-repo') { + this.renderSnapshots(); + } + }); + + // Type select changes refresh the visible connection fields inline. + // Retention preset changes are handled by applyRetentionPreset, which + // already updates CUSTOM_RETENTION too — no extra toggle wiring needed. + document.addEventListener('change', (e) => { + const detailsScope = e.target.closest('.backup-location-row .task-details'); + if (detailsScope) { + const locIdx = parseInt(detailsScope.dataset.loc, 10); + if (e.target.matches('[name$="_TYPE"]')) { + this.refreshInlineTypeFields(locIdx, e.target.value); + } + if (e.target.matches('[name$="_SSH_AUTH"]')) { + this.applySshAuthVisibility(detailsScope); + } + if (e.target.matches('[name$="_PATH_MODE"]')) { + this.applyPathModeVisibility(detailsScope); + } + } + const presetSel = e.target.closest('[data-retention-preset]'); + if (presetSel) { + this.applyRetentionPreset(presetSel); + } + }); + } + + async refreshAll() { + const ts = Date.now(); + const [dashboard, locations] = await Promise.all([ + this.fetchJson(`/data/backup/generated/dashboard.json?t=${ts}`), + this.fetchJson(`/data/backup/generated/locations.json?t=${ts}`) + ]); + this.dashboard = dashboard; + this.locations = locations; + this.snapshotsByLoc = {}; + + if (!this.engines.length) await this.loadEngines(); + + if (locations?.locations?.length) { + const enabled = locations.locations.filter(l => l.enabled); + await Promise.all(enabled.map(async (l) => { + const s = await this.fetchJson(`/data/backup/generated/snapshots_${l.idx}.json?t=${ts}`); + if (s) this.snapshotsByLoc[l.idx] = s; + })); + } + } + + async fetchJson(url) { + try { const r = await fetch(url); if (!r.ok) return null; return await r.json(); } + catch { return null; } + } + + async loadEngines() { + const ts = Date.now(); + const index = await this.fetchJson(`/data/backup/generated/engines/index.json?t=${ts}`); + const ids = index?.engines || []; + const metas = await Promise.all(ids.map(id => + this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(id)}.json?t=${ts}`) + )); + this.engines = metas.filter(Boolean); + // Fallback so the dropdown never collapses to empty if the regen + // hasn't run yet — restic is always assumed available. + if (!this.engines.length) { + this.engines = [{ id: 'restic', name: 'restic', supported_types: ['local','sftp','rest','s3','b2','gs','azure','rclone'] }]; + } + } + + engineDisplayName(id) { + if (!id) return 'restic'; + const match = (this.engines || []).find(e => e.id === id); + return match?.name || id; + } + + enginesForType(type) { + if (!type) return this.engines; + return this.engines.filter(e => + !Array.isArray(e.supported_types) || + e.supported_types.includes(type) + ); + } + + switchTab(tab, opts = {}) { + if (!tab || tab === this.currentTab) return; + this.currentTab = tab; + this.applyActiveTabUi(tab); + this.updatePageHeader(); + this.updatePrimaryAction(); + if (!opts.fromPopstate) this.pushTabToUrl(tab); + } + + pushTabToUrl(tab) { + const url = `/backup?=${tab}`; + // Use replaceState for the *first* push (initial tab inferred from + // URL); otherwise pushState so back/forward navigates between tabs. + if (!this._pushedAnyTab) { + window.history.replaceState({ backupTab: tab, route: url }, '', url); + this._pushedAnyTab = true; + } else { + window.history.pushState({ backupTab: tab, route: url }, '', url); + } + } + + updatePageHeader() { + const titleEl = document.getElementById('backup-section-title'); + const subEl = document.getElementById('backup-section-subtitle'); + const iconEl = document.getElementById('backup-page-header-icon'); + if (titleEl) titleEl.textContent = this.titleFor(this.currentTab); + if (subEl) subEl.textContent = this.subtitleFor(this.currentTab); + if (iconEl) iconEl.innerHTML = this.iconFor(this.currentTab); + } + + titleFor(tab) { + return { + dashboard: 'Dashboard', + backups: 'Backups', + locations: 'Locations', + configuration: 'Configuration' + }[tab] || 'Backups'; + } + + subtitleFor(tab) { + return { + dashboard: 'Per-app status and storage at a glance.', + backups: 'Every snapshot across every enabled location.', + locations: 'Where backups are stored. Add, edit, or remove destinations.', + configuration: 'Schedule, retention, and engine settings.' + }[tab] || ''; + } + + iconFor(tab) { + const icons = { + dashboard: + '' + + '' + + '' + + '' + + '', + backups: + '' + + '' + + '' + + '', + locations: + '' + + '' + + '', + configuration: + '' + + '' + + '' + }; + return icons[tab] || icons.backups; + } + + updatePrimaryAction() { + const btn = document.getElementById('backup-primary-action'); + if (!btn) return; + if (this.currentTab === 'locations') { + btn.innerHTML = ` + + + + + Add location + `; + btn.dataset.intent = 'add-location'; + } else { + btn.innerHTML = ` + + + + + + Backup all apps + `; + btn.dataset.intent = 'backup-all'; + } + } + + handlePrimaryAction() { + const intent = document.getElementById('backup-primary-action')?.dataset.intent; + if (intent === 'add-location') { + this.openAddLocationModal(); + } else { + this.runBackupAllApps(); + } + } + + render() { + this.renderDashboard(); + this.renderLocations(); + this.renderSnapshots(); + this.renderConfiguration(); + } + + renderDashboard() { + const summary = document.getElementById('backup-summary-row'); + const appGrid = document.getElementById('backup-app-grid'); + const locSummary = document.getElementById('backup-repo-list-summary'); + if (!summary || !appGrid || !locSummary) return; + + const d = this.dashboard || {}; + const locs = d.locations || []; + const apps = d.apps || []; + const totalSnapshots = Object.values(this.snapshotsByLoc).reduce((acc, r) => { + return acc + (Array.isArray(r?.snapshots) ? r.snapshots.length : 0); + }, 0); + const protectedApps = apps.filter(a => a.latest_snapshot).length; + const totalSize = locs.reduce((acc, r) => acc + (parseInt(r.total_size_bytes) || 0), 0); + + summary.innerHTML = ` + ${this.tile('Apps protected', `${protectedApps} / ${apps.length}`, 'with at least one backup')} + ${this.tile('Backups', `${totalSnapshots}`, `across ${locs.length} location${locs.length === 1 ? '' : 's'}`)} + ${this.tile('Total stored', this.formatBytes(totalSize), 'deduplicated, encrypted')} + `; + + if (!apps.length) { + appGrid.innerHTML = `
No apps installed yet.
`; + } else { + appGrid.innerHTML = apps.map(app => this.renderAppTile(app)).join(''); + } + + if (!locs.length) { + locSummary.innerHTML = `
No locations enabled.
`; + } else { + locSummary.innerHTML = locs.map(r => ` +
+
+ ${this.escape(r.type)} + ${this.escape(r.name)} +
+
+ ${this.formatBytes(parseInt(r.total_size_bytes) || 0)}
+ ${r.total_files || 0} files +
+
+ `).join(''); + } + } + + tile(label, value, detail) { + return ` +
+
${this.escape(label)}
+
${this.escape(value)}
+
${this.escape(detail || '')}
+
+ `; + } + + /* Look up the icon + display name from window.apps the same way the + dashboard and tasks page do. Falls back to the default app icon and + a capitalised slug if the app isn't in the cached list. */ + appMeta(slug) { + const apps = window.apps || []; + const match = apps.find(a => { + const command = a.command || ''; + return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase(); + }); + let icon = match?.icon || 'icons/apps/default.svg'; + if (!icon.startsWith('/')) icon = '/' + icon; + const displayName = (typeof window.getAppDisplayName === 'function') + ? window.getAppDisplayName(slug) + : (slug.charAt(0).toUpperCase() + slug.slice(1)); + return { icon, displayName }; + } + + renderAppTile(app) { + const has = !!app.latest_snapshot; + const dot = has ? 'ok' : 'none'; + const when = has ? this.formatRelative(app.latest_time) : 'No backup yet'; + const { icon, displayName } = this.appMeta(app.app); + return ` +
+ +
+
${this.escape(displayName)}
+
+ + ${when} +
+
+
+ `; + } + + renderLocations() { + const list = document.getElementById('backup-location-list'); + const repoSelect = document.getElementById('backup-snapshot-repo'); + if (!list) return; + + const locs = this.locations?.locations || []; + if (!locs.length) { + list.innerHTML = ` +
+ No backup locations configured yet.
+ Click Add location above to create one. +
+ `; + } else { + list.innerHTML = locs.map(l => this.renderLocationRow(l)).join(''); + } + + if (repoSelect) { + const cur = repoSelect.value; + repoSelect.innerHTML = `` + + locs.filter(l => l.enabled).map(l => ``).join(''); + if (cur) repoSelect.value = cur; + } + } + + renderLocationRow(l) { + // Status pill mirrors task-status: ✅ Ready / ⏳ Initialising / ⏸ Disabled. + const statusKind = l.enabled && l.password_exists ? 'ready' + : l.enabled && !l.password_exists ? 'init' + : 'disabled'; + const statusMeta = { + ready: { icon: '✅', label: 'Ready' }, + init: { icon: '⏳', label: 'Initialising' }, + disabled: { icon: '⏸', label: 'Disabled' } + }[statusKind]; + const snapCount = this.snapshotsByLoc[l.idx]?.snapshots?.length ?? 0; + const expanded = this.expandedLocs.has(l.idx); + const size = this.formatBytes(parseInt(l.total_size_bytes) || 0); + return ` +
+
+
+ ${this.typeIcon(l.type)} + ${this.escape(l.name)} + ${this.escape(l.type)} + ${this.escape(this.engineDisplayName(l.engine))} + ${l.append_only ? 'append-only' : ''} + ${statusMeta.icon} ${statusMeta.label} + · + ${snapCount} backup${snapCount === 1 ? '' : 's'} + · + ${size} +
+ + + +
+
+ ${expanded ? this.renderLocationDetailsBody(l) : ''} +
+
+ `; + } + + /* Inline-SVG icon for a location's backend type. Local gets the disk + (stack of platters) glyph; everything else gets a cloud — that's + the visual line between "lives on this box" and "lives somewhere else." */ + typeIcon(type) { + const local = ` + + + + `; + const cloud = ` + + `; + return type === 'local' ? local : cloud; + } + + renderLocationDetailsBody(l) { + const idx = l.idx; + const connectionFields = BACKUP_LOC_FIELDS_BY_TYPE[l.type] || BACKUP_LOC_FIELDS_BY_TYPE.local; + const retentionValues = { + last: l.custom_retention ? (l.keep_last || '') : '', + daily: l.custom_retention ? (l.keep_daily || '') : '', + weekly: l.custom_retention ? (l.keep_weekly || '') : '', + monthly: l.custom_retention ? (l.keep_monthly || '') : '', + yearly: l.custom_retention ? (l.keep_yearly || '') : '' + }; + + return ` +
+

Connection

+
+ ${this.renderLocFields(idx, connectionFields, l)} +
+
+
+

Retention

+

When to delete old backups from this location.

+
+ ${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, retentionValues, true)} +
+
+
+ + +
+ `; + } + + toggleLocationExpand(idx) { + const row = document.querySelector(`.backup-location-row[data-loc="${idx}"]`); + if (!row) return; + const details = row.querySelector('.task-details'); + const header = row.querySelector('.task-header'); + if (!details) return; + + const willOpen = !this.expandedLocs.has(idx); + if (willOpen) { + this.expandedLocs.add(idx); + const loc = (this.locations?.locations || []).find(l => l.idx === idx); + if (loc) { + details.innerHTML = this.renderLocationDetailsBody(loc); + this.tagFieldsForSave(details); + this.filterEngineSelect(details, loc.type, loc.engine); + this.applySshAuthVisibility(details); + this.applyPathModeVisibility(details); + } + this.enhanceEngineDetailsButton(); + details.classList.add('show'); + row.classList.add('expanded'); + if (header) header.setAttribute('aria-expanded', 'true'); + } else { + this.expandedLocs.delete(idx); + details.classList.remove('show'); + row.classList.remove('expanded'); + if (header) header.setAttribute('aria-expanded', 'false'); + } + } + + refreshInlineTypeFields(idx, type) { + const container = document.getElementById(`backup-location-${idx}-connection`); + if (!container) return; + const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {}; + const suffixes = BACKUP_LOC_FIELDS_BY_TYPE[type] || BACKUP_LOC_FIELDS_BY_TYPE.local; + container.innerHTML = this.renderLocFields(idx, suffixes, { ...loc, type }); + this.tagFieldsForSave(container); + this.filterEngineSelect(container, type, loc.engine); + this.applySshAuthVisibility(container); + this.applyPathModeVisibility(container); + this.enhanceEngineDetailsButton(); + } + + /* Hide the SSH password field when SSH auth = key, show it when = password. + Applied at expand time and whenever the SSH_AUTH select changes. */ + applySshAuthVisibility(scope) { + const authSelect = scope.querySelector('select[name$="_SSH_AUTH"]'); + if (!authSelect) return; + const passInput = scope.querySelector('input[name$="_SSH_PASS"]'); + const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement; + if (!passGroup) return; + passGroup.style.display = authSelect.value === 'password' ? '' : 'none'; + } + + /* Hide the custom PATH input when PATH_MODE=auto, show when =custom. */ + applyPathModeVisibility(scope) { + const modeSelect = scope.querySelector('select[name$="_PATH_MODE"]'); + if (!modeSelect) return; + const pathInput = scope.querySelector('input[name$="_PATH"]:not([name$="_SSH_PATH"])'); + const pathGroup = pathInput?.closest('.field-group') || pathInput?.parentElement; + if (!pathGroup) return; + pathGroup.style.display = modeSelect.value === 'custom' ? '' : 'none'; + } + + /* Trim the per-location ENGINE select to only engines whose + supported_types include the location's current TYPE. If the currently + saved engine isn't compatible, fall back to the first compatible one. */ + filterEngineSelect(scope, type, preferred) { + const select = scope.querySelector('select[name$="_ENGINE"]'); + if (!select) return; + const compatible = this.enginesForType(type); + if (!compatible.length) return; + + const want = compatible.find(e => e.id === preferred)?.id || compatible[0].id; + select.innerHTML = compatible + .map(e => ``) + .join(''); + select.value = want; + } + + async saveInlineLocation(idx) { + await this.saveSection(`location-${idx}`); + } + + async deleteInlineLocation(idx) { + const loc = (this.locations?.locations || []).find(l => l.idx === idx); + const name = loc?.name || `Location ${idx}`; + if (!confirm(`Delete location "${name}"?\n\nBackup data already stored at this location is not deleted — only LibrePortal's reference to it. The password file on disk also stays in place.`)) return; + this.expandedLocs.delete(idx); + await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null); + setTimeout(() => this.reloadAfterSave(), 2000); + } + + renderSnapshots() { + const tbody = document.getElementById('backup-snapshot-tbody'); + if (!tbody) return; + + const filter = (document.getElementById('backup-snapshot-filter')?.value || '').toLowerCase(); + const locFilter = document.getElementById('backup-snapshot-repo')?.value || ''; + + const locNameByIdx = {}; + (this.locations?.locations || []).forEach(l => { locNameByIdx[l.idx] = l.name; }); + + const rows = []; + Object.entries(this.snapshotsByLoc).forEach(([locIdx, data]) => { + if (locFilter && String(locFilter) !== String(locIdx)) return; + const snaps = Array.isArray(data?.snapshots) ? data.snapshots : []; + snaps.forEach(s => { + const app = (s.tags || []).map(t => /^app=/.test(t) ? t.slice(4) : null).find(Boolean) || '—'; + rows.push({ + app, + host: s.hostname || '—', + locIdx, + locName: locNameByIdx[locIdx] || `Loc ${locIdx}`, + time: s.time, + id: s.short_id || (s.id || '').slice(0, 8), + }); + }); + }); + + rows.sort((a, b) => String(b.time).localeCompare(String(a.time))); + + const filtered = filter ? rows.filter(r => + r.app.toLowerCase().includes(filter) || + r.host.toLowerCase().includes(filter) || + r.id.toLowerCase().includes(filter) || + r.locName.toLowerCase().includes(filter) + ) : rows; + + if (!filtered.length) { + tbody.innerHTML = `No backups yet.`; + return; + } + + tbody.innerHTML = filtered.map(r => ` + + ${this.escape(r.app)} + ${this.escape(r.host)} + ${this.escape(r.locName)} + ${this.formatRelative(r.time)} + ${this.escape(r.id)} + + + + + + `).join(''); + } + + renderConfiguration() { + const body = document.getElementById('backup-configuration-body'); + if (!body) return; + + body.innerHTML = ` +
+
+ Keep your LibrePortal config backed up offline. + Repository passwords live inside the config directory. Without that backup, snapshots cannot be decrypted by anyone — including you. +
+ +
+
+ `; + + this.invokeConfigManager(); + } + + async exportRepositoryPasswords(triggerBtn) { + const restoreBtn = () => { + if (triggerBtn) { + triggerBtn.disabled = false; + triggerBtn.dataset.busy = ''; + } + }; + if (triggerBtn) { + triggerBtn.disabled = true; + triggerBtn.dataset.busy = '1'; + } + + try { + const task = await this.taskManager?.createTask( + 'libreportal webui generate backup', + 'webui', + null + ); + if (task?.id) { + await this.waitForTask(task.id, 20000); + } + const res = await fetch(`/data/backup/generated/passwords.txt?t=${Date.now()}`, { + credentials: 'same-origin' + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = await res.text(); + if (!text || !text.includes('CFG_BACKUP_LOC_')) { + throw new Error('Password file is empty — no locations configured?'); + } + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const host = (window.systemConfigs?.CFG_INSTALL_NAME || 'libreportal').replace(/[^a-z0-9_-]/gi, '_'); + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + a.href = url; + a.download = `libreportal-backup-passwords-${host}-${stamp}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + this.notify('Password export downloaded — store it offline.', 'success'); + } catch (err) { + this.notify(`Export failed: ${err.message || err}`, 'error'); + } finally { + restoreBtn(); + } + } + + waitForTask(taskId, timeoutMs = 15000) { + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + window.removeEventListener('taskCompleted', onComplete); + clearTimeout(timer); + resolve(); + }; + const onComplete = (e) => { + if (e?.detail?.taskId === taskId) finish(); + }; + window.addEventListener('taskCompleted', onComplete); + const timer = setTimeout(finish, timeoutMs); + }); + } + + async invokeConfigManager(attempt = 0) { + if (window.configManager && typeof window.configManager.renderConfig === 'function') { + try { + await window.configManager.renderConfig('backup'); + this.enhanceConfigurationWithPresets(); + } catch (err) { + console.error('Backup configuration render failed:', err); + } + return; + } + if (attempt >= 20) { + const sec = document.getElementById('config-section'); + if (sec) sec.innerHTML = `
Configuration system not loaded. Try refreshing the page.
`; + return; + } + setTimeout(() => this.invokeConfigManager(attempt + 1), 150); + } + + /* Post-render polish on the dynamic /config render: wrap the five raw + retention number fields in a persona-preset dropdown. The five inputs + stay in the DOM (so /config's save flow captures them unchanged) but + are hidden under "Custom…" by default. */ + enhanceConfigurationWithPresets() { + this.enhanceEngineDetailsButton(); + const lastInput = document.querySelector('#config-section [name="CFG_BACKUP_KEEP_LAST"]'); + if (!lastInput) return; + + const section = lastInput.closest('.config-category'); + if (!section || section.dataset.backupPresetEnhanced === '1') return; + section.dataset.backupPresetEnhanced = '1'; + + const fieldNames = [ + 'CFG_BACKUP_KEEP_LAST', + 'CFG_BACKUP_KEEP_DAILY', + 'CFG_BACKUP_KEEP_WEEKLY', + 'CFG_BACKUP_KEEP_MONTHLY', + 'CFG_BACKUP_KEEP_YEARLY' + ]; + const inputs = fieldNames + .map(n => section.querySelector(`[name="${n}"]`)) + .filter(Boolean); + if (inputs.length < 5) return; + + const wrappers = inputs.map(input => { + return input.closest('.config-field, .field-group, .form-group') || input.parentElement; + }); + + const extraCustomFields = ['CFG_BACKUP_PRUNE_AFTER_FORGET']; + extraCustomFields.forEach(name => { + const el = section.querySelector(`[name="${name}"]`); + if (el) { + const wrap = el.closest('.config-field, .field-group, .form-group') || el.parentElement; + if (wrap) wrappers.push(wrap); + } + }); + + const readVals = () => ({ + last: inputs[0].value || '', + daily: inputs[1].value || '', + weekly: inputs[2].value || '', + monthly: inputs[3].value || '', + yearly: inputs[4].value || '' + }); + + const preset = backupRetentionDetectPreset(readVals()); + const meta = BACKUP_RETENTION_PRESET_META[preset]; + const presetOptions = Object.entries(BACKUP_RETENTION_PRESET_META) + .map(([k, v]) => ``) + .join(''); + + const block = document.createElement('div'); + block.className = 'backup-retention-preset-block'; + block.innerHTML = ` + +
${this.escape(meta?.hint || '')}
+ `; + + const fieldsGrid = section.querySelector('.config-fields'); + if (fieldsGrid) { + fieldsGrid.prepend(block); + } else { + section.prepend(block); + } + + const applyVisibility = (presetKey) => { + const isCustom = presetKey === 'custom'; + wrappers.forEach(w => { if (w) w.style.display = isCustom ? '' : 'none'; }); + }; + applyVisibility(preset); + + const select = block.querySelector('[data-backup-retention-preset]'); + const hintEl = block.querySelector('.backup-retention-hint'); + select.addEventListener('change', () => { + const chosen = select.value; + hintEl.textContent = BACKUP_RETENTION_PRESET_META[chosen]?.hint || ''; + applyVisibility(chosen); + if (chosen === 'custom') return; + const p = BACKUP_RETENTION_PRESETS[chosen]; + const map = { last: 0, daily: 1, weekly: 2, monthly: 3, yearly: 4 }; + Object.entries(map).forEach(([k, i]) => { + inputs[i].value = p[k]; + inputs[i].dispatchEvent(new Event('input', { bubbles: true })); + inputs[i].dispatchEvent(new Event('change', { bubbles: true })); + }); + }); + } + + /* Retention preset dropdown + hidden underlying fields. + `prefix` is the CFG name prefix, e.g. 'CFG_BACKUP_' or 'CFG_BACKUP_LOC_3_'. + When `includeInherit` is true (per-location scope), an "Inherit global" + option is added at the top and an extra hidden CUSTOM_RETENTION field is + written: false when inherit, true otherwise. The five raw KEEP_* inputs + are always rendered (so the save flow captures them) but hidden until + "Custom…" is selected. */ + formRetention(prefix, values, includeInherit = false) { + const preset = backupRetentionDetectPreset(values, includeInherit); + const meta = BACKUP_RETENTION_PRESET_META[preset]; + const presetOptions = Object.entries(BACKUP_RETENTION_PRESET_META) + .filter(([k]) => k !== 'inherit-global' || includeInherit) + .map(([k, v]) => ``) + .join(''); + + const customRetentionHidden = includeInherit + ? `` + : ''; + + return ` +
+ +
${this.escape(meta?.hint || '')}
+ ${customRetentionHidden} +
+
+
+ ${this.formInput(`${prefix}KEEP_LAST`, 'Keep last', values.last, 'number', '', 'snapshots')} + ${this.formInput(`${prefix}KEEP_DAILY`, 'Keep daily', values.daily, 'number', '', 'days')} + ${this.formInput(`${prefix}KEEP_WEEKLY`, 'Keep weekly', values.weekly, 'number', '', 'weeks')} + ${this.formInput(`${prefix}KEEP_MONTHLY`, 'Keep monthly', values.monthly, 'number', '', 'months')} + ${this.formInput(`${prefix}KEEP_YEARLY`, 'Keep yearly', values.yearly, 'number', '', 'years')} +
+
+ `; + } + + applyRetentionPreset(selectEl) { + const block = selectEl.closest('[data-retention-prefix]'); + const advanced = block?.nextElementSibling; + if (!block) return; + const prefix = block.dataset.retentionPrefix; + const allowInherit = block.dataset.retentionAllowInherit === '1'; + const preset = selectEl.value; + const hintEl = block.querySelector('[data-retention-hint]'); + if (hintEl) hintEl.textContent = BACKUP_RETENTION_PRESET_META[preset]?.hint || ''; + + if (preset === 'custom') { + if (advanced) advanced.hidden = false; + } else { + if (advanced) advanced.hidden = true; + const p = BACKUP_RETENTION_PRESETS[preset]; + if (p) { + const setField = (suffix, value) => { + const el = document.querySelector(`[name="${prefix}${suffix}"]`); + if (el) { + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + } + }; + setField('KEEP_LAST', p.last); + setField('KEEP_DAILY', p.daily); + setField('KEEP_WEEKLY', p.weekly); + setField('KEEP_MONTHLY', p.monthly); + setField('KEEP_YEARLY', p.yearly); + } + } + + // Keep CUSTOM_RETENTION in sync with the preset (location scope only). + if (allowInherit) { + const cr = block.querySelector(`[name="${prefix}CUSTOM_RETENTION"]`); + if (cr) cr.value = preset === 'inherit-global' ? 'false' : 'true'; + } + } + + formInput(name, label, value, type = 'text', placeholder = '', unit = '') { + const escVal = this.escape(value ?? ''); + const escPh = this.escape(placeholder); + const escLabel = this.escape(label); + const inputHTML = ``; + const wrapped = unit ? `
${inputHTML}${this.escape(unit)}
` : inputHTML; + return ` + + `; + } + + formSelect(name, label, value, options) { + const escLabel = this.escape(label); + const opts = options.map(([v, lbl]) => ``).join(''); + return ` + + `; + } + + formToggle(name, label, checked) { + const escLabel = this.escape(label); + return ` + + `; + } + + /* Append a "Details" button next to every Engine field (global or + per-location). The button reads its engine id from a sibling input + at click time so per-location selects work even before save. */ + enhanceEngineDetailsButton() { + const selector = '[name="CFG_BACKUP_ENGINE"], [name^="CFG_BACKUP_LOC_"][name$="_ENGINE"]'; + document.querySelectorAll(`#config-section ${selector}, .backup-location-details ${selector}`).forEach((engineInput) => { + const customSelect = engineInput.closest('.custom-select'); + const wrapTarget = customSelect || engineInput; + const group = wrapTarget.closest('.field-group') || wrapTarget.parentElement; + if (!group || group.dataset.engineDetailsBound === '1') return; + group.dataset.engineDetailsBound = '1'; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'backup-secondary-btn backup-engine-details-btn'; + btn.dataset.action = 'open-engine-details'; + btn.innerHTML = ` + + + + + + Details + `; + + const wrap = document.createElement('div'); + wrap.className = 'backup-engine-input-row'; + wrapTarget.parentNode.insertBefore(wrap, wrapTarget); + wrap.appendChild(wrapTarget); + wrap.appendChild(btn); + }); + } + + async openEngineDetailsModal(triggerEl) { + const modal = document.getElementById('backup-engine-modal'); + const body = document.getElementById('backup-engine-modal-body'); + const title = document.getElementById('backup-engine-modal-title'); + if (!modal || !body) return; + + // Find the engine select adjacent to the Details button that fired + // this event so per-location Details work even when the user has + // changed the select but not saved yet. + let engineId = (window.systemConfigs?.CFG_BACKUP_ENGINE || 'restic').trim(); + const row = triggerEl?.closest('.backup-engine-input-row'); + const sel = row?.querySelector('select, input'); + if (sel && sel.value) engineId = sel.value.trim(); + body.innerHTML = `
Loading engine details…
`; + modal.classList.add('open'); + + const data = await this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(engineId)}.json?t=${Date.now()}`); + if (!data) { + body.innerHTML = ` +
+ No details file for engine "${this.escape(engineId)}".
+ Add scripts/backup/engines/${this.escape(engineId)}.json and run the WebUI regen. +
+ `; + return; + } + + if (title) title.textContent = `Backup engine: ${data.name || engineId}`; + const propsHTML = (data.properties || []).map(p => + `${this.escape(p.label)}${this.escape(p.value)}` + ).join(''); + const featsHTML = (data.features || []).map(f => `
  • ${this.escape(f)}
  • `).join(''); + const docsHTML = data.docs_url + ? `${this.escape(data.docs_url)} ↗` + : ''; + const logoHTML = data.logo + ? `` + : ''; + + body.innerHTML = ` +
    + ${logoHTML} +
    +

    ${this.escape(data.name || engineId)}

    +

    ${this.escape(data.tagline || '')}

    +
    +
    + ${propsHTML ? `${propsHTML}
    ` : ''} + ${featsHTML ? `
    Highlights
      ${featsHTML}
    ` : ''} + ${docsHTML ? `
    Documentation

    ${docsHTML}

    ` : ''} + `; + } + + formCrontab(name, label, value) { + if (typeof ConfigShared === 'undefined' || !ConfigShared.createCrontabField) { + return this.formInput(name, label, value, 'text', 'minute hour day month weekday'); + } + const fieldId = `config-${name}`; + let cronHtml = ConfigShared.createCrontabField(fieldId, name, value, label, ''); + cronHtml = cronHtml.replace(`name="${name}"`, `name="${name}" data-backup-field`); + return ` + + `; + } + + formReadOnly(label, value) { + return ` +
    + ${this.escape(label)} + ${this.escape(value)} +
    + `; + } + + /* ----- Location modal (edit / add) ----- */ + + openLocationModal_unused(idx) { + const loc = (this.locations?.locations || []).find(l => l.idx === idx); + if (!loc) return; + + const modal = document.getElementById('backup-location-modal'); + const body = document.getElementById('backup-location-modal-body'); + const title = document.getElementById('backup-location-modal-title'); + if (!modal || !body) return; + + modal.dataset.locIdx = idx; + title.textContent = `Edit location: ${loc.name}`; + + body.innerHTML = ` +
    +
    +
    +
    +

    Retention

    +

    When to delete old backups from this location.

    +
    +
    + `; + + this.refreshLocationModalTypeFields(loc.type, loc); + this.refreshLocationModalRetention(loc.custom_retention); + + modal.classList.add('open'); + } + + refreshLocationModalTypeFields(type, locOverride) { + const container = document.getElementById('backup-location-connection'); + const modal = document.getElementById('backup-location-modal'); + if (!container || !modal) return; + const idx = parseInt(modal.dataset.locIdx, 10); + const loc = locOverride || (this.locations?.locations || []).find(l => l.idx === idx) || {}; + + const suffixes = BACKUP_LOC_FIELDS_BY_TYPE[type] || BACKUP_LOC_FIELDS_BY_TYPE.local; + container.innerHTML = this.renderLocFields(idx, suffixes, loc); + this.tagFieldsForSave(container); + } + + refreshLocationModalRetention(enabled) { + const container = document.getElementById('backup-location-retention'); + const modal = document.getElementById('backup-location-modal'); + if (!container || !modal) return; + const idx = parseInt(modal.dataset.locIdx, 10); + const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {}; + + // The "Use custom retention" toggle itself stays at the top regardless. + const toggleField = this.renderLocFields(idx, ['CUSTOM_RETENTION'], loc); + + if (!enabled) { + container.innerHTML = ` + ${toggleField} +
    Inherits the global retention policy from the Configuration tab.
    + `; + this.tagFieldsForSave(container); + return; + } + + const values = { + last: loc.keep_last || '', + daily: loc.keep_daily || '', + weekly: loc.keep_weekly || '', + monthly: loc.keep_monthly || '', + yearly: loc.keep_yearly || '' + }; + container.innerHTML = ` + ${toggleField} + ${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, values)} + `; + this.tagFieldsForSave(container); + } + + /* Render a list of CFG_BACKUP_LOC_${idx}_${suffix} fields via the same + ConfigShared.generateField machinery /config uses, so widgets and + styling match pixel-for-pixel. Values are picked up from the location + object (locations.json) using the camelCase mirrors of each suffix. */ + renderLocFields(idx, suffixes, loc) { + if (typeof ConfigShared === 'undefined' || !ConfigShared.generateField) { + return `
    Configuration system not loaded.
    `; + } + const locValueLookup = { + NAME: loc.name, ENABLED: loc.enabled ? 'true' : 'false', TYPE: loc.type, + ENGINE: loc.engine || 'restic', + PATH_MODE: loc.path_mode || 'custom', + PATH: loc.path, URI: loc.uri, SSH_USER: loc.ssh_user, SSH_HOST: loc.ssh_host, + SSH_PORT: loc.ssh_port, SSH_PATH: loc.ssh_path, + SSH_AUTH: loc.ssh_auth || 'key', SSH_PASS: '', + S3_ACCESS_KEY: '', S3_SECRET_KEY: '', + B2_ACCOUNT_ID: '', B2_ACCOUNT_KEY: '', + APPEND_ONLY: loc.append_only ? 'true' : 'false', + CUSTOM_RETENTION: loc.custom_retention ? 'true' : 'false', + KEEP_LAST: loc.keep_last, KEEP_DAILY: loc.keep_daily, + KEEP_WEEKLY: loc.keep_weekly, KEEP_MONTHLY: loc.keep_monthly, + KEEP_YEARLY: loc.keep_yearly + }; + + let html = '
    '; + let inBlock = 0; + for (const suffix of suffixes) { + const def = BACKUP_LOC_FIELD_DEFS[suffix]; + if (!def) continue; + const key = `CFG_BACKUP_LOC_${idx}_${suffix}`; + const value = (locValueLookup[suffix] ?? '').toString(); + const fieldId = `config-${key}`; + // Three-up grouping mirrors /config's row layout. + if (inBlock > 0 && inBlock % 3 === 0) html += '
    '; + html += ConfigShared.generateField(fieldId, key, value, def.title, def.description, {}, {}); + inBlock++; + } + html += '
    '; + return html; + } + + tagFieldsForSave(container) { + container.querySelectorAll('input[name], select[name], textarea[name]').forEach(el => { + if (!el.hasAttribute('data-backup-field')) { + el.setAttribute('data-backup-field', ''); + if (el.type === 'checkbox') el.setAttribute('data-backup-bool', ''); + } + }); + } + + async saveLocationModal() { + const modal = document.getElementById('backup-location-modal'); + if (!modal) return; + const idx = parseInt(modal.dataset.locIdx, 10); + this.closeAllModals(); + await this.saveSection(`location-${idx}`); + } + + async deleteLocationModal() { + const modal = document.getElementById('backup-location-modal'); + if (!modal) return; + const idx = parseInt(modal.dataset.locIdx, 10); + const loc = (this.locations?.locations || []).find(l => l.idx === idx); + const name = loc?.name || `Location ${idx}`; + if (!confirm(`Delete location "${name}"?\n\nBackup data already stored at this location is not deleted by this action — only LibrePortal's reference to it. The password file on disk also stays in place (rename it manually if you want to start fresh).`)) return; + this.closeAllModals(); + await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null); + setTimeout(() => this.reloadAfterSave(), 2000); + } + + /* ----- Add location modal ----- */ + + openAddLocationModal() { + const modal = document.getElementById('backup-add-location-modal'); + const body = document.getElementById('backup-add-location-modal-body'); + if (!modal || !body) return; + body.innerHTML = ` +
    + ${this.formInput('__add_name', 'Friendly name', '', 'text', 'e.g. Office NAS')} + ${this.formSelect('__add_type', 'Type', 'local', [ + ['local', 'Local / mounted path'], + ['sftp', 'SFTP'], + ['rest', 'REST server'], + ['s3', 'S3'], + ['b2', 'Backblaze B2'], + ['gs', 'Google Cloud Storage'], + ['azure', 'Azure'], + ['rclone', 'rclone'] + ])} +
    +

    The location starts disabled — fill in its connection details on the next screen, then toggle Enabled.

    + `; + modal.classList.add('open'); + } + + async confirmAddLocation() { + const modal = document.getElementById('backup-add-location-modal'); + if (!modal) return; + const name = modal.querySelector('[name="__add_name"]')?.value?.trim(); + const type = modal.querySelector('[name="__add_type"]')?.value || 'local'; + if (!name) { this.notify('Name is required.', 'error'); return; } + this.closeAllModals(); + const safeName = name.replace(/'/g, "'\\''"); + await this.runTask(`libreportal backup location add '${safeName}' ${type}`, 'backup', null); + setTimeout(() => this.reloadAfterSave(), 2000); + } + + /* ----- Snapshot restore/delete modals ----- */ + + openRestoreModal(app, locIdx, snapshot) { + const locName = this.locName(locIdx); + const modal = document.getElementById('backup-restore-modal'); + const body = document.getElementById('backup-restore-modal-body'); + if (!modal || !body) return; + body.innerHTML = ` +

    Restore ${this.escape(app)} from backup ${this.escape(snapshot)} at ${this.escape(locName)}?

    +

    The app will be stopped, its folder wiped, the snapshot restored in place, then the app started again. App-specific pre/post-restore hooks run if present.

    + `; + modal.dataset.app = app; + modal.dataset.locIdx = locIdx; + modal.dataset.snapshot = snapshot; + modal.classList.add('open'); + } + + openDeleteModal(app, locIdx, snapshot) { + const locName = this.locName(locIdx); + const modal = document.getElementById('backup-delete-modal'); + const body = document.getElementById('backup-delete-modal-body'); + if (!modal || !body) return; + body.innerHTML = ` +

    Delete backup ${this.escape(snapshot)} for ${this.escape(app)} from ${this.escape(locName)}?

    +

    This cannot be undone. Append-only locations will reject the operation.

    + `; + modal.dataset.app = app; + modal.dataset.locIdx = locIdx; + modal.dataset.snapshot = snapshot; + modal.classList.add('open'); + } + + locName(idx) { + const l = (this.locations?.locations || []).find(x => String(x.idx) === String(idx)); + return l?.name || `Location ${idx}`; + } + + closeAllModals() { + document.querySelectorAll('.backup-modal.open').forEach(m => m.classList.remove('open')); + } + + async confirmRestore() { + const modal = document.getElementById('backup-restore-modal'); + const { app, locIdx, snapshot } = modal.dataset; + this.closeAllModals(); + await this.runTask(`libreportal restore app start ${app} ${snapshot} ${locIdx}`, 'restore', app); + } + + async confirmDelete() { + const modal = document.getElementById('backup-delete-modal'); + const { app, locIdx, snapshot } = modal.dataset; + this.closeAllModals(); + await this.runTask(`libreportal backup app delete ${app} ${locIdx}:${snapshot}`, 'backup', app); + } + + async runBackupAllApps() { + await this.runTask(`libreportal backup all`, 'backup', null); + } + + async runTask(command, type, app) { + if (!this.taskManager) { + this.notify('Task system unavailable', 'error'); + return; + } + try { + await this.taskManager.createTask(command, type, app); + setTimeout(() => this.refreshAll().then(() => this.render()), 1500); + } catch (err) { + this.notify(`Failed to queue task: ${err.message || err}`, 'error'); + } + } + + /* ----- Generic save handler ----- */ + + async saveSection(sectionId) { + let scope; + if (sectionId.startsWith('location-')) { + const idx = sectionId.slice('location-'.length); + scope = document.querySelector(`.backup-location-row[data-loc="${idx}"] .task-details`); + } else { + scope = document.querySelector(`#backup-panel-${sectionId}`); + } + if (!scope) return; + + const cfg = window.systemConfigs || {}; + const changes = []; + scope.querySelectorAll('[data-backup-field]').forEach(el => { + const name = el.name; + if (!name || name.startsWith('__')) return; + let value; + if (el.hasAttribute('data-backup-bool')) { + value = el.checked ? 'true' : 'false'; + } else { + value = (el.value ?? '').toString(); + } + const original = (cfg[name] ?? '').toString(); + if (value === original) return; + changes.push(`${name}=${value.replace(/\|/g, '%7C')}`); + }); + + if (!changes.length) { + this.notify('No changes to save.', 'info'); + return; + } + + const encoded = changes.join('|'); + try { + if (!window.tasksManager?.router) throw new Error('Task system not available'); + await window.tasksManager.router.routeAction('config_update', { + changes: `'${encoded.replace(/'/g, "'\\''")}'` + }); + this.notify(`Saving ${changes.length} change${changes.length === 1 ? '' : 's'}…`, 'success'); + setTimeout(() => this.reloadAfterSave(), 2500); + } catch (err) { + this.notify(`Save failed: ${err.message || err}`, 'error'); + } + } + + async reloadAfterSave() { + try { + const r = await fetch(`/data/config/generated/configs.json?t=${Date.now()}`); + if (r.ok) window.systemConfigs = await r.json(); + } catch {} + await this.refreshAll(); + this.render(); + } + + notify(message, type) { + if (window.notificationSystem) { + window.notificationSystem.show(message, type || 'info'); + } else { + console.log(`[backup ${type || 'info'}] ${message}`); + } + } + + escape(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[c]); + } + + formatBytes(b) { + if (!b || b < 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let v = b; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`; + } + + formatRelative(iso) { + if (!iso) return '—'; + const t = new Date(iso).getTime(); + if (!t) return iso; + const diff = Math.max(0, Date.now() - t); + const s = Math.floor(diff / 1000); + if (s < 60) return 'just now'; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + return new Date(iso).toLocaleDateString(); + } +} + +window.BackupPage = BackupPage; diff --git a/containers/libreportal/frontend/js/components/config/config-core.js b/containers/libreportal/frontend/js/components/config/config-core.js new file mode 100755 index 0000000..ab9c43d --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-core.js @@ -0,0 +1,80 @@ +// Core Config Management - Handles loading, caching, and basic operations +class ConfigCore { + constructor() { + this.cache = new Map(); + } + + getRandomLoadingMessage() { + const messages = [ + "Preparing your configuration settings...", + "Gathering finest configuration options...", + "Loading configuration data...", + "Fetching your preferences...", + "Setting up your workspace...", + "Loading configuration components...", + "Preparing configuration interface...", + "Gathering system settings...", + "Loading configuration modules...", + "Initializing configuration system..." + ]; + return messages[Math.floor(Math.random() * messages.length)]; + } + + async loadConfig(category) { + //console.log(`ConfigCore: Loading ${category} config...`); + + if (this.cache.has('unified')) { + //console.log(`ConfigCore: Using cached unified config`); + const cachedData = this.cache.get('unified'); + window.configData = cachedData; // Make available globally + return cachedData.subcategories || {}; + } + + try { + //console.log(`ConfigCore: Fetching from /data/config/generated/configs.json`); + const response = await fetch('/data/config/generated/configs.json'); + //console.log(`ConfigCore: Response status: ${response.status}`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const configData = await response.json(); + //console.log(`ConfigCore: Loaded config data:`, configData); + + this.cache.set('unified', configData); + window.configData = configData; // Make available globally + + // Return the actual subcategories, not just the category metadata + const categoryData = configData.subcategories || {}; + //console.log(`ConfigCore: Available subcategories:`, Object.keys(categoryData)); + + return categoryData; + } catch (error) { + console.error(`ConfigCore: Error loading ${category} config:`, error); + return {}; + } + } + + filterConfigByCategory(unifiedData, category) { + if (!unifiedData || !unifiedData.config) { + return {}; + } + + const categoryData = {}; + Object.entries(unifiedData.config).forEach(([key, value]) => { + if (value.category === category) { + categoryData[key] = value; + } + }); + + return categoryData; + } + + clearCache() { + this.cache.clear(); + } +} + +// Export for use in other modules +window.ConfigCore = ConfigCore; diff --git a/containers/libreportal/frontend/js/components/config/config-form.js b/containers/libreportal/frontend/js/components/config/config-form.js new file mode 100755 index 0000000..b2d6b8d --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-form.js @@ -0,0 +1,124 @@ +class ConfigForm { + constructor() { + this.form = null; + this._submitHandlerAttached = false; + } + + resetForm() { + this.form = document.getElementById('config-form'); + if (this.form) { + this.form.reset(); + } + } + + snapshotOriginal() { + const original = {}; + const data = window.configData && window.configData.config; + if (!data) return original; + Object.entries(data).forEach(([key, entry]) => { + original[key] = entry && entry.value !== undefined ? String(entry.value) : ''; + }); + return original; + } + + collectChanges(original) { + const changes = []; + if (!this.form) return changes; + + const current = {}; + const inputs = this.form.querySelectorAll('input, select, textarea'); + inputs.forEach((input) => { + const name = input.name; + if (!name || !name.startsWith('CFG_')) return; + if (name.endsWith('_PORT_MANAGER')) return; // UI-only aggregate + let value; + if (input.type === 'checkbox') { + value = input.checked ? 'true' : 'false'; + } else { + value = (input.value || '').trim(); + } + current[name] = value; + }); + + Object.keys(current).forEach((name) => { + const oldValue = original[name] !== undefined ? original[name] : ''; + const newValue = current[name]; + if (oldValue === newValue) return; + const encoded = newValue.replace(/\|/g, '%7C'); + changes.push(`${name}=${encoded}`); + }); + + return changes; + } + + async saveConfig() { + this.form = document.getElementById('config-form'); + if (!this.form) { + console.error('ConfigForm: Form not found'); + return; + } + + const original = this.snapshotOriginal(); + const changes = this.collectChanges(original); + + if (changes.length === 0) { + this.showNotification('No configuration changes to save.', 'info'); + return; + } + + const encoded = changes.join('|'); + + try { + if (!window.tasksManager || !window.tasksManager.router) { + throw new Error('Task system not available'); + } + const task = await window.tasksManager.router.routeAction('config_update', { + changes: `'${encoded.replace(/'/g, "'\\''")}'` + }); + + this.showNotification( + `Saving ${changes.length} configuration change${changes.length === 1 ? '' : 's'}...`, + 'success' + ); + + if (task && window.librePortalSPA && typeof window.librePortalSPA.navigate === 'function') { + setTimeout(() => window.librePortalSPA.navigate(`/tasks?=all&task=${task.id}`), 400); + } else if (task && window.navigateToRoute) { + setTimeout(() => window.navigateToRoute(`tasks?=all&task=${task.id}`), 400); + } + } catch (error) { + console.error('ConfigForm: Error saving configuration:', error); + this.showNotification('Failed to save configuration: ' + error.message, 'error'); + } + } + + // preventDefault stops the form from falling back to GET (which dumps every + // CFG into the URL). + attachSubmitHandler() { + const form = document.getElementById('config-form'); + if (!form) return; + if (form.dataset.submitWired === '1') return; + form.dataset.submitWired = '1'; + form.addEventListener('submit', (event) => { + event.preventDefault(); + this.saveConfig(); + }); + } + + showNotification(message, type) { + type = type || 'info'; + if (window.notificationSystem) { + window.notificationSystem.show(message, type); + return; + } + const notification = document.createElement('div'); + notification.className = 'notification notification-' + type; + notification.textContent = message; + document.body.appendChild(notification); + setTimeout(() => { + if (notification.parentNode) notification.parentNode.removeChild(notification); + }, 5000); + } +} + +window.ConfigForm = ConfigForm; diff --git a/containers/libreportal/frontend/js/components/config/config-manager-old.js b/containers/libreportal/frontend/js/components/config/config-manager-old.js new file mode 100755 index 0000000..c328c98 --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-manager-old.js @@ -0,0 +1,1645 @@ +// Simple Config Manager - Direct approach without complex dependencies +class ConfigManager { + constructor() { + this.cache = new Map(); + } + + getRandomLoadingMessage() { + const messages = [ + "Preparing your configuration settings...", + "Gathering the finest configuration options...", + "Tuning up your system preferences...", + "Organizing your configuration categories...", + "Loading the perfect settings for you...", + "Crafting your personalized configuration...", + "Aligning your configuration stars...", + "Brewing the ideal configuration blend...", + "Setting up your configuration masterpiece...", + "Polishing your configuration preferences...", + "Configuring things just right for you...", + "Preparing your digital control panel...", + "Gathering your system's best settings...", + "Optimizing your configuration experience...", + "Loading your configuration superpowers..." + ]; + + return messages[Math.floor(Math.random() * messages.length)]; + } + + async loadConfig(category) { + //console.log(`ConfigManager: Loading ${category} config...`); + + // Check cache first + if (this.cache.has('unified')) { + //console.log(`ConfigManager: Using cached unified config`); + const unifiedData = this.cache.get('unified'); + return this.filterConfigByCategory(unifiedData, category); + } + + try { + // Load unified config data + const response = await fetch('/data/config/generated/configs.json'); + if (!response.ok) { + throw new Error(`Failed to load configs.json: ${response.status}`); + } + + const configData = await response.json(); + //console.log(`ConfigManager: Loaded unified config:`, configData); + + // Cache the result + this.cache.set('unified', configData); + + // Filter by requested category + const categoryData = this.filterConfigByCategory(configData, category); + //console.log(`ConfigManager: Filtered ${category} config:`, categoryData); + + return categoryData; + + } catch (error) { + console.error(`ConfigManager: Error loading ${category} config:`, error); + return { config: {}, description: 'Failed to load configuration' }; + } + } + + filterConfigByCategory(unifiedData, category) { + if (!unifiedData || !unifiedData.config) { + return { config: {}, categories: {} }; + } + + // Filter config items by category + const filteredConfig = {}; + Object.entries(unifiedData.config).forEach(([key, value]) => { + if (value.category === category) { + filteredConfig[key] = value; + } + }); + + return { + config: filteredConfig, + categories: unifiedData.categories || {}, + subcategories: unifiedData.subcategories || {}, + configType: unifiedData.configType, + name: unifiedData.name + }; + } + + async renderConfig(category) { + //console.log(`ConfigManager: Rendering ${category} config...`); + + const configSection = document.getElementById('config-section'); + if (!configSection) { + console.error('ConfigManager: config-section element not found'); + return; + } + + // Show loading with enhanced visual + configSection.innerHTML = ` +
    +
    +
    +
    + Loading configuration... +
    +
    + ${this.getRandomLoadingMessage()} +
    +
    + +
    + `; + + // Update loading bar if available + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(60); + } + + try { + // Load config data + const configData = await this.loadConfig(category); + const config = configData.config || {}; + + // Update loading bar + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(70); + } + + if (Object.keys(config).length === 0) { + configSection.innerHTML = '
    No configuration available
    '; + return; + } + + // Use the original ConfigShared system for beautiful rendering + if (typeof ConfigShared !== 'undefined') { + await this.renderWithOriginalStyling(category, configData); + } else { + // Fallback: load ConfigShared and then render + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(75); + } + await this.loadScript('/js/components/config/config-options.js'); + await this.loadScript('/js/components/config/config-shared.js'); + await this.renderWithOriginalStyling(category, configData); + } + + // Final progress update + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(80); + } + + //console.log(`ConfigManager: Successfully rendered ${category} config`); + + // Initialize git field visibility after rendering + if (typeof initializeGitFieldVisibility === 'function') { + setTimeout(() => { + initializeGitFieldVisibility(); + }, 100); + } + + } catch (error) { + console.error(`ConfigManager: Error rendering ${category} config:`, error); + configSection.innerHTML = `
    Failed to load ${category} configuration: ${error.message}
    `; + } + } + + async renderWithOriginalStyling(category, configData) { + //console.log(`renderWithOriginalStyling: category=${category}, CFG_INSTALL_MODE=${configData.config?.CFG_INSTALL_MODE?.value}`); + const configSection = document.getElementById('config-section'); + const config = configData.config || {}; + const subcategories = configData.subcategories || {}; + + // Check if we have subcategories data available + const hasSubcategories = Object.keys(subcategories).length > 0; + + // Use shared categorization functionality + const categorized = ConfigShared.categorizeConfigs(config); + const { groupedConfigs, regularCategories, advancedCategories, unusedCategories } = categorized; + + // Render using the original system's approach with advanced/unused sections + let formHTML = ` +
    +

    ${this.formatCategoryName(category)} Configuration

    +

    ${configData.description || 'Configure settings for ' + category}

    +
    +
    +
    + `; + + // Add requirements warning for requirements category + if (category === 'requirements') { + formHTML += ConfigShared.generateRequirementsWarning(); + } + + // Add danger zone warning for features category + if (category === 'features') { + formHTML += ` +
    +
    + ⚠️ +
    + Danger Zone - These options are for advanced users and may affect system stability +
    +
    +
    + `; + } + + // Render using subcategories structure if available, otherwise fall back to original + if (hasSubcategories) { + // Render using new subcategories structure + const regularSubcategories = []; + const advancedSubcategories = []; + const unusedSubcategories = []; + + // Filter subcategories by category and separate into regular, advanced, and unused + for (const [subcategoryName, subcategoryData] of Object.entries(subcategories)) { + if (subcategoryData.category === category) { + if (subcategoryData.description.includes('**ADVANCED**')) { + advancedSubcategories.push(subcategoryName); + } else if (subcategoryData.description.includes('**UNUSED**')) { + unusedSubcategories.push(subcategoryName); + } else { + regularSubcategories.push(subcategoryName); + } + } + } + + // Render regular subcategories with proper sectioning + for (const subcategoryName of regularSubcategories) { + const subcategoryData = subcategories[subcategoryName]; + const rawSubcategoryTitle = subcategoryData.title || ConfigShared.formatCategoryName(subcategoryName); + const displaySubcategory = ConfigShared.stripCategoryPrefix(rawSubcategoryTitle, category); + const subcategoryDescription = subcategoryData.description || 'Subcategory configuration'; + + // Find config items for this subcategory + const configItems = Object.entries(config) + .filter(([key, value]) => value.subcategory === subcategoryName) + .map(([key, value]) => ({ key, ...value })); + + if (configItems.length > 0) { + // Check for master toggle in this subcategory + const masterKey = configItems.find(item => item.master === true); + + // Find any ENABLED options and use universal toggle renderer + const enabledKey = configItems.find(item => item.key.includes('ENABLED') || item.key === 'CFG_INSTALL_MODE'); + //console.log('ConfigManager: Checking for toggle - subcategoryName:', subcategoryName, 'enabledKey found:', !!enabledKey, enabledKey ? enabledKey.key : null); + + // Special handling for domains section + const isDomains = subcategoryName.includes('domains'); + + if (enabledKey) { + // Use universal toggle renderer for any ENABLED option or CFG_INSTALL_MODE + formHTML += ToggleManager.renderToggleSection(enabledKey, configItems, displaySubcategory, subcategoryDescription, config); + } else if (masterKey) { + // Render with master toggle + formHTML += this.renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription); + } else if (isDomains) { + // Render domains section with special handling + formHTML += await this.renderDomainsSection(configItems, displaySubcategory, subcategoryDescription); + } else { + // Render regular subcategory with proper sectioning + formHTML += this.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config); + } + } + } + + // Render advanced subcategories (hidden by default) + if (advancedSubcategories.length > 0) { + // Add danger zone toggle for advanced sections + formHTML += ConfigShared.generateToggleControls(true, false); + + // Generate advanced sections using shared functionality + const advancedGroupedConfigs = {}; + advancedSubcategories.forEach(subcategoryName => { + const configItems = Object.entries(config) + .filter(([key, value]) => value.subcategory === subcategoryName) + .map(([key, value]) => ({ key, ...value })); + + if (configItems.length > 0) { + advancedGroupedConfigs[subcategoryName] = configItems.map(item => item.key); + } + }); + + formHTML += await ConfigShared.generateAdvancedSections( + advancedSubcategories, + advancedGroupedConfigs, + config, + (category) => this.cleanDescription(subcategories[category]?.description || 'Advanced settings') + ); + } + + // Render unused subcategories (hidden by default) + if (unusedSubcategories.length > 0) { + // Add unused section toggle + formHTML += ConfigShared.generateToggleControls(false, true); + + // Wrap unused sections in hidden container + formHTML += ` + + `; + } + } else { + // Fall back to original categorization system + const categorized = ConfigShared.categorizeConfigs(config); + const { groupedConfigs, regularCategories, advancedCategories, unusedCategories } = categorized; + + // Render regular categories (always visible) + for (const cat of regularCategories) { + const keys = groupedConfigs[cat]; + if (keys && keys.length > 0 && cat !== 'Hidden/Unused Options') { + const displayCategory = ConfigShared.formatCategoryName(cat); + const categoryDescription = await ConfigShared.getCategoryDescription(cat); + + // Check if this category has a master toggle (any key with master: true) + const masterKey = keys.find(key => { + const configItem = config[key] || {}; + return configItem.master === true; + }); + + if (masterKey) { + // Dynamic master toggle handling + const masterValue = config[masterKey]?.value || 'false'; + const isMasterEnabled = masterValue === 'true'; + const masterTitle = config[masterKey]?.title || ConfigShared.formatConfigLabel(masterKey); + const masterDescription = config[masterKey]?.description || ''; + const sectionId = `${cat.toLowerCase().replace(/[^a-z0-9]/g, '-')}`; + const toggleId = `${masterKey.toLowerCase()}-toggle`; + + formHTML += ` +
    +
    +
    +
    +

    ${displayCategory}

    +

    ${categoryDescription}

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + `; + + // Add all other fields (excluding the master toggle) + keys.filter(key => key !== masterKey).forEach(key => { + const configItem = config[key] || {}; + const value = configItem.value || ''; + const title = configItem.title || ConfigShared.formatConfigLabel(key); + const description = configItem.description || ''; + const options = configItem.options || ''; + const fieldId = `config-${key}`; + formHTML += ConfigShared.generateField(fieldId, key, value, title, description, options, config); + }); + + formHTML += ` +
    +
    +
    +
    +
    + `; + } else if (cat === 'DOMAINS') { + // Check if Traefik is installed + const traefikInstalled = await this.checkTraefikInstallation(); + + // Always show the domains section, but add a warning banner if Traefik is not installed + formHTML += ` +
    +
    +
    +

    ${displayCategory}

    +

    ${categoryDescription}

    +
    +
    + `; + + if (!traefikInstalled) { + // Show smaller warning banner + formHTML += ` +
    +
    + ⚠️ +
    + Traefik Not Installed - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later. +
    +
    +
    + `; + } + + formHTML += `
    `; + + // Only show domains that have content (non-empty values) + const allDomainKeys = keys.filter(key => key.startsWith('CFG_DOMAIN_')); + const domainKeysWithContent = allDomainKeys.filter(key => { + const configItem = config[key] || {}; + const value = configItem.value || ''; + return value.trim() !== ''; + }); + + // Check if we've reached the maximum of 9 domains (count only existing domains, not empty slots) + const isMaxDomains = domainKeysWithContent.length >= 9; + + domainKeysWithContent.forEach(key => { + const configItem = config[key] || {}; + const value = configItem.value || ''; + const title = configItem.title || ConfigShared.formatConfigLabel(key); + const fieldId = `config-${key}`; + + // Extract domain number + const domainNum = parseInt(key.match(/CFG_DOMAIN_(\d+)/)[1]); + const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k => + parseInt(k.match(/CFG_DOMAIN_(\d+)/)[1]) + )); + + // Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted + const canDelete = isHighestDomain && domainNum !== 1; + + formHTML += ` +
    +
    + ${ConfigShared.generateField(fieldId, key, value, title, '', { + placeholder: 'example.com', + className: 'domain-input' + })} + +
    +
    + `; + }); + + formHTML += ` +
    +
    + +
    +
    +
    + `; + } else { + // Regular category handling (no master toggle) + formHTML += ` +
    +

    ${displayCategory}

    +

    ${categoryDescription}

    +
    + ${ConfigShared.generateFieldsForCategory(keys, cat, config, (fieldId, key, value, title, description, options, config) => ConfigShared.generateField(fieldId, key, value, title, description, options, config))} +
    +
    + `; + } + } + } + } + + // Add danger zone before advanced/unused sections (so content appears below) + formHTML += ConfigShared.generateToggleControls( + advancedCategories.length > 0, + unusedCategories.length > 0 + ); + + // Add advanced and unused sections using shared functionality + formHTML += await ConfigShared.generateAdvancedSections( + advancedCategories, + groupedConfigs, + config, + (category) => ConfigShared.getCategoryDescription(category) + ); + + formHTML += await ConfigShared.generateUnusedSections( + unusedCategories, + groupedConfigs, + config, + (category) => ConfigShared.getCategoryDescription(category) + ); + + formHTML += ` +
    +
    + + +
    +
    + `; + + configSection.innerHTML = formHTML; + + // Initialize all master toggles dynamically + setTimeout(() => { + // Find all master toggle checkboxes (any input with id ending in "-toggle" where the name ends with "_ENABLED") + const masterToggles = document.querySelectorAll('input[id$="-toggle"][name$="_ENABLED"]'); + + masterToggles.forEach(toggle => { + const sectionId = toggle.id.replace('-toggle', ''); + const section = document.getElementById(`section-content-${sectionId}`); + + if (section && typeof ConfigShared.toggleSectionVisibility === 'function') { + // Initialize the section state based on the toggle + ConfigShared.toggleSectionVisibility(`section-content-${sectionId}`, toggle.checked); + } + }); + }, 100); + } + + // Check if Traefik is installed + async checkTraefikInstallation() { + try { + // Use the generic app installation checker + return await DataLoader.isAppInstalled('traefik'); + } catch (error) { + //console.log('Traefik check failed:', error.message); + return false; + } + } + + // Domain management functions + addNewDomain() { + //console.log('Add Domain button clicked!'); + + try { + // Find the highest existing domain number + const domainInputs = document.querySelectorAll('input[name^="CFG_DOMAIN_"]'); + const domainNumbers = []; + + domainInputs.forEach(input => { + const match = input.name.match(/CFG_DOMAIN_(\d+)/); + if (match) { + domainNumbers.push(parseInt(match[1])); + } + }); + + // Check if we've reached the maximum of 9 domains + if (domainNumbers.length >= 9) { + //console.log('Maximum of 9 domains reached'); + return; + } + + const nextDomainNumber = domainNumbers.length > 0 ? Math.max(...domainNumbers) + 1 : 1; + const newDomainKey = `CFG_DOMAIN_${nextDomainNumber}`; + const newFieldId = `config-${newDomainKey}`; + + // Create new domain building block HTML + const newDomainHTML = ` +
    +
    + ${ConfigShared.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', { placeholder: 'example.com' })} + +
    +
    + `; + + // Find the domain-building-blocks container and add the new block + const domainContainer = document.querySelector('.domain-building-blocks'); + if (domainContainer) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newDomainHTML; + const newBlock = tempDiv.firstElementChild; + domainContainer.appendChild(newBlock); + + // Focus on the new input field + const newInput = newBlock.querySelector('input'); + if (newInput) { + newInput.focus(); + } + + // Update add domain button state + const addBtn = document.getElementById('add-domain-btn'); + if (addBtn) { + const totalDomains = document.querySelectorAll('.domain-building-block').length; + if (totalDomains >= 9) { + addBtn.disabled = true; + addBtn.className = 'btn btn-secondary'; + addBtn.innerHTML = 'Maximum Domains Reached'; + } + } + + // Update delete button states (only highest numbered domain should be deletable) + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + allDomainBlocks.forEach((block, index) => { + const deleteBtn = block.querySelector('.delete-domain-btn'); + if (deleteBtn) { + const input = block.querySelector('input'); + const inputName = input ? input.name : ''; + const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/); + const domainNumber = domainNum ? parseInt(domainNum[1]) : 0; + + // Find the highest domain number among all visible blocks + const allDomainNumbers = Array.from(allDomainBlocks).map(b => { + const inp = b.querySelector('input'); + const name = inp ? inp.name : ''; + const match = name.match(/CFG_DOMAIN_(\d+)/); + return match ? parseInt(match[1]) : 0; + }); + const highestDomainNumber = Math.max(...allDomainNumbers); + + // Only highest numbered domain can be deleted, but NEVER Domain 1 + const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1; + + deleteBtn.disabled = !canDelete; + deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`; + deleteBtn.title = canDelete ? 'Delete domain' : 'Can only delete highest numbered domain'; + } + }); + } + } catch (error) { + console.error('Error adding new domain:', error); + } + } + + deleteDomain(domainKey, buttonElement) { + //console.log(`Delete domain button clicked for: ${domainKey}`); + + try { + // Find the domain-building-block and remove it + const domainBlock = buttonElement.closest('.domain-building-block'); + if (domainBlock) { + // Clear the input value first + const input = domainBlock.querySelector('input'); + if (input) { + input.value = ''; + } + // Remove the entire building block + domainBlock.remove(); + + // Update add domain button state (re-enable if we're below 9 domains) + const addBtn = document.getElementById('add-domain-btn'); + if (addBtn) { + const totalDomains = document.querySelectorAll('.domain-building-block').length; + if (totalDomains < 9) { + addBtn.disabled = false; + addBtn.className = 'btn btn-primary'; + addBtn.innerHTML = '+Add Domain'; + } + } + + // Update delete button states (only highest numbered domain should be deletable) + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + allDomainBlocks.forEach((block, index) => { + const deleteBtn = block.querySelector('.delete-domain-btn'); + if (deleteBtn) { + const input = block.querySelector('input'); + const inputName = input ? input.name : ''; + const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/); + const domainNumber = domainNum ? parseInt(domainNum[1]) : 0; + + // Find the highest domain number among all visible blocks + const allDomainNumbers = Array.from(allDomainBlocks).map(b => { + const inp = b.querySelector('input'); + const name = inp ? inp.name : ''; + const match = name.match(/CFG_DOMAIN_(\d+)/); + return match ? parseInt(match[1]) : 0; + }); + const highestDomainNumber = Math.max(...allDomainNumbers); + + // Only highest numbered domain can be deleted, but NEVER Domain 1 + const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1; + + deleteBtn.disabled = !canDelete; + deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`; + deleteBtn.title = canDelete ? 'Delete domain' : 'Can only delete highest numbered domain'; + } + }); + } + } catch (error) { + console.error('Error deleting domain:', error); + } + } + + async loadScript(src) { + const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_'); + const existingScript = document.getElementById(scriptId); + + if (existingScript && src.includes('config-shared.js')) { + existingScript.remove(); + } else if (existingScript) { + return; + } + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.id = scriptId; + script.onload = resolve; + script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); + document.head.appendChild(script); + }); + } + + formatCategoryName(category) { + return category + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + } + + static async saveConfig(category) { + const form = document.getElementById(`config-form-${category}`); + if (!form) return; + + const formData = new FormData(form); + const config = {}; + + for (const [key, value] of formData.entries()) { + const checkbox = form.querySelector(`input[name="${key}"][type="checkbox"]`); + if (checkbox) { + config[key] = checkbox.checked; + } else { + config[key] = value; + } + } + } + + static async resetConfig(category) { + if (confirm('Are you sure you want to reset all settings to their default values?')) { + window.location.reload(); + } + } + + // Helper method to render subcategory with master toggle + renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription, isAdvanced = false) { + const masterValue = masterKey.value || 'false'; + const isMasterEnabled = masterValue === 'true'; + const masterTitle = masterKey.title || ConfigShared.formatConfigLabel(masterKey.key); + const masterDescription = masterKey.description || ''; + const sectionId = `${displaySubcategory.toLowerCase().replace(/[^a-z0-9]/g, '-')}`; + const toggleId = `${masterKey.key.toLowerCase()}-toggle`; + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + `; + + // Add all other fields (excluding the master toggle) + configItems.filter(item => item.key !== masterKey.key).forEach(item => { + const fieldId = `config-${item.key}`; + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); + }); + + html += ` +
    +
    +
    +
    +
    + `; + + return html; + } + + // Helper method to render domains section with special handling + async renderDomainsSection(configItems, displaySubcategory, subcategoryDescription) { + // Check if Traefik is installed + const traefikInstalled = await this.checkTraefikInstallation(); + + let html = ` +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    + `; + + if (!traefikInstalled) { + // Show smaller warning banner + html += ` +
    +
    + ⚠️ +
    + Traefik Not Installed - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later. +
    +
    +
    + `; + } + + html += `
    `; + + // Only show domains that have content (non-empty values) + const allDomainKeys = configItems.filter(item => item.key.startsWith('CFG_DOMAIN_')); + const domainKeysWithContent = allDomainKeys.filter(item => { + const value = item.value || ''; + return value.trim() !== ''; + }); + + // Only render domains that have content + domainKeysWithContent.forEach(item => { + const value = item.value || ''; + const title = item.title || ConfigShared.formatConfigLabel(item.key); + const fieldId = `config-${item.key}`; + + // Extract domain number + const domainNum = parseInt(item.key.match(/CFG_DOMAIN_(\d+)/)[1]); + const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k => + parseInt(k.key.match(/CFG_DOMAIN_(\d+)/)[1]) + )); + + // Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted + const canDelete = isHighestDomain && domainNum !== 1; + + html += ` +
    +
    + ${ConfigShared.generateField(fieldId, item.key, value, title, '', { + placeholder: 'example.com', + className: 'domain-input', + onchange: 'window.configManager.validateDomainFormat(this, true)', + oninput: 'window.configManager.validateDomainFormat(this, true)', + onblur: 'window.configManager.validateDomainFormat(this, true)' + })} + +
    +
    + `; + }); + + // Add "Add Domain" button outside the grid + const isMaxDomains = domainKeysWithContent.length >= 9; + html += ` +
    +
    + +
    +
    +
    +
    + `; + + return html; + } + + // Helper method to render remote backup section with toggle + renderRemoteBackupSection(backupKey, configItems, displaySubcategory, subcategoryDescription, config = {}) { + const isEnabled = backupKey.value === 'true'; + const sectionId = `backup-${backupKey.key}`; + const toggleId = `${backupKey.key.toLowerCase()}-toggle`; + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + `; + + // Add all other fields (excluding the ENABLED toggle) + configItems.filter(item => item.key !== backupKey.key).forEach(item => { + const fieldId = `config-${item.key}`; + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); + }); + + html += ` +
    +
    +
    +
    +
    + `; + + return html; + } + + // Helper method to render mail section with toggle + renderMailSection(mailKey, configItems, displaySubcategory, subcategoryDescription, config = {}) { + const isEnabled = mailKey.value === 'true'; + const sectionId = `mail-${mailKey.key}`; + const toggleId = `${mailKey.key.toLowerCase()}-toggle`; + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + `; + + // Add all other fields (excluding the ENABLED toggle) + configItems.filter(item => item.key !== mailKey.key).forEach(item => { + const fieldId = `config-${item.key}`; + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); + }); + + // Add test connection button after all mail fields + html += ` +
    + + +
    +
    +
    +
    +
    +
    + `; + + return html; + } + + // Test mail server connection + async testMailConnection(mailKey) { + const resultDiv = document.getElementById('mail-test-result'); + const button = event.target.closest('button'); + + // Show loading state + button.disabled = true; + button.innerHTML = 'Testing...'; + resultDiv.style.display = 'block'; + resultDiv.className = 'test-result testing'; + resultDiv.innerHTML = 'Testing mail server connection...'; + + // Initialize mailConfig outside try block for catch block access + let mailConfig = {}; + + try { + // Get current mail configuration values from the form + mailConfig = { + host: document.querySelector('input[name="CFG_MAIL_HOST"]')?.value || '', + port: document.querySelector('input[name="CFG_MAIL_PORT"]')?.value || '', + secure: document.querySelector('select[name="CFG_MAIL_SECURE"]')?.value || '', + username: document.querySelector('input[name="CFG_MAIL_USERNAME"]')?.value || '', + password: document.querySelector('input[name="CFG_MAIL_PASSWORD"]')?.value || '', + from: document.querySelector('input[name="CFG_MAIL_FROM"]')?.value || '' + }; + + // Validate required fields + if (!mailConfig.host || !mailConfig.port || !mailConfig.username || !mailConfig.password) { + throw new Error('Please fill in all required mail server fields (host, port, username, password)'); + } + + // Call backend test script + const response = await fetch('/api/test-mail-connection', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(mailConfig) + }); + + const result = await response.json(); + + if (result.success) { + resultDiv.className = 'test-result success'; + resultDiv.innerHTML = `✅ ${result.message || 'Mail server connection successful!'}${result.details ? `
    ${result.details}` : ''}`; + } else { + resultDiv.className = 'test-result error'; + let errorHtml = `❌ ${result.message || 'Mail server connection failed'}`; + + // Add detailed error information directly underneath + if (result.details || result.error || result.config) { + errorHtml += ` +
    + Error Details:
    + ${result.details || result.error || 'No additional details available'} + ${result.stack ? `

    Stack Trace:
    ${result.stack}` : ''} + ${result.config ? `

    Connection Config:
    ${JSON.stringify(result.config, null, 2)}` : ''} +
    + `; + } + + resultDiv.innerHTML = errorHtml; + } + + } catch (error) { + resultDiv.className = 'test-result error'; + resultDiv.innerHTML = ` + ❌ ${error.message || 'Failed to test mail connection'} +
    + Error Details:
    + ${error.message || 'Unknown error'} + ${error.stack ? `

    Stack Trace:
    ${error.stack}` : ''} + ${error.response ? `

    Response:
    ${JSON.stringify(error.response, null, 2)}` : ''} + ${mailConfig ? `

    Mail Config:
    ${JSON.stringify({...mailConfig, password: mailConfig.password ? '[REDACTED]' : undefined}, null, 2)}` : ''} +
    + `; + } finally { + // Restore button state + button.disabled = false; + button.innerHTML = '📧Test Mail Connection'; + } + } + + // Helper method to render Git section with toggle + renderGitSection(gitKey, configItems, displaySubcategory, subcategoryDescription, config = {}) { + // CFG_INSTALL_MODE controls git section: 'git' = enabled, 'local' = disabled + const isEnabled = gitKey.value === 'git'; + const sectionId = `git-${gitKey.key}`; + const toggleId = `${gitKey.key.toLowerCase()}-toggle`; + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + `; + + // Add all other git fields (excluding the CFG_INSTALL_MODE itself) + configItems.filter(item => item.key !== gitKey.key && item.key.startsWith('CFG_GIT_')).forEach(item => { + const fieldId = `config-${item.key}`; + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); + }); + + html += ` +
    +
    +
    +
    +
    + `; + + return html; + } + + // Helper method to clean description text by removing tags + cleanDescription(description) { + return description + .replace(/\*\*ADVANCED\*\*/g, '') + .replace(/\*\*UNUSED\*\*/g, '') + .replace(/^\s+|\s+$/g, '') // Trim whitespace + .replace(/\s{2,}/g, ' '); // Replace multiple spaces with single space + } + + // Update all domain delete button states + updateDomainDeleteButtons() { + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + + // Find the highest domain number (regardless of content) + const domainNumbers = Array.from(allDomainBlocks).map(block => { + const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]'); + if (input) { + const match = input.id.match(/CFG_DOMAIN_(\d+)/); + return match ? parseInt(match[1]) : 0; + } + return 0; + }).filter(num => num > 0); + + const highestDomain = Math.max(...domainNumbers, 0); + + // Update delete buttons + allDomainBlocks.forEach(block => { + const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]'); + const deleteBtn = block.querySelector('.delete-domain-btn'); + + if (input && deleteBtn) { + const match = input.id.match(/CFG_DOMAIN_(\d+)/); + const domainNum = match ? parseInt(match[1]) : 0; + + // SIMPLE RULE: Only highest numbered domain can be deleted (except Domain 1) + const canDelete = domainNum === highestDomain && domainNum !== 1; + + if (canDelete) { + deleteBtn.classList.remove('disabled'); + deleteBtn.disabled = false; + deleteBtn.title = 'Delete domain'; + } else { + deleteBtn.classList.add('disabled'); + deleteBtn.disabled = true; + if (domainNum === 1) { + deleteBtn.title = 'Domain 1 cannot be deleted'; + } else { + deleteBtn.title = 'Can only delete highest numbered domain'; + } + } + } + }); + } + + // Validate domain format when user tries to add a new domain + validateDomainFormat(input, showNotifications = true) { + const value = input.value.trim(); + if (!value) { + return true; // Allow empty for initial state + } + + // Basic domain validation regex - supports subdomains and multiple TLD levels + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + const isValidFormat = domainRegex.test(value); + + // Check for duplicates + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + const duplicates = Array.from(allDomainInputs).filter(otherInput => { + if (otherInput === input) return false; // Skip self + return otherInput.value.trim().toLowerCase() === value.toLowerCase(); + }); + const hasDuplicate = duplicates.length > 0; + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid domain format (e.g., example.com)'; + if (showNotifications && window.notificationSystem) { + window.notificationSystem.error(`Invalid domain format: "${value}". Please use a valid domain like example.com`); + } + return false; + } else if (hasDuplicate) { + input.style.borderColor = '#ffc107'; + input.title = 'Domain already exists!'; + if (showNotifications && window.notificationSystem) { + window.notificationSystem.warning(`Domain "${value}" already exists. Please use a unique domain.`); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } + } + + // Validate email format for mail fields + validateEmailFormat(input, showNotifications = true) { + const value = input.value.trim(); + if (!value) { + return true; // Allow empty for initial state + } + + // Email validation regex + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + const isValidFormat = emailRegex.test(value); + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid email format (e.g., user@example.com)'; + if (showNotifications && window.notificationSystem) { + window.notificationSystem.error(`Invalid email format: "${value}". Please use a valid email like user@example.com`); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } + } + + // Validate hostname format for mail server + validateHostnameFormat(input, showNotifications = true) { + const value = input.value.trim(); + if (!value) { + return true; // Allow empty for initial state + } + + // Hostname validation regex - allows subdomains and multiple TLD levels + const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + const isValidFormat = hostnameRegex.test(value); + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid hostname format (e.g., mail.domain.com)'; + if (showNotifications && window.notificationSystem) { + window.notificationSystem.error(`Invalid hostname format: "${value}". Please use a valid hostname like mail.domain.com`); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } + } + + // Validate port number for mail server + validatePortNumber(input, showNotifications = true) { + const value = input.value.trim(); + if (!value) { + return true; // Allow empty for initial state + } + + const port = parseInt(value, 10); + const isValidPort = !isNaN(port) && port >= 1 && port <= 65535; + + if (!isValidPort) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid port number (1-65535)'; + if (showNotifications && window.notificationSystem) { + window.notificationSystem.error(`Invalid port number: "${value}". Please use a valid port between 1 and 65535`); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } + } + + // Check if all domains are valid before allowing new domain addition + canAddNewDomain() { + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + for (const input of allDomainInputs) { + if (!this.validateDomainFormat(input, true)) { // Don't show notifications during bulk check + return false; // At least one domain has invalid format or duplicate + } + } + return true; // All domains are valid + } + + // Add a new domain field + addDomain(button) { + // Find all existing domain blocks + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + const domainData = Array.from(allDomainBlocks).map(block => { + const input = block.querySelector('input[id^="config-CFG_DOMAIN_"]'); + if (input) { + const match = input.id.match(/CFG_DOMAIN_(\d+)/); + return { + num: match ? parseInt(match[1]) : 0, + value: input.value.trim(), + input: input, + block: block + }; + } + return null; + }).filter(item => item !== null); + + // Sort by domain number + domainData.sort((a, b) => a.num - b.num); + + // Find the highest domain number with content + const domainsWithContent = domainData.filter(d => d.value); + const highestDomainWithContent = domainsWithContent.length > 0 ? + Math.max(...domainsWithContent.map(d => d.num)) : 0; + + // Find the highest domain number overall (including empty) + const highestDomainOverall = Math.max(...domainData.map(d => d.num), 0); + + // Check if the highest domain (overall) is empty + const highestDomainData = domainData.find(d => d.num === highestDomainOverall); + if (highestDomainData && !highestDomainData.value) { + // Flash the empty highest domain without validation checks + const emptyInput = document.querySelector(`input[id="config-CFG_DOMAIN_${highestDomainOverall}"]`); + if (emptyInput) { + emptyInput.style.animation = 'flash 0.5s ease-in-out 2'; + emptyInput.focus(); + setTimeout(() => { + emptyInput.style.animation = ''; + }, 1000); + return; + } + + // Remove animation after it completes + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + return; + } + + // Find the next available domain slot (only if highest with content is filled) + const usedNumbers = domainData.map(d => d.num); + let nextDomain = 1; + while (usedNumbers.includes(nextDomain) && nextDomain <= 9) { + nextDomain++; + } + + // Only add if we have domains with content and the highest with content is filled + if (highestDomainWithContent === 0) { + // No domains with content yet, this shouldn't happen but handle it + } else if (nextDomain > 9) { + if (window.notificationSystem) { + window.notificationSystem.warning('Maximum of 9 domains reached!'); + } + return; + } + + // Before adding new domain, validate that all existing domains have valid format + if (!this.canAddNewDomain()) { + // Find the first invalid domain and focus it with flash + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + for (const input of allDomainInputs) { + if (!this.validateDomainFormat(input, false)) { // Don't show notification here + input.style.animation = 'flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + return; // Just flash and focus, no extra notification + } + } + } + + // Create new domain field with proper structure + const domainKey = `CFG_DOMAIN_${nextDomain}`; + const fieldId = `config-${domainKey}`; + const title = `Domain ${nextDomain}`; + + const newDomainHTML = ` +
    +
    + ${ConfigShared.generateField(fieldId, domainKey, '', title, '', { + placeholder: 'example.com', + className: 'domain-input', + onchange: 'window.configManager.validateDomainFormat(this, true)', + oninput: 'window.configManager.validateDomainFormat(this, true)', + onblur: 'window.configManager.validateDomainFormat(this, true)' + })} + +
    +
    + `; + + // Insert inside the domain-building-blocks container before the domain-actions + const domainBlocks = button.closest('.domains-wrapper').querySelector('.domain-building-blocks'); + domainBlocks.insertAdjacentHTML('beforeend', newDomainHTML); + + // Update all delete button states after DOM is ready + setTimeout(() => this.updateDomainDeleteButtons(), 10); + + // Update button state if we're now at max domains + const totalDomains = document.querySelectorAll('[id^="config-CFG_DOMAIN_"]').length; + if (totalDomains >= 9) { + button.classList.add('disabled'); + button.disabled = true; + const iconSpan = button.querySelector('.add-icon'); + const textSpan = button.querySelector('.add-text'); + iconSpan.textContent = '✓'; + textSpan.textContent = 'Maximum Domains Reached'; + } + } + + // Delete a domain field + deleteDomain(domainKey, button) { + const domainBlock = button.closest('.domain-building-block'); + + // Clear the domain value + const input = document.getElementById(`config-${domainKey}`); + if (input) { + input.value = ''; + } + + // Remove the domain block if it's empty + if (!input || input.value === '') { + domainBlock.remove(); + } + + // Update all delete button states after DOM is ready + setTimeout(() => this.updateDomainDeleteButtons(), 10); + + // Update add button state + const addButton = document.querySelector('.add-domain-btn'); + if (addButton) { + const totalDomains = document.querySelectorAll('[id^="config-CFG_DOMAIN_"]').length; + const domainsWithContent = Array.from(document.querySelectorAll('[id^="config-CFG_DOMAIN_"]')) + .filter(input => input.value.trim() !== '').length; + + if (totalDomains < 9) { + addButton.classList.remove('disabled'); + addButton.disabled = false; + const iconSpan = addButton.querySelector('.add-icon'); + const textSpan = addButton.querySelector('.add-text'); + iconSpan.textContent = '+'; + textSpan.textContent = 'Add Domain'; + } else { + addButton.classList.add('disabled'); + addButton.disabled = true; + const iconSpan = addButton.querySelector('.add-icon'); + const textSpan = addButton.querySelector('.add-text'); + iconSpan.textContent = '✓'; + textSpan.textContent = 'Maximum Domains Reached'; + } + } + } + + // Helper method to render subcategory with proper sectioning, dividers and headers + renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config = {}) { + //console.log(`renderSubcategorySection: subcategory=${subcategoryDescription}, configKeys=${Object.keys(config)}, CFG_INSTALL_MODE=${config.CFG_INSTALL_MODE?.value}`); + const cleanDescription = this.cleanDescription(subcategoryDescription); + let html = ` +
    +

    ${displaySubcategory}

    +

    ${cleanDescription}

    +
    +
    +
    + `; + + // Add all config items using standard layout + configItems.forEach((item, index) => { + const fieldId = `config-${item.key}`; + const cleanItemDescription = this.cleanDescription(item.description || ''); + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, cleanItemDescription, item.options); + }); + + html += ` +
    +
    +
    +
    + `; + + return html; + } + + // Helper method to render regular subcategory + renderRegularSubcategory(configItems, displaySubcategory, subcategoryDescription, config = {}) { + let html = ` +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    + `; + + configItems.forEach(item => { + const fieldId = `config-${item.key}`; + html += ConfigShared.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config); + }); + + html += ` +
    +
    +
    + `; + + return html; + } +} + +// Global instance +window.configManager = new ConfigManager(); diff --git a/containers/libreportal/frontend/js/components/config/config-manager.js b/containers/libreportal/frontend/js/components/config/config-manager.js new file mode 100755 index 0000000..4f1ce6d --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-manager.js @@ -0,0 +1,307 @@ +// Config Manager - Main orchestrator for modular config system +if (typeof window.ConfigManager === 'undefined') { + //console.log('ConfigManager: Defining new ConfigManager class...'); + + class ConfigManager { + constructor() { + this.core = new ConfigCore(); + this.domainManager = new DomainManager(); + this.whitelistManager = new IPWhitelistManager(); + this.renderer = new ConfigRenderer(); + this.sidebar = new ConfigSidebar(); + this.form = new ConfigForm(); + this.utils = new ConfigUtils(); + + // Expose IPWhitelistManager globally for wrapper functions + window.IPWhitelistManager = this.whitelistManager; + } + + async renderConfig(category) { + //console.log('ConfigManager: Rendering ' + category + ' config...'); + + const configSection = document.getElementById('config-section'); + if (!configSection) { + console.error('ConfigManager: config-section element not found'); + return; + } + + try { + // Show loading state with enhanced box styling + configSection.innerHTML = ` +
    +
    +
    + Loading configuration... +
    +
    + ${this.core.getRandomLoadingMessage()} +
    +
    + + `; + + // Load configuration data + const configData = await this.core.loadConfig(category); + + // Populate sidebar with categories + this.sidebar.populateSidebar(); + + if (Object.keys(configData).length === 0) { + configSection.innerHTML = '

    No Configuration Available

    No configuration items found for this category.

    '; + return; + } + + // Render configuration sections + var formHTML = ''; + var self = this; // Preserve 'this' context + + // Features page is system-level — add a Danger Zone header at the + // top so it's visually obvious before the user touches anything. + // Reuses the same `.danger-zone-section` / `.danger-zone-header` + // styling used elsewhere, but without the advanced/unused toggle + // tickboxes that live inside the normal danger zone — this is just + // the heading. + if (category === 'features') { + formHTML += '

    ⚠️ Danger Zone

    These options are for advanced users and may affect system stability

    '; + } + + //console.log('ConfigManager: About to process configData entries:', Object.keys(configData)); + + // Filter subcategories by type + const subcategoryTypes = this.utils.filterSubcategoriesByType(configData, category); + + // Render regular subcategories + for (const subcategoryName of subcategoryTypes.regular) { + const subcategoryData = configData[subcategoryName]; + //console.log('ConfigManager: Processing regular subcategory:', subcategoryName, 'data:', subcategoryData); + + if (typeof subcategoryData === 'object' && subcategoryData !== null) { + //console.log('ConfigManager: Calling renderSubcategory for:', subcategoryName); + formHTML += await self.renderSubcategory.call(self, category, subcategoryName, subcategoryData); + } + } + + // Render advanced and unused sections + formHTML = await this.utils.renderSectionedContent(formHTML, subcategoryTypes.advanced, subcategoryTypes.unused, self, category, configData); + + //console.log('ConfigManager: Final formHTML length:', formHTML.length); + + if (formHTML) { + // Page-level header for the config section. Mirrors the + // .backup-page-header used on /backup so /config gets the same + // prominent H1 + description above the form fields. Looked up from + // window.configData.categories[category] so titles/descriptions + // come straight from the .category metadata file. + var catMeta = (window.configData && window.configData.categories && window.configData.categories[category]) || {}; + var catTitle = catMeta.title || (typeof ConfigShared !== 'undefined' && ConfigShared.formatCategoryName ? ConfigShared.formatCategoryName(category) : category); + var catDesc = catMeta.description || ''; + var catIcon = catMeta.icon || category; + var headerHTML = + ''; + + configSection.innerHTML = headerHTML + '
    ' + formHTML + '
    ' + + '' + + '' + + '
    '; + // Wire the submit event so it dispatches the config-update task + // instead of letting the browser fall back to a GET that dumps every + // CFG value (including passwords) into the URL. + if (this.form && typeof this.form.attachSubmitHandler === 'function') { + this.form.attachSubmitHandler(); + } + } + + //console.log('ConfigManager: Successfully rendered ' + category + ' config'); + + // Force rediscover toggles to handle timing issues + if (window.toggleManager && window.toggleManager.forceRediscover) { + setTimeout(() => { + window.toggleManager.forceRediscover(); + }, 200); + } + + } catch (error) { + console.error('ConfigManager: Error rendering ' + category + ' config: ', error); + configSection.innerHTML = '

    Error Loading Configuration

    Failed to load configuration: ' + error.message + '

    '; + } + } + + async renderSubcategory(category, subcategoryName, subcategoryData) { + //console.log('ConfigManager: renderSubcategory() called - category: ' + category + ', subcategory: ' + subcategoryName); + + var displaySubcategory = this.utils.formatSubcategoryName(subcategoryName); + // Strip the parent-category prefix from the display title so the user + // sees "Basic" instead of "General Basic" while on the General page. + if (typeof ConfigShared !== 'undefined' && ConfigShared.stripCategoryPrefix) { + displaySubcategory = ConfigShared.stripCategoryPrefix(displaySubcategory, category); + } + var subcategoryDescription = this.utils.cleanDescription(subcategoryData.description || ''); + + // The subcategoryData IS the config items, not a container for them + var configItems = []; + + // Look for actual config items in the main config object + if (window.configData && window.configData.config) { + Object.entries(window.configData.config).forEach(function([configKey, configValue]) { + if (configValue.subcategory === subcategoryName) { + configItems.push({ + key: configKey, + title: configValue.title, + description: configValue.description, + value: configValue.value, + options: configValue.options, + master: configValue.master, + subcategory: configValue.subcategory + }); + } + }); + } + + //console.log('ConfigManager: Processing subcategory:', subcategoryName, 'data:', subcategoryData); + //console.log('ConfigManager: configItems count: ' + configItems.length); + //console.log('ConfigManager: All config items keys:', configItems.map(item => item.key)); + + if (configItems.length === 0) { + //console.log('ConfigManager: No config items, returning empty string'); + return ''; + } + + //console.log('ConfigManager: renderSubcategory called with:', { + //category, + //subcategoryName, + //displaySubcategory, + //hasData: !!subcategoryData + //}); + + // Check for master toggle in this subcategory + var masterKey = configItems.find(function(item) { return item.master === true; }); + //console.log('ConfigManager: masterKey found: ' + !!masterKey, masterKey ? masterKey.key : null); + + // Look for any ENABLED options and use universal toggle renderer + var enabledKey = configItems.find(function(item) { + //console.log('Checking item for ENABLED:', item.key, item.key.includes('ENABLED')); + return item.key.includes('ENABLED') || item.key === 'CFG_INSTALL_MODE'; + }); + //console.log('ConfigManager: enabledKey found: ' + !!enabledKey, enabledKey ? enabledKey.key : null); + + // Special handling for domains section + var isDomains = subcategoryName.includes('domains') || subcategoryName.includes('network_domains'); + //console.log('ConfigManager: isDomains:', isDomains); + + // Special handling for IP whitelist section + var isWhitelist = subcategoryName === 'network_whitelist' || subcategoryName.includes('whitelist'); + //console.log('ConfigManager: subcategoryName:', subcategoryName, 'isWhitelist:', isWhitelist); + + var resultHTML = ''; + + if (isDomains) { + //console.log('ConfigManager: Using domains renderer'); + // Render domains section with special handling + resultHTML = await this.domainManager.renderDomainsSection(configItems, displaySubcategory, subcategoryDescription); + } else if (isWhitelist) { + //console.log('ConfigManager: Using whitelist renderer'); + resultHTML = await this.whitelistManager.renderWhitelistSection(configItems, displaySubcategory, subcategoryDescription); + } else if (enabledKey) { + //console.log('ConfigManager: Using universal toggle renderer'); + // Use universal toggle renderer for any ENABLED option or CFG_INSTALL_MODE + resultHTML = window.toggleManager ? window.toggleManager.renderToggleSection(enabledKey, configItems, displaySubcategory, subcategoryDescription) : ''; + } else if (masterKey) { + //console.log('ConfigManager: Using master toggle renderer'); + // Render with master toggle + resultHTML = this.renderer.renderSubcategoryWithMaster(masterKey, configItems, displaySubcategory, subcategoryDescription); + } else { + //console.log('ConfigManager: Using regular renderer'); + // Render regular subcategory + resultHTML = this.renderer.renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription); + } + + //console.log('ConfigManager: resultHTML length:', resultHTML.length); + return resultHTML; + } + + // Delegate form operations to ConfigForm + resetForm() { + return this.form.resetForm(); + } + + // Domain management methods + addDomain() { + return window.domainManager.addDomain(); + } + + deleteDomain(domainKey, buttonElement) { + return window.domainManager.deleteDomain(domainKey, buttonElement); + } + + async saveConfig() { + return await this.form.saveConfig(); + } + + showNotification(message, type) { + return this.form.showNotification(message, type); + } + } + + // Export to global scope + window.ConfigManager = ConfigManager; +} else { + //console.log('ConfigManager: Already exists, using existing instance'); +} diff --git a/containers/libreportal/frontend/js/components/config/config-options.js b/containers/libreportal/frontend/js/components/config/config-options.js new file mode 100755 index 0000000..4f1419b --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-options.js @@ -0,0 +1,434 @@ +// Config Options - Centralized configuration options for dropdown fields +class ConfigOptions { + + // Per-app form renders id="GLUETUN_VPN_*"; global form renders id="config-CFG_GLUETUN_VPN_*". + static findGluetunProviderEl() { + return document.getElementById('config-CFG_GLUETUN_VPN_SERVICE_PROVIDER') + || document.getElementById('GLUETUN_VPN_SERVICE_PROVIDER'); + } + static findGluetunVpnTypeEl() { + return document.getElementById('config-CFG_GLUETUN_VPN_TYPE') + || document.getElementById('GLUETUN_VPN_TYPE'); + } + static findGluetunFieldEl(suffix) { + return document.getElementById(`config-CFG_GLUETUN_${suffix}`) + || document.getElementById(`GLUETUN_${suffix}`); + } + + // Show only the credential fields that match the selected VPN type. + static refreshGluetunCredentialVisibility() { + const typeEl = this.findGluetunVpnTypeEl(); + if (!typeEl) return; + const type = (typeEl.value || '').toLowerCase(); + const wg = ['WIREGUARD_PRIVATE_KEY', 'WIREGUARD_ADDRESSES']; + const ov = ['OPENVPN_USER', 'OPENVPN_PASSWORD']; + const setVisible = (suffix, visible) => { + const el = this.findGluetunFieldEl(suffix); + if (!el) return; + const wrapper = el.closest('.form-field') || el.parentElement; + if (wrapper) wrapper.style.display = visible ? '' : 'none'; + }; + wg.forEach((s) => setVisible(s, type === 'wireguard')); + ov.forEach((s) => setVisible(s, type === 'openvpn')); + this.refreshMullvadGenerateButton(type); + } + + static refreshMullvadGenerateButton(typeValue) { + const providerEl = this.findGluetunProviderEl(); + const provider = (providerEl?.value || '').toLowerCase(); + const type = (typeValue || this.findGluetunVpnTypeEl()?.value || '').toLowerCase(); + const shouldShow = provider === 'mullvad' && type === 'wireguard'; + + document.querySelectorAll('.mullvad-generate-field').forEach(b => b.remove()); + if (!shouldShow) return; + + const typeEl = this.findGluetunVpnTypeEl(); + const anchor = typeEl && typeEl.closest('.form-field'); + if (!anchor) return; + + const keyEl = this.findGluetunFieldEl('WIREGUARD_PRIVATE_KEY'); + const addrEl = this.findGluetunFieldEl('WIREGUARD_ADDRESSES'); + const configured = !!(keyEl?.value && addrEl?.value); + + const block = document.createElement('div'); + block.className = 'form-field mullvad-generate-field'; + block.innerHTML = ` + +
    + + + ${configured ? '✓' : ''} + ${configured ? 'Configured' : 'Not configured'} + +
    + Generates a WireGuard key against your Mullvad account and fills the credentials below. + `; + block.querySelector('.mullvad-generate-btn') + .addEventListener('click', () => window.appsManager?.openMullvadGenerateModal?.()); + anchor.insertAdjacentElement('afterend', block); + } + + static refreshMullvadGenerateStatus() { + const status = document.querySelector('.mullvad-generate-field .mullvad-generate-status'); + if (!status) return; + const keyEl = this.findGluetunFieldEl('WIREGUARD_PRIVATE_KEY'); + const addrEl = this.findGluetunFieldEl('WIREGUARD_ADDRESSES'); + const configured = !!(keyEl?.value && addrEl?.value); + status.classList.toggle('is-configured', configured); + status.querySelector('.mullvad-generate-tick').textContent = configured ? '✓' : ''; + status.querySelector('.mullvad-generate-status-text').textContent = configured ? 'Configured' : 'Not configured'; + } + + static loadGluetunProviderIcons() { + if (this._gluetunIconsPromise) return this._gluetunIconsPromise; + this._gluetunIconsPromise = (async () => { + try { + const res = await fetch('/data/apps/gluetun-provider-icons.json', { cache: 'no-store' }); + if (!res.ok) return {}; + return await res.json(); + } catch { return {}; } + })(); + this._gluetunIconsPromise.then((m) => { window.gluetunProviderIcons = m || {}; }); + return this._gluetunIconsPromise; + } + static _gluetunIconsPromise = null; + + // Pulled from gluetun's upstream servers.json by webuiGenerateGluetunProviders. + // Generator writes to /data/apps/generated/; static fallback ships at /data/apps/. + // We try generated first, fall back to bundled, fall back to a tiny static list. + static _gluetunProvidersPromise = null; + static loadGluetunProviders() { + if (this._gluetunProvidersPromise) return this._gluetunProvidersPromise; + this._gluetunProvidersPromise = (async () => { + const tryFetch = async (url) => { + try { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) return null; + const json = await res.json(); + return json && json.providers ? json.providers : null; + } catch { return null; } + }; + const live = await tryFetch('/data/apps/generated/gluetun-providers.json'); + if (live) return live; + const fallback = await tryFetch('/data/apps/gluetun-providers.json'); + if (fallback) return fallback; + return null; + })(); + this._gluetunProvidersPromise.then((p) => { + window.gluetunProviders = p; + // If the provider field rendered before this finished, repopulate it + // (and the VPN-type field, which depends on the selected provider). + const sel = ConfigOptions.findGluetunProviderEl(); + if (sel && p) { + const previous = sel.value; + const opts = ConfigOptions.getGluetunProviderOptions(); + sel.innerHTML = opts.map((o) => + `` + ).join(''); + ConfigOptions.refreshGluetunVpnTypeOptions(); + } + ConfigOptions.refreshGluetunCredentialVisibility(); + }); + return this._gluetunProvidersPromise; + } + + static getGluetunProviderOptions() { + const providers = window.gluetunProviders; + if (!providers) { + this.loadGluetunProviders(); + return [ + { value: 'mullvad', label: 'Mullvad' }, + { value: 'nordvpn', label: 'NordVPN' }, + { value: 'protonvpn', label: 'ProtonVPN' }, + { value: 'surfshark', label: 'Surfshark' }, + { value: 'custom', label: 'Custom (manual config)' } + ]; + } + const titleCase = (s) => s.replace(/\b\w/g, (c) => c.toUpperCase()); + return Object.keys(providers).sort().map((slug) => ({ + value: slug, + label: slug === 'custom' ? 'Custom (manual config)' : titleCase(slug) + })); + } + + // Repaint the VPN-type changes, rebuild the +// VPN-type + + + ${masterKey.title || 'Enable Advanced Configuration'} + ℹ️ + + + + +
    +
    + `; + + // Add all other fields (excluding the master toggle) + configItems.filter(item => item.key !== masterKey.key).forEach(item => { + const fieldId = `config-${item.key}`; + html += window.ConfigShared?.generateField(fieldId, item.key, item.value, item.title, item.description, item.options) || ''; + }); + + html += ` +
    +
    + +
    + + `; + + return html; + } + + renderSubcategorySection(configItems, displaySubcategory, subcategoryDescription, config = {}) { + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    + `; + + // Add all fields + configItems.forEach(item => { + const fieldId = `config-${item.key}`; + html += window.ConfigShared?.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config) || ''; + }); + + html += ` +
    +
    +
    +
    + `; + + return html; + } + + renderRegularSubcategory(configItems, displaySubcategory, subcategoryDescription, config = {}) { + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    + `; + + // Add all fields + configItems.forEach(item => { + const fieldId = `config-${item.key}`; + html += window.ConfigShared?.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config) || ''; + }); + + html += ` +
    +
    +
    +
    + `; + + return html; + } + + // Field rendering methods + renderTextField(fieldId, key, value, title, description, options) { + return ` +
    + + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderNumberField(fieldId, key, value, title, description, options) { + return ` +
    + + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderEmailField(fieldId, key, value, title, description, options) { + return ` +
    + + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderPasswordField(fieldId, key, value, title, description, options) { + return ` +
    + +
    + + +
    + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderSelectField(fieldId, key, value, title, description, fieldOptions) { + const options = window.ConfigOptions?.getSelectOptions(key) || []; + return ` +
    + + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderCheckboxField(fieldId, key, value, title, description, options) { + const isChecked = value === 'true' || value === true; + return ` +
    + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + renderTextareaField(fieldId, key, value, title, description, options) { + return ` +
    + + + ${description ? `${this.cleanDescription(description)}` : ''} +
    + `; + } + + togglePasswordVisibility(fieldId) { + const input = document.getElementById(fieldId); + const button = document.querySelector(`button[onclick*="${fieldId}"]`); + + if (input && button) { + if (input.type === 'password') { + input.type = 'text'; + button.innerHTML = '🙈️'; + } else { + input.type = 'password'; + button.innerHTML = '👁️'; + } + } + } + + cleanDescription(description) { + if (!description) return ''; + return description.replace(/CFG_[A-Z_]+/g, '').replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + } +} + +// Export for use in other modules +window.ConfigRenderer = ConfigRenderer; diff --git a/containers/libreportal/frontend/js/components/config/config-router.js b/containers/libreportal/frontend/js/components/config/config-router.js new file mode 100755 index 0000000..d465aaa --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-router.js @@ -0,0 +1,291 @@ +// Configuration Router - Loads appropriate config component based on URL +class ConfigRouter { + constructor() { + this.currentComponent = null; + } + + async init() { + //console.log('ConfigRouter: Initializing...'); + // Get current category from query parameter or global variable + const searchParams = new URLSearchParams(window.location.search); + let currentCategory = searchParams.get('config') || window.configCategory || 'general'; + + //console.log(`Initial parsing - searchParams.get('config'): ${searchParams.get('config')}`); + //console.log(`Window location search: ${window.location.search}`); + //console.log(`Initial currentCategory: ${currentCategory}`); + + // Handle the case where URL is config?=backup (malformed but common) + // Check if we got the category from URL params, not from fallback + const gotFromUrlParams = searchParams.get('config') !== null; + + if (!gotFromUrlParams && window.location.search.includes('?=')) { + //console.log(`URL contains ?= and no valid config param, attempting regex match...`); + const pathMatch = window.location.search.match(/\?=([^&]+)/); + //console.log(`Regex match result:`, pathMatch); + //console.log(`Regex test result:`, /\?=([^&]+)/.test(window.location.search)); + if (pathMatch && pathMatch[1]) { + currentCategory = pathMatch[1]; + //console.log(`Updated currentCategory from regex: ${currentCategory}`); + } else { + //console.log(`Regex failed to match, currentCategory remains: ${currentCategory}`); + } + } else { + //console.log(`Got category from URL params (${gotFromUrlParams}) or URL doesn't contain ?=, keeping: ${currentCategory}`); + } + + //console.log(`Config router init: final category=${currentCategory}`); + + // Backup config moved to /backup — redirect old URL/bookmarks. + if (currentCategory === 'backup') { + if (window.librePortalSPA && typeof window.librePortalSPA.navigate === 'function') { + window.librePortalSPA.navigate('/backup', true); + } else { + window.location.href = '/backup'; + } + return; + } + + // Load categories for sidebar + await this.loadConfigCategories(); + + // Set active category in sidebar + this.setActiveCategory(currentCategory); + + // Load appropriate config component + await this.loadConfigComponent(currentCategory); + } + + async loadConfigCategories() { + try { + //console.log('Loading config categories from: data/config/generated/configs.json'); + + // Start loading bar + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(20); + } + + // Load unified config file + const response = await fetch('/data/config/generated/configs.json'); + //console.log('Response status:', response.status, response.statusText); + //console.log('Response ok:', response.ok); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const text = await response.text(); + //console.log('Raw response text:', text); + //console.log('Text length:', text.length); + //console.log('First 100 chars:', text.substring(0, 100)); + + if (!text || text.trim() === '') { + throw new Error('Empty response from configs.json'); + } + + const data = JSON.parse(text); + const categories = data.categories; + + //console.log('Parsed categories:', categories); + + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(60); + } + + const categoriesList = document.getElementById('config-categories-list'); + if (!categoriesList) { + console.error('config-categories-list element not found'); + return; + } + + categoriesList.innerHTML = ''; + + // Convert categories object to array and sort by ORDER + const categoriesArray = Object.entries(categories).map(([key, value]) => ({ + id: key, + ...value + })); + + // Sort by ORDER if available, otherwise by title + categoriesArray.sort((a, b) => { + const orderA = parseInt(a.order) || 999; + const orderB = parseInt(b.order) || 999; + return orderA - orderB; + }); + + categoriesArray.forEach(category => { + const categoryItem = document.createElement('div'); + categoryItem.className = 'category'; + categoryItem.setAttribute('data-category', category.id); + + // Use correct icon from our new structure + const iconName = category.icon || category.id; + const iconPath = `/icons/config/${iconName}.svg`; + //console.log(`Category: ${category.id}, Icon path: ${iconPath}`); + categoryItem.innerHTML = ` + ${category.title} + ${category.title} + `; + + categoryItem.addEventListener('click', () => { + //console.log(`Category clicked: ${category.id}`); + this.navigateToCategory(category.id); + }); + + categoriesList.appendChild(categoryItem); + }); + + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(80); + } + + } catch (error) { + console.error('Error loading config categories:', error); + if (typeof router !== 'undefined' && router.hideLoadingBar) { + router.hideLoadingBar(); + } + } + } + + setActiveCategory(categoryId) { + // Update active state + document.querySelectorAll('.category').forEach(item => { + item.classList.remove('active'); + }); + document.querySelector(`[data-category="${categoryId}"]`)?.classList.add('active'); + } + + navigateToCategory(categoryId) { + //console.log(`Config router: navigating to ${categoryId} (SPA mode)`); + + // Update URL without full page reload using query parameter + const url = `/config?=${categoryId}`; + //console.log(`Updating URL to: ${url}`); + window.history.pushState({}, '', url); + + // Set active category + this.setActiveCategory(categoryId); + + // Load config content dynamically + this.loadConfigComponent(categoryId); + } + + async loadConfigComponent(categoryId) { + try { + //console.log(`Config router: Loading component for ${categoryId}`); + + // Start loading bar + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(10); + router.showLoadingBar(); + } + + // Clear current content + const configSection = document.getElementById('config-section'); + if (configSection) { + configSection.innerHTML = '
    Loading configuration...
    '; + } + + // Update progress + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(30); + } + + // Use the simple config manager + if (window.configManager) { + //console.log(`Using ConfigManager for ${categoryId}`); + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(50); + } + + await window.configManager.renderConfig(categoryId); + + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(90); + } + } else { + // Fallback - try to load config manager + //console.log('ConfigManager not available, loading it...'); + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(40); + } + + await new Promise((resolve) => { + const script = document.createElement('script'); + script.src = '/js/components/config/config-manager.js'; + script.onload = async () => { + if (window.configManager) { + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(60); + } + await window.configManager.renderConfig(categoryId); + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(90); + } + } + resolve(); + }; + document.head.appendChild(script); + }); + } + + // Complete loading + if (typeof router !== 'undefined' && router.updateProgress) { + router.updateProgress(100); + setTimeout(() => { + router.hideLoadingBar(); + }, 500); // Small delay to show completion + } + + } catch (error) { + console.error(`Error loading config component for ${categoryId}:`, error); + const configSection = document.getElementById('config-section'); + if (configSection) { + configSection.innerHTML = `
    Failed to load ${categoryId} configuration: ${error.message}
    `; + } + if (typeof router !== 'undefined' && router.hideLoadingBar) { + router.hideLoadingBar(); + } + } + } + + async loadConfigComponentManual(categoryId) { + const configSection = document.getElementById('config-section'); + + // This method is no longer needed since we use ConfigManager for all categories + // The individual config classes have been removed + //console.log(`ConfigRouter: loadConfigComponentManual called for ${categoryId} - delegating to ConfigManager`); + + // Use ConfigManager for all categories now + if (window.configManager) { + await window.configManager.renderConfig(categoryId); + } else { + configSection.innerHTML = '
    ConfigManager not available
    '; + } + + // Hide loading bar + if (typeof router !== 'undefined' && router.hideLoadingBar) { + router.hideLoadingBar(); + } + } + + async loadScript(src) { + // Check if script is already loaded + const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_'); + if (document.getElementById(scriptId)) { + //console.log(`Script ${src} already loaded, skipping`); + return; + } + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.id = scriptId; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } +} + +// Export for global access +window.ConfigRouter = ConfigRouter; diff --git a/containers/libreportal/frontend/js/components/config/config-shared.js b/containers/libreportal/frontend/js/components/config/config-shared.js new file mode 100755 index 0000000..6a57ad5 --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-shared.js @@ -0,0 +1,1542 @@ +// Config Shared Functions - Common functionality used across all config components +class ConfigShared { + + // Toggle switch system - handles different types of toggles with proper layout + static createToggleSwitch(fieldId, key, value, title, description, options = {}) { + const isChecked = value === 'true'; + const tooltipHtml = description ? `ℹ️` : ''; + + // Determine toggle type and layout + const toggleType = options.type || 'standard'; + const layout = options.layout || 'inline'; + + let toggleHTML = ''; + + switch (toggleType) { + case 'master': + toggleHTML = this.createMasterToggle(fieldId, key, title, isChecked, tooltipHtml, options); + break; + case 'section': + toggleHTML = this.createSectionToggle(fieldId, key, title, isChecked, tooltipHtml, options); + break; + case 'standard': + default: + toggleHTML = this.createStandardToggle(fieldId, key, title, isChecked, tooltipHtml, options); + break; + } + + // Wrap in layout container + return this.wrapToggleInLayout(toggleHTML, layout, options); + } + + // Standard toggle switch (most common) + static createStandardToggle(fieldId, key, title, isChecked, tooltipHtml, options) { + return ` +
    + +
    + `; + } + + // Master toggle (for enabling/disabling entire sections) + static createMasterToggle(fieldId, key, title, isChecked, tooltipHtml, options) { + return ` +
    + + +
    + `; + } + + // Section toggle (for showing/hiding sections) + static createSectionToggle(fieldId, key, title, isChecked, tooltipHtml, options) { + return ` +
    + +
    + `; + } + + // Wrap toggle in layout container + static wrapToggleInLayout(toggleHTML, layout, options) { + switch (layout) { + case 'grid': + return `
    ${toggleHTML}
    `; + case 'flex': + return `
    ${toggleHTML}
    `; + case 'inline': + default: + return toggleHTML; + } + } + + // Auto-detect and create appropriate field type + static createSmartField(fieldId, key, value, title, description, options = {}) { + //console.log(`createSmartField: key=${key}, value=${value}, config=${!!options.config}`); + + // Check if value is boolean (true/false strings) + const isBoolean = value === 'true' || value === 'false'; + + if (isBoolean) { + // Auto-create toggle switch for boolean values + return this.createToggleSwitch(fieldId, key, value, title, description, { + type: options.type || 'standard', + layout: options.layout || 'inline', + category: options.category || '', + sectionId: options.sectionId || '', + fieldIds: options.fieldIds || [] + }); + } + + // Fall back to regular field generation for non-boolean values + return this.generateField(fieldId, key, value, title, description, options.selectOptions, options.config); + } + + // Handle standard toggle changes + static handleToggleChange(checkbox, key, category) { + //console.log(`Toggle changed: ${key} = ${checkbox.checked} (category: ${category})`); + + // Trigger custom event for other components to listen to + const event = new CustomEvent('configToggleChanged', { + detail: { key, value: checkbox.checked, category } + }); + document.dispatchEvent(event); + + // Auto-save if enabled + if (checkbox.dataset.autoSave === 'true') { + this.saveToggleValue(key, checkbox.checked); + } + } + + // Handle master toggle changes (enables/disables multiple fields) + static handleMasterToggle(checkbox, sectionId, fieldIds) { + //console.log(`Master toggle changed: ${sectionId} = ${checkbox.checked}`); + + // Enable/disable related fields + if (fieldIds && fieldIds.length > 0) { + fieldIds.forEach(fieldId => { + const field = document.getElementById(`config-${fieldId}`); + if (field) { + field.disabled = !checkbox.checked; + field.closest('.config-field')?.classList.toggle('disabled', !checkbox.checked); + } + }); + } + + // Show/hide section content + if (sectionId) { + const sectionContent = document.getElementById(`${sectionId}-content`); + if (sectionContent) { + sectionContent.style.display = checkbox.checked ? 'block' : 'none'; + } + } + + // Trigger custom event + const event = new CustomEvent('configMasterToggleChanged', { + detail: { sectionId, enabled: checkbox.checked, fieldIds } + }); + document.dispatchEvent(event); + } + + // Handle section toggle changes (shows/hides sections) + static handleSectionToggle(checkbox, sectionId) { + //console.log(`Section toggle changed: ${sectionId} = ${checkbox.checked}`); + + const content = document.getElementById(`${sectionId}-content`); + if (content) { + content.style.display = checkbox.checked ? 'block' : 'none'; + } + + // Trigger custom event + const event = new CustomEvent('configSectionToggleChanged', { + detail: { sectionId, visible: checkbox.checked } + }); + document.dispatchEvent(event); + } + + // Save toggle value immediately + static async saveToggleValue(key, value) { + try { + // For now, just log the change - local implementation + //console.log('Toggle value changed:', key, value ? 'true' : 'false'); + + // TODO: Implement local config file update + // This would require backend integration to write to actual config files + + } catch (error) { + console.error('Error saving toggle value:', error); + } + } + + // Create custom range field with start-end inputs + static createRangeField(fieldId, key, value, title, description) { + // Parse range value (format: "start-end") + let startRange = ''; + let endRange = ''; + + if (value && value.includes('-')) { + const parts = value.split('-'); + startRange = parts[0] || ''; + endRange = parts[1] || ''; + } + + return ` +
    + + - + + +
    + + `; + } + + // Update range value when inputs change + static updateRangeValue(key) { + const startInput = document.getElementById(`config-${key}-start`); + const endInput = document.getElementById(`config-${key}-end`); + const hiddenInput = document.getElementById(`config-${key}`); + + if (startInput && endInput && hiddenInput) { + const startValue = startInput.value || ''; + const endValue = endInput.value || ''; + + if (startValue && endValue) { + hiddenInput.value = `${startValue}-${endValue}`; + } else if (startValue) { + hiddenInput.value = `${startValue}-`; + } else if (endValue) { + hiddenInput.value = `-${endValue}`; + } else { + hiddenInput.value = ''; + } + } + } + + // Create custom crontab field with hour/minutes/AM-PM + static createCrontabField(fieldId, key, value, title, description) { + // Parse crontab value (format: "minute hour * * *") + const parts = value.split(' '); + let hour = '5'; + let minute = '0'; + let period = 'AM'; + + if (parts.length >= 2) { + minute = parts[0] || '0'; + const cronHour = parseInt(parts[1]) || 0; + + // Convert 24-hour to 12-hour format + if (cronHour === 0) { + hour = '12'; + period = 'AM'; + } else if (cronHour < 12) { + hour = cronHour.toString(); + period = 'AM'; + } else if (cronHour === 12) { + hour = '12'; + period = 'PM'; + } else { + hour = (cronHour - 12).toString(); + period = 'PM'; + } + } + + return ` +
    + + : + + / + + +
    + + `; + } + + // Update crontab value when fields change + static updateCrontabValue(key) { + const fieldId = `config-${key}`; + const hourSelect = document.getElementById(`${fieldId}-hour`); + const minuteSelect = document.getElementById(`${fieldId}-minute`); + const periodSelect = document.getElementById(`${fieldId}-period`); + const hiddenInput = document.getElementById(fieldId); + + if (!hourSelect || !minuteSelect || !periodSelect || !hiddenInput) { + console.warn(`Crontab field elements not found for: ${key}`); + return; + } + + const hour = parseInt(hourSelect.value); + const minute = minuteSelect.value; + const period = periodSelect.value; + + // Convert 12-hour to 24-hour format + let cronHour; + if (period === 'AM') { + cronHour = hour === 12 ? 0 : hour; + } else { + cronHour = hour === 12 ? 12 : hour + 12; + } + + // Create crontab format: "minute hour * * *" + const crontabValue = `${minute} ${cronHour} * * *`; + hiddenInput.value = crontabValue; + + //console.log(`Updated crontab for ${key}: ${crontabValue}`); + } + + // Toggle password visibility + static togglePasswordVisibility(fieldId) { + const passwordField = document.getElementById(fieldId); + const icon = document.getElementById(`${fieldId}-icon`); + + if (!passwordField || !icon) { + console.warn(`Password field or icon not found: ${fieldId}`); + return; + } + + if (passwordField.type === 'password') { + passwordField.type = 'text'; + icon.textContent = '👁‍🗨'; // Eye with strikethrough (hidden) + } else { + passwordField.type = 'password'; + icon.textContent = '👁'; // Regular eye (visible) + } + } + + static setPasswordMode(fieldId, mode) { + const wrapper = document.querySelector(`.password-mode-wrapper[data-field-id="${fieldId}"]`); + const input = document.getElementById(fieldId); + const tokenInput = document.getElementById(`${fieldId}-token`); + if (!wrapper || !input || !tokenInput) return; + + const key = wrapper.dataset.fieldKey; + + if (mode === 'random') { + input.dataset.previousCustom = input.value || ''; + input.value = ''; + input.readOnly = true; + input.type = 'password'; + input.setAttribute('placeholder', 'Will generate on save'); + input.removeAttribute('name'); + tokenInput.setAttribute('name', key); + const icon = document.getElementById(`${fieldId}-icon`); + if (icon) icon.textContent = '👁'; + } else { + input.readOnly = false; + input.removeAttribute('placeholder'); + input.value = input.dataset.previousCustom || ''; + input.setAttribute('name', key); + tokenInput.removeAttribute('name'); + input.focus(); + } + } + + // Format config key to readable label + static formatConfigLabel(key) { + // Special handling for domain configuration + if (key.startsWith('CFG_DOMAIN_')) { + const domainNum = key.replace('CFG_DOMAIN_', ''); + return `Domain ${domainNum}`; + } + + // Special handling for requirement configuration - remove REQUIREMENT and format nicely + if (key.startsWith('CFG_REQUIREMENT_')) { + const requirement = key.replace('CFG_REQUIREMENT_', ''); + return requirement + .replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + } + + return key + .replace(/^CFG_/, '') + .replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + } + + // Group config keys by category + static groupConfigKeys(config) { + const groups = {}; + + // Group by category field from JSON config + for (const key of Object.keys(config)) { + const configItem = config[key] || {}; + const category = configItem.category; + + if (!groups[category]) { + groups[category] = []; + } + groups[category].push(key); + } + + // Pin any per-app `*_BACKUP` toggle to the top of its category so the + // user always sees it first when configuring an app. Matches both + // `CFG_BACKUP` and `CFG__BACKUP` (but not `*_BACKUP_*` like + // `CFG_BACKUP_CRONTAB_APP`, which would otherwise bubble up too). + for (const cat of Object.keys(groups)) { + groups[cat].sort((a, b) => { + const aBackup = /^CFG_(?:[A-Z0-9]+_)?BACKUP$/.test(a); + const bBackup = /^CFG_(?:[A-Z0-9]+_)?BACKUP$/.test(b); + if (aBackup && !bBackup) return -1; + if (!aBackup && bBackup) return 1; + return 0; + }); + } + + return groups; + } + + // Extract category order from config + static extractCategoryOrder(config) { + const order = []; + const seen = new Set(); + + // Get categories in the order they appear in config + for (const key of Object.keys(config)) { + const configItem = config[key] || {}; + const category = configItem.category; + + if (category && !seen.has(category)) { + seen.add(category); + order.push(category); + } + } + + return order; + } + + // Format category name for display + static formatCategoryName(category) { + return category + .replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + } + + /* Subcategory titles often come back as " " — e.g. + "General Basic" under the General category, "Webui Logins" under + the Webui category. The redundant leading category word is noisy + when the user is already viewing that category page, so strip it. + Comparison is case-insensitive and only strips a clean leading + whole-word match (won't turn "Configuration" into "iguration"). */ + static stripCategoryPrefix(title, parentCategory) { + if (!title || !parentCategory) return title; + const prefix = ConfigShared.formatCategoryName(parentCategory) + ' '; + if (title.toLowerCase().startsWith(prefix.toLowerCase())) { + return title.substring(prefix.length); + } + return title; + } + + // Get category description from config or fallback + static async getCategoryDescription(category) { + try { + // First try to get description from the unified config + const response = await fetch('/data/config/generated/configs.json'); + const data = await response.json(); + + // Check if categories exist in this config + if (data.categories) { + // Categories are stored as simple key-value pairs: "CATEGORY": "description" + const categoryDescription = data.categories[category]; + if (categoryDescription) { + return categoryDescription; + } + } + + // Fallback to unified config for general descriptions + const unifiedConfigResponse = await fetch('/data/config/generated/configs.json'); + const unifiedConfigData = await unifiedConfigResponse.json(); + const fallbackCategoryData = unifiedConfigData.categories[category] || null; + + return fallbackCategoryData ? fallbackCategoryData.description : `${this.formatCategoryName(category)} settings and configuration`; + } catch (error) { + console.error('Error loading category descriptions:', error); + return `${this.formatCategoryName(category)} settings and configuration`; + } + } + + // Generate appropriate field based on value type and key + static generateField(fieldId, key, value, title, description, options = {}, allConfig = {}) { + //console.log(`generateField: key=${key}, value=${value}, CFG_INSTALL_MODE=${allConfig.CFG_INSTALL_MODE?.value}`); + + // Note: Git fields (CFG_GIT_*) are now handled by the toggle system in renderGitSection + // They don't need to be hidden here since the section itself is toggled + + // Handle boolean toggle fields from options + const { onchange, oninput, onblur, className, placeholder, ...fieldOptions } = options; + + // Check if value is boolean (true/false strings) + const isBoolean = value === 'true' || value === 'false'; + + if (isBoolean) { + // Auto-create toggle switch for boolean values + return this.createToggleSwitch(fieldId, key, value, title, description, { + type: 'standard', + layout: 'inline', + category: '' + }); + } + + // Non-boolean fields - use exact old config structure + const tooltipHtml = description ? `ℹ️` : ''; + + let fieldHTML = ` +
    + + `; + + // Determine field type based on key or options + ////console.log(`Checking field type for key: ${key}`); // Debug log + ////console.log(`Password check: ${key.includes('PASS') && !key.includes('LENGTH')}, ${key.includes('SECRET')}`); // Debug log + + // Custom crontab fields for backup schedules + if (key === 'CFG_BACKUP_CRONTAB_APP') { + fieldHTML += this.createCrontabField(fieldId, key, value, title, description); + } else if (key.includes('PORT_RANGE')) { + fieldHTML += this.createRangeField(fieldId, key, value, title, description); + } else if ((key.includes('PASS') && !key.includes('LENGTH')) || key.includes('SECRET') || key.endsWith('_API_KEY') || key.endsWith('_PRIVATE_KEY')) { + ////console.log(`Creating password field for: ${key}`); // Debug log + const randomMatch = typeof value === 'string' && /^RANDOMIZEDPASSWORD\d+$/.test(value); + const placeholderToken = randomMatch ? value : `RANDOMIZEDPASSWORD1`; + const initialMode = randomMatch ? 'random' : 'custom'; + const inputValue = randomMatch ? '' : value; + const visibleName = initialMode === 'custom' ? `name="${key}"` : ''; + const hiddenName = initialMode === 'random' ? `name="${key}"` : ''; + fieldHTML += ` +
    + +
    + + + +
    +
    + + `; + } else if (key.includes('EMAIL') || key.includes('MAIL')) { + // Special handling for CFG_MAIL_SECURE - it's a dropdown + if (key === 'CFG_MAIL_SECURE') { + const selectOptions = ConfigOptions.getSelectOptions(key); + fieldHTML += ``; + } else { + // Different validation for different mail fields + let validationAttrs = ''; + let fieldType = 'email'; + let placeholder = 'user@example.com'; + + if (key === 'CFG_MAIL_HOST') { + fieldType = 'text'; + placeholder = 'mail.domain.com'; + validationAttrs = `onchange="window.configManager.validateHostnameFormat(this, true)" oninput="window.configManager.validateHostnameFormat(this, true)" onblur="window.configManager.validateHostnameFormat(this, true)"`; + } else if (key === 'CFG_MAIL_PORT') { + fieldType = 'number'; + placeholder = '587'; + validationAttrs = `onchange="window.configManager.validatePortNumber(this, true)" oninput="window.configManager.validatePortNumber(this, true)" onblur="window.configManager.validatePortNumber(this, true)"`; + } else if (key === 'CFG_MAIL_USERNAME' || key === 'CFG_MAIL_FROM') { + validationAttrs = `onchange="window.configManager.validateEmailFormat(this, true)" oninput="window.configManager.validateEmailFormat(this, true)" onblur="window.configManager.validateEmailFormat(this, true)"`; + } else { + validationAttrs = `onchange="window.configManager.validateEmailFormat(this, true)" oninput="window.configManager.validateEmailFormat(this, true)" onblur="window.configManager.validateEmailFormat(this, true)"`; + } + + fieldHTML += ` + + `; + } + } else if (key.includes('URL') || key.includes('LINK') || key.includes('HOST') || key.startsWith('CFG_DOMAIN_')) { + const placeholder = key.startsWith('CFG_DOMAIN_') ? 'example.com' : 'https://example.com'; + fieldHTML += ` + + `; + } else if (key === 'CFG_BACKUP_CRONTAB_APP_INTERVAL') { + fieldHTML += ` +
    + + minutes +
    + `; + } else if (/^CFG_BACKUP(_LOC_[0-9]+)?_KEEP_(LAST|DAILY|WEEKLY|MONTHLY|YEARLY)$/.test(key)) { + const unitLabel = key.endsWith('_KEEP_LAST') ? 'snapshots' : + key.endsWith('_KEEP_DAILY') ? 'days' : + key.endsWith('_KEEP_WEEKLY') ? 'weeks' : + key.endsWith('_KEEP_MONTHLY') ? 'months' : 'years'; + fieldHTML += ` +
    + + ${unitLabel} +
    + `; + } else if (key === 'CFG_UPDATER_CHECK') { + fieldHTML += ` +
    + + minutes +
    + `; + } else if (key === 'CFG_GENERATED_PASS_LENGTH') { + fieldHTML += ` +
    + + characters +
    + `; + } else if (key === 'CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES') { + fieldHTML += ` +
    + + minutes +
    + `; + } else if (key === 'CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES') { + fieldHTML += ` +
    + + minutes +
    + `; + } else if (key === 'CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC') { + fieldHTML += ` +
    + + lines +
    + `; + } else if (key === 'CFG_SWAPFILE_SIZE') { + // Extract numeric value from "2G" format + let numericValue = value.replace(/[^0-9.]/g, ''); + fieldHTML += ` +
    + + GB +
    + `; + } else if (key.includes('SIZE') || key.includes('LENGTH') || key.includes('CHECK') || key.includes('MTU') || key.includes('PORT')) { + let min = ''; + let max = ''; + + if (key.includes('PORT')) { + min = '1'; + max = '65535'; + } else if (key.includes('SIZE') || key.includes('LENGTH')) { + min = '0'; + max = key.includes('PASS_LENGTH') ? '128' : ''; + } + + fieldHTML += ` + + `; + } else if (key.includes('TIMEZONE')) { + // Special handling for Timezone - create comprehensive timezone dropdown + const timezoneOptions = ConfigOptions.getTimezoneOptions(); + //console.log('Timezone key:', key, 'Current value:', value, 'Type:', typeof value); + //console.log('Available timezone options:', timezoneOptions.map(opt => ({value: opt.value, label: opt.label}))); + fieldHTML += ` + + `; + //console.log('Generated timezone dropdown HTML for', key, 'with value', value); + } else if (key === 'CFG_INSTALL_MODE') { + const selectOptions = ConfigOptions.getSelectOptions(key); + fieldHTML += ``; + } else if (/^CFG_BACKUP_LOC_[0-9]+_TYPE$/.test(key)) { + const selectOptions = ConfigOptions.getSelectOptions(key); + fieldHTML += ``; + } else if (ConfigOptions.isDropdownKey(key) || (options && Object.keys(options).length > 0)) { + //console.log('=== GENERIC DROPDOWN BLOCK ENTERED for key:', key); + //console.log('Dropdown detected for key:', key); + //console.log('isDropdownKey result:', ConfigOptions.isDropdownKey(key)); + //console.log('options available:', options); + const selectOptions = (options && typeof options === 'string') ? this.parseOptions(options) : ConfigOptions.getSelectOptions(key); + //console.log('selectOptions:', selectOptions); + fieldHTML += ` + + `; + //console.log('Generated dropdown for', key, 'with value', value); + } else if (key.includes('DESCRIPTION') || key.includes('COMMENTS') || key.includes('NOTES')) { + //console.log('Textarea detected for key:', key); + fieldHTML += ` + + `; + } else { + //console.log('Default text input for key:', key); + // Default text input with event handlers and options + const inputClass = className ? `form-control ${className}` : 'form-control'; + const inputPlaceholder = placeholder || ''; + const eventHandlers = []; + if (onchange) eventHandlers.push(`onchange="${onchange}"`); + if (oninput) eventHandlers.push(`oninput="${oninput}"`); + if (onblur) eventHandlers.push(`onblur="${onblur}"`); + const eventAttrs = eventHandlers.length > 0 ? ` ${eventHandlers.join(' ')}` : ''; + + fieldHTML += ` + + `; + } + + fieldHTML += ` +
    + `; + + return fieldHTML; + } + + // Create master toggle for section enabling/disabling + static createMasterToggle(sectionId, masterKey, isEnabled, title, description) { + return ` +
    +
    + +
    +
    + `; + } + + // Create section content wrapper + static createSectionContent(sectionId, isEnabled) { + return ` +
    + `; + } + + // Toggle section fields (modular function) + static toggleSection(sectionId, isEnabled) { + const sectionContent = document.getElementById(`section-content-${sectionId}`); + + if (!sectionContent) { + console.warn(`Section content not found: ${sectionId}`); + return; + } + + const fields = sectionContent.querySelectorAll('input, select, textarea'); + + if (isEnabled) { + // Enable section + sectionContent.classList.remove('disabled'); + fields.forEach(field => { + field.disabled = false; + const fieldGroup = field.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + //console.log(`Section ${sectionId} enabled`); + } else { + // Disable section + sectionContent.classList.add('disabled'); + fields.forEach(field => { + field.disabled = true; + const fieldGroup = field.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.6'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + //console.log(`Section ${sectionId} disabled`); + } + } + + // Toggle section visibility (hide/show entire section) + static toggleSectionVisibility(sectionId, isEnabled) { + const sectionContent = document.getElementById(sectionId); + + if (!sectionContent) { + console.warn(`Section content not found: ${sectionId}`); + return; + } + + if (isEnabled) { + // Show section + sectionContent.classList.remove('hidden'); + //console.log(`Section ${sectionId} shown`); + } else { + // Hide section + sectionContent.classList.add('hidden'); + //console.log(`Section ${sectionId} hidden`); + } + } + + // Initialize section toggles on page load + static initializeSectionToggles() { + // Find all master toggles and initialize their sections + document.querySelectorAll('[id^="config-CFG_"][onchange*="toggleSection"]').forEach(toggle => { + // Extract section ID from the onchange attribute + const onchangeAttr = toggle.getAttribute('onchange'); + const match = onchangeAttr.match(/toggleSection\('([^']+)'/); + if (match) { + const sectionId = match[1]; + const isEnabled = toggle.checked; + this.toggleSection(sectionId, isEnabled); + } + }); + } + + // Git section toggle function (moved from global scope) + static toggleGitSectionFields(isEnabled) { + // If no parameter provided, get the state from the checkbox + if (typeof isEnabled === 'undefined') { + const gitLoginCheckbox = document.getElementById('git-login-toggle'); + isEnabled = gitLoginCheckbox ? gitLoginCheckbox.checked : false; + } + + const gitSectionContent = document.getElementById('git-section-content'); + const gitFields = gitSectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + + if (gitSectionContent && gitFields) { + if (isEnabled) { + gitSectionContent.classList.remove('hidden'); + gitFields.forEach(field => { + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + gitSectionContent.classList.add('hidden'); + gitFields.forEach(field => { + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + } + + // Force re-initialization of form to ensure proper state + setTimeout(() => { + const form = document.getElementById('config-form'); + if (form) { + // Trigger a change event to update any dependent fields + const event = new Event('change', { bubbles: true }); + gitSectionContent?.dispatchEvent(event); + } + }, 100); + } + + // Universal toggle function for all _ENABLED options + static toggleSection(sectionId, isEnabled) { + //console.log('=== UNIVERSAL TOGGLE DEBUG ==='); + //console.log('sectionId:', sectionId); + //console.log('isEnabled:', isEnabled); + + const sectionContent = document.getElementById(sectionId); + const fields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + + //console.log('sectionContent found:', !!sectionContent); + //console.log('fields found:', fields ? fields.length : 0); + + if (sectionContent && fields) { + if (isEnabled) { + //console.log('Enabling section...'); + sectionContent.classList.remove('hidden'); + fields.forEach((field, index) => { + //console.log(`Enabling field ${index}:`, field); + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + //console.log('Disabling section...'); + sectionContent.classList.add('hidden'); + fields.forEach((field, index) => { + //console.log(`Disabling field ${index}:`, field); + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + } + + //console.log('=== UNIVERSAL TOGGLE DEBUG END ==='); + } + + // Remote backup section toggle function + static toggleMailSection(sectionId, isEnabled) { + //console.log('=== TOGGLE MAIL SECTION DEBUG ==='); + //console.log('sectionId:', sectionId); + //console.log('isEnabled:', isEnabled); + + alert('toggleMailSection called: ' + sectionId + ', enabled: ' + isEnabled); + + //console.log('Looking for sectionContent...'); + const sectionContent = document.getElementById(sectionId); + //console.log('sectionContent found:', !!sectionContent); + + //console.log('Looking for mailFields...'); + const mailFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + //console.log('mailFields found:', mailFields ? mailFields.length : 0); + + if (sectionContent && mailFields) { + //console.log('Enabling mail section...'); + if (isEnabled) { + //console.log('Removing hidden class...'); + sectionContent.classList.remove('hidden'); + //console.log('Enabling fields...'); + mailFields.forEach((field, index) => { + //console.log(`Disabling field ${index}:`, field); + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + //console.log('Enabling field group...'); + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + //console.log('Disabling mail section...'); + sectionContent.classList.add('hidden'); + //console.log('Disabling fields...'); + mailFields.forEach((field, index) => { + //console.log(`Enabling field ${index}:`, field); + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + //console.log('Disabling field group...'); + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + } + + //console.log('=== TOGGLE MAIL SECTION DEBUG END ==='); + } + + static toggleRemoteBackupSection(sectionId, isEnabled) { + const sectionContent = document.getElementById(sectionId); + const backupFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + + if (sectionContent && backupFields) { + if (isEnabled) { + sectionContent.classList.remove('hidden'); + backupFields.forEach(field => { + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + sectionContent.classList.add('hidden'); + backupFields.forEach(field => { + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + } + + // Force re-initialization of form to ensure proper state + setTimeout(() => { + const form = document.getElementById('config-form'); + if (form) { + // Trigger a change event to update any dependent fields + const event = new Event('change', { bubbles: true }); + sectionContent?.dispatchEvent(event); + } + }, 100); + } + + // Git section toggle function + static toggleGitSection(sectionId, isEnabled) { + const sectionContent = document.getElementById(sectionId); + const gitFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + + if (sectionContent && gitFields) { + if (isEnabled) { + sectionContent.classList.remove('hidden'); + gitFields.forEach(field => { + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + sectionContent.classList.add('hidden'); + gitFields.forEach(field => { + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + } + + // Force re-initialization of form to ensure proper state + setTimeout(() => { + const form = document.getElementById('config-form'); + if (form) { + // Trigger a change event to update any dependent fields + const event = new Event('change', { bubbles: true }); + sectionContent?.dispatchEvent(event); + } + }, 100); + } + + // Parse options string into array of {value, label} objects + static parseOptions(options) { + if (!options || typeof options !== 'string') { + return []; + } + + return options.split('|').map(opt => { + const parts = opt.split('='); + if (parts.length === 2) { + return { value: parts[0].trim(), label: parts[1].trim() }; + } + return { value: opt.trim(), label: opt.trim() }; + }); + } + + // Generate fields for category with 3-per-line layout and smart field detection + static generateFieldsForCategory(keys, category, config, generateFieldCallback = null) { + let formHTML = '
    '; + + keys.forEach((key, index) => { + const configItem = config[key] || {}; + const value = configItem.value || ''; + const title = configItem.title || this.formatConfigLabel(key); + const description = configItem.description || ''; + const options = configItem.options || ''; + const fieldId = `config-${key}`; + + // Add line break every 3 items + if (index > 0 && index % 3 === 0) { + formHTML += `
    `; + } + + // Use smart field creation if no callback provided, otherwise use callback + if (generateFieldCallback) { + formHTML += generateFieldCallback(fieldId, key, value, title, description, options, config); + } else { + formHTML += this.createSmartField(fieldId, key, value, title, description, { + selectOptions: options, + category: category, + layout: 'inline', + config: config + }); + } + }); + + formHTML += `
    `; + return formHTML; + } + + // Generate fields for category WITHOUT the leading divider (for master toggle sections) + static generateFieldsForCategoryNoDivider(keys, category, config, generateFieldCallback = null) { + let formHTML = ''; + + keys.forEach((key, index) => { + const configItem = config[key] || {}; + const value = configItem.value || ''; + const title = configItem.title || this.formatConfigLabel(key); + const description = configItem.description || ''; + const options = configItem.options || ''; + const fieldId = `config-${key}`; + + let fieldHTML; + if (generateFieldCallback) { + fieldHTML = generateFieldCallback(fieldId, key, value, title, description, options, config); + } else { + fieldHTML = this.generateField(fieldId, key, value, title, description, options, config); + } + + formHTML += fieldHTML; + }); + + return formHTML; + } + + // Separate categories into regular, advanced, and unused + static categorizeConfigs(config) { + const groupedConfigs = this.groupConfigKeys(config); + const categoryOrder = this.extractCategoryOrder(config); + + const regularCategories = []; + const advancedCategories = []; + const unusedCategories = []; + + for (const category of categoryOrder) { + const keys = groupedConfigs[category]; + + if (keys && keys.length > 0 && category !== 'Hidden/Unused Options') { + // Check if category has advanced items + const hasAdvanced = keys.some(key => { + const configItem = config[key] || {}; + return configItem.advanced === true; + }); + + // Check if category has unused items + const hasUnused = keys.some(key => { + const configItem = config[key] || {}; + return configItem.unused === true; + }); + + // Categorize based on the presence of advanced/unused flags + if (hasUnused) { + unusedCategories.push(category); + } else if (hasAdvanced) { + advancedCategories.push(category); + } else { + regularCategories.push(category); + } + } + } + + return { + groupedConfigs, + categoryOrder, + regularCategories, + advancedCategories, + unusedCategories + }; + } + + // Generate warning notice for requirements page + static generateRequirementsWarning() { + return ` +
    +
    +

    ⚠️ System Requirements Warning

    +

    Disabling any of the following system requirements may break LibrePortal functionality. Always create a backup before making changes.

    +
    +
    + `; + } + + // Generate toggle controls HTML for advanced/unused sections + static generateToggleControls(hasAdvanced = false, hasUnused = false) { + if (!hasAdvanced && !hasUnused) { + return ''; // Don't show danger zone if no content + } + + let formHTML = ` +
    +
    +

    ⚠️ Danger Zone

    +

    These options are for advanced users and may affect system stability

    +
    + +
    + `; + + if (hasAdvanced) { + formHTML += ` +
    + +
    + `; + } + + if (hasUnused) { + formHTML += ` +
    + +
    + `; + } + + formHTML += ` +
    +
    + `; + + return formHTML; + } + + // Generate advanced sections HTML + static async generateAdvancedSections(advancedCategories, groupedConfigs, config, getCategoryDescriptionCallback) { + if (advancedCategories.length === 0) { + return ''; + } + + let formHTML = ` + `; + return formHTML; + } + + // Generate unused sections HTML + static async generateUnusedSections(unusedCategories, groupedConfigs, config, getCategoryDescriptionCallback) { + if (unusedCategories.length === 0) { + return ''; + } + + let formHTML = ` + `; + return formHTML; + } + + // Toggle advanced sections visibility + static toggleAdvancedSections() { + const checkbox = document.getElementById('show-advanced'); + const advancedSections = document.getElementById('advanced-sections'); + + if (advancedSections) { + advancedSections.style.display = checkbox.checked ? 'block' : 'none'; + } + } + + // Toggle unused sections visibility + static toggleUnusedSections() { + const checkbox = document.getElementById('show-unused'); + const unusedSections = document.getElementById('unused-sections'); + + if (unusedSections) { + unusedSections.style.display = checkbox.checked ? 'block' : 'none'; + } + } +} + +// Export for global access +window.ConfigShared = ConfigShared; + +// Global toggle change function for checkbox handling +window.handleToggleChange = function(checkbox, key) { + //console.log(`Toggle changed: ${key} = ${checkbox.checked}`); + // This function can be extended to handle specific toggle logic + // For now, it just logs change +}; + +// Global flag to prevent multiple config reloads +window.isReloadingConfig = false; + +// Global function to handle CFG_INSTALL_MODE change +window.handleInstallModeChange = function(selectElement) { + // Prevent multiple simultaneous reloads + if (window.isReloadingConfig) { + //console.log('Config reload already in progress, ignoring...'); + return; + } + + const installMode = selectElement.value; + //console.log(`Install mode changed to: ${installMode}`); + + // Set flag to prevent multiple reloads + window.isReloadingConfig = true; + + // Use a longer delay and onchange instead of onblur to avoid the loop + setTimeout(() => { + if (window.configManager) { + // Clear cache to ensure we get fresh data with updated CFG_INSTALL_MODE + window.configManager.cache.clear(); + //console.log('Cache cleared for fresh config data'); + + // Get the current category from the URL or default to 'general' + const currentCategory = window.configCategory || 'general'; + //console.log(`Reloading config category: ${currentCategory}`); + window.configManager.renderConfig(currentCategory).finally(() => { + // Clear the flag after reload is complete + setTimeout(() => { + window.isReloadingConfig = false; + }, 500); // Extra delay to ensure DOM is fully settled + }); + } else if (window.configRouter) { + // Fallback to configRouter if configManager is not available + const currentCategory = window.configCategory || 'general'; + //console.log(`Using configRouter to reload: ${currentCategory}`); + window.configRouter.loadConfigComponentManual(currentCategory).finally(() => { + // Clear the flag after reload is complete + setTimeout(() => { + window.isReloadingConfig = false; + }, 500); // Extra delay to ensure DOM is fully settled + }); + } else { + console.warn('Neither configManager nor configRouter available for install mode change'); + window.isReloadingConfig = false; + } + }, 500); // Longer delay to avoid conflicts +}; + +// Global function to initialize git field visibility based on current CFG_INSTALL_MODE +window.initializeGitFieldVisibility = function() { + const installModeSelect = document.querySelector('select[name="CFG_INSTALL_MODE"]'); + if (installModeSelect) { + // Trigger the change handler to set initial visibility + handleInstallModeChange(installModeSelect); + } +}; + +// Global password toggle function for onclick handlers +window.togglePasswordVisibility = function(fieldId) { + ConfigShared.togglePasswordVisibility(fieldId); +}; diff --git a/containers/libreportal/frontend/js/components/config/config-sidebar.js b/containers/libreportal/frontend/js/components/config/config-sidebar.js new file mode 100755 index 0000000..9da7d3c --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-sidebar.js @@ -0,0 +1,96 @@ +// Config Sidebar - Handles sidebar population and navigation +class ConfigSidebar { + constructor() { + this.categoriesList = null; + } + + populateSidebar() { + //console.log('ConfigSidebar: Populating sidebar with categories...'); + + this.categoriesList = document.getElementById('config-categories-list'); + if (!this.categoriesList) { + console.error('ConfigSidebar: config-categories-list element not found'); + return; + } + + if (!window.configData || !window.configData.categories) { + console.error('ConfigSidebar: No config data available for sidebar'); + return; + } + + this.categoriesList.innerHTML = ''; + + // Convert categories object to array and sort by ORDER + const categoriesArray = Object.entries(window.configData.categories).map(([key, value]) => ({ + id: key, + ...value + })); + + // Sort by ORDER if available, otherwise by title + categoriesArray.sort(function(a, b) { + const orderA = parseInt(a.order) || 999; + const orderB = parseInt(b.order) || 999; + return orderA - orderB; + }); + + var self = this; // Preserve 'this' context + + categoriesArray.forEach(function(category) { + // Backup category has its own top-level page (/backup) which renders + // these same fields dynamically — hide it from the /config sidebar to + // avoid two surfaces for the same data. + if (category.id === 'backup') return; + + const categoryItem = document.createElement('div'); + categoryItem.className = 'category'; + categoryItem.setAttribute('data-category', category.id); + + // Use correct icon from our new structure + const iconName = category.icon || category.id; + const iconPath = '/icons/config/' + iconName + '.svg'; + + categoryItem.innerHTML = '' + category.title + ' ' + category.title; + + categoryItem.addEventListener('click', function() { + // Update URL without full page reload + const url = '/config?=' + category.id; + window.history.pushState({}, '', url); + + // Update active state + document.querySelectorAll('.category').forEach(function(item) { + item.classList.remove('active'); + }); + this.classList.add('active'); + + // Update global category and load dynamically + window.configCategory = category.id; + + // Load config dynamically without page refresh + if (window.configManager && typeof window.configManager.renderConfig === 'function') { + window.configManager.renderConfig(category.id); + } + }); + + self.categoriesList.appendChild(categoryItem); + }); + + // Set initial active category + this.setActiveCategory(window.configCategory || 'general'); + + //console.log('ConfigSidebar: Sidebar populated with ' + categoriesArray.length + ' categories'); + } + + setActiveCategory(categoryId) { + // Update active state + document.querySelectorAll('.category').forEach(function(item) { + item.classList.remove('active'); + }); + var activeItem = document.querySelector('[data-category="' + categoryId + '"]'); + if (activeItem) { + activeItem.classList.add('active'); + } + } +} + +// Export to global scope +window.ConfigSidebar = ConfigSidebar; diff --git a/containers/libreportal/frontend/js/components/config/config-utils.js b/containers/libreportal/frontend/js/components/config/config-utils.js new file mode 100755 index 0000000..91223cc --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-utils.js @@ -0,0 +1,111 @@ +// Config Utils - Utility functions for configuration management +class ConfigUtils { + constructor() { + // No initialization needed + } + + formatSubcategoryName(subcategoryName) { + return subcategoryName.replace(/_/g, ' ').replace(/\b\w/g, function(l) { return l.toUpperCase(); }); + } + + cleanDescription(description) { + return description + .replace(/\*\*ADVANCED\*\*/g, '') + .replace(/\*\*UNUSED\*\*/g, '') + .replace(/^\s+|\s+$/g, '') // Trim whitespace + .replace(/\s{2,}/g, ' '); // Replace multiple spaces with single space + } + + filterSubcategoriesByType(configData, category) { + // Filter subcategories by category and separate into regular, advanced, and unused + var regularSubcategories = []; + var advancedSubcategories = []; + var unusedSubcategories = []; + + for (const [subcategoryName, subcategoryData] of Object.entries(configData)) { + if (subcategoryData.category === category) { + if (subcategoryData.description.includes('**ADVANCED**')) { + advancedSubcategories.push(subcategoryName); + } else if (subcategoryData.description.includes('**UNUSED**')) { + unusedSubcategories.push(subcategoryName); + } else { + regularSubcategories.push(subcategoryName); + } + } + } + + return { + regular: regularSubcategories, + advanced: advancedSubcategories, + unused: unusedSubcategories + }; + } + + async renderSectionedContent(formHTML, advancedSubcategories, unusedSubcategories, self, category, configData) { + // Add danger zone toggle controls if needed + if (advancedSubcategories.length > 0 || unusedSubcategories.length > 0) { + formHTML += this.generateToggleControls(advancedSubcategories.length > 0, unusedSubcategories.length > 0); + } + + // Render advanced sections (hidden by default). The old grouping + // header ("🛠️ Advanced Configuration") is gone — each subcategory + // self-identifies via the red "Advanced" badge on its title (see + // .is-advanced .domains-header h3::after in config.css). + if (advancedSubcategories.length > 0) { + formHTML += ''; + } + + // Render unused sections (hidden by default) + if (unusedSubcategories.length > 0) { + formHTML += ''; + } + + return formHTML; + } + + generateToggleControls(hasAdvanced = false, hasUnused = false) { + if (!hasAdvanced && !hasUnused) { + return ''; // Don't show danger zone if no content + } + + let formHTML = '

    ⚠️ Danger Zone

    These options are for advanced users and may affect system stability

    '; + + if (hasAdvanced) { + formHTML += '
    '; + } + + if (hasUnused) { + formHTML += '
    '; + } + + formHTML += '
    '; + + return formHTML; + } +} + +// Export to global scope +window.ConfigUtils = ConfigUtils; diff --git a/containers/libreportal/frontend/js/components/config/config-validator.js b/containers/libreportal/frontend/js/components/config/config-validator.js new file mode 100755 index 0000000..5f1510c --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/config-validator.js @@ -0,0 +1,248 @@ +// Config Validation System for LibrePortal Web UI +// Validates JSON config files before loading the main interface + +// Global validator instance +window.ConfigValidator = function() { + let validationResults = null; + let hasValidated = false; + + // Validate all config files + this.validateAllConfigs = async function() { + // Use client-side validation directly + return this.fallbackValidation(); + }; + + // Fallback client-side validation + this.fallbackValidation = async function() { + const results = { + valid: true, + errors: [], + warnings: [], + suggestions: [] + }; + + // Check if unified config file exists (file existence check only) + const configFiles = [ + { name: 'Unified System Config', path: 'data/config/generated/configs.json' } + ]; + + for (const config of configFiles) { + try { + const response = await fetch(config.path, { method: 'HEAD' }); + if (!response.ok) { + results.valid = false; + results.errors.push(`Config file '${config.name}' not found: ${config.path}`); + continue; + } + + //console.log(`Config file exists: ${config.name}`); + + } catch (error) { + results.valid = false; + results.errors.push(`Failed to check ${config.name}: ${error.message}`); + } + } + + // Add suggestions if there are issues + if (!results.valid) { + results.suggestions = [ + "Run 'libreportal run' to fix configuration issues", + "Check config file permissions and ownership", + "Ensure Docker is running and accessible" + ]; + } + + validationResults = results; + hasValidated = true; + return results; + }; + + // Show validation error message + this.showValidationError = function() { + if (!hasValidated) { + return; + } + + if (validationResults.valid) { + return; // No error to show + } + + // Create error overlay + const errorOverlay = document.createElement('div'); + errorOverlay.className = 'config-validation-overlay'; + errorOverlay.innerHTML = ` +
    +
    +

    ⚠️ Configuration Issues Detected

    + +
    +
    +
    +

    Errors:

    +
      + ${validationResults.errors.map(error => `
    • ${this.escapeHtml(error)}
    • `).join('')} +
    +
    + ${validationResults.warnings.length > 0 ? ` +
    +

    Warnings:

    +
      + ${validationResults.warnings.map(warning => `
    • ${this.escapeHtml(warning)}
    • `).join('')} +
    +
    + ` : ''} +
    +

    Suggestions:

    +
      + ${validationResults.suggestions.map(suggestion => `
    • ${this.escapeHtml(suggestion)}
    • `).join('')} +
    +
    +
    + + +
    +
    +
    + `; + + // Add styles + errorOverlay.innerHTML += ` + + `; + + document.body.appendChild(errorOverlay); + }; + + // Escape HTML to prevent XSS + this.escapeHtml = function(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + // Get validation status + this.isValid = function() { + return hasValidated && validationResults.valid; + }; + + // Get validation errors + this.getErrors = function() { + return hasValidated ? validationResults.errors : []; + }; + + // Get validation warnings + this.getWarnings = function() { + return hasValidated ? validationResults.warnings : []; + }; +}; diff --git a/containers/libreportal/frontend/js/components/config/domain-manager.js b/containers/libreportal/frontend/js/components/config/domain-manager.js new file mode 100755 index 0000000..189b9a3 --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/domain-manager.js @@ -0,0 +1,646 @@ +// Domain Management - Handles domain-related functionality +class DomainManager { + constructor() { + this.domains = []; + } + + async checkTraefikInstallation() { + try { + const response = await fetch('/api/traefik/status'); + if (response.ok) { + const data = await response.json(); + return data.installed || false; + } + return false; + } catch (error) { + //console.log('DomainManager: Traefik status check failed, assuming not installed:', error.message); + return false; + } + } + + async renderDomainsSection(configItems, displaySubcategory, subcategoryDescription) { + const traefikStatus = await this.checkTraefikInstallation(); + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    + `; + + if (!traefikStatus.installed) { + html += ` +
    +
    + ⚠️ +
    + Traefik Not Installed - Domain settings won't be applied until Traefik is installed. You can configure domains now and install Traefik later. +
    +
    +
    + `; + } + + html += `
    `; + + // Only show domains that have content (non-empty values) + const allDomainKeys = configItems.map(item => item.key).filter(key => key.startsWith('CFG_DOMAIN_')); + const domainKeysWithContent = allDomainKeys.filter(key => { + const configItem = configItems.find(item => item.key === key) || {}; + const value = configItem.value || ''; + return value.trim() !== ''; + }); + + // Check if we've reached the maximum of 9 domains (count only existing domains, not empty slots) + const isMaxDomains = domainKeysWithContent.length >= 9; + + domainKeysWithContent.forEach(key => { + const configItem = configItems.find(item => item.key === key) || {}; + const value = configItem.value || ''; + const title = configItem.title || this.formatConfigLabel(key); + const fieldId = `config-${key}`; + + // Extract domain number + const domainNum = parseInt(key.match(/CFG_DOMAIN_(\d+)/)[1]); + const isHighestDomain = domainNum === Math.max(...domainKeysWithContent.map(k => + parseInt(k.match(/CFG_DOMAIN_(\d+)/)[1]) + )); + + // Domain 1 can never be deleted, and only highest numbered domain WITH CONTENT can be deleted + const canDelete = isHighestDomain && domainNum !== 1; + + html += ` +
    +
    + ${this.generateField(fieldId, key, value, title, '', { + placeholder: 'example.com', + className: 'domain-input' + })} + +
    +
    +
    + `; + }); + + setTimeout(() => this.attachDnsChecks(), 0); + + html += ` +
    +
    + +
    +
    +
    +
    + `; + + return html; + } + + attachDnsChecks() { + const inputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + inputs.forEach((input) => { + if (input.dataset.dnsBound === '1') return; + input.dataset.dnsBound = '1'; + const status = document.querySelector(`[data-dns-for="${input.id}"]`); + if (!status) return; + let timer = null; + let ctrl = null; + const run = () => { + if (timer) clearTimeout(timer); + if (ctrl) ctrl.abort(); + const domain = input.value.trim().toLowerCase(); + if (!domain) { status.textContent = ''; status.className = 'setup-dns-status'; return; } + if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(domain)) { + status.textContent = ''; status.className = 'setup-dns-status'; + return; + } + status.textContent = 'Checking DNS…'; + status.className = 'setup-dns-status checking'; + timer = setTimeout(async () => { + ctrl = new AbortController(); + try { + const res = await fetch(`/api/setup/dns-check?domain=${encodeURIComponent(domain)}`, { signal: ctrl.signal }); + const data = await res.json(); + if (data.matches) { + status.textContent = `✓ ${domain} → ${data.server_ip} (this server)`; + status.className = 'setup-dns-status ok'; + } else { + const detail = data.domain_ip + ? `points to ${data.domain_ip}, this server is ${data.server_ip}` + : `does not resolve (server: ${data.server_ip || 'unknown'})`; + status.textContent = `⚠ ${domain} ${detail}. Traefik may not route this yet.`; + status.className = 'setup-dns-status warn'; + } + } catch (err) { + if (err.name !== 'AbortError') { + status.textContent = 'DNS check failed.'; + status.className = 'setup-dns-status warn'; + } + } + }, 500); + }; + input.addEventListener('input', run); + input.addEventListener('blur', run); + if (input.value) run(); + }); + } + + generateField(fieldId, key, value, title, description, options = {}) { + // Use the same field generation as ConfigShared + if (typeof ConfigShared !== 'undefined') { + return ConfigShared.generateField(fieldId, key, value, title, description, options); + } + + // Fallback field generation + const placeholder = key.startsWith('CFG_DOMAIN_') ? 'example.com' : 'https://example.com'; + return ` + + `; + } + + formatConfigLabel(key) { + // Special handling for domain configuration + if (key.startsWith('CFG_DOMAIN_')) { + const domainNum = key.replace('CFG_DOMAIN_', ''); + return `Domain ${domainNum}`; + } + + // Use ConfigShared if available + if (typeof ConfigShared !== 'undefined') { + return ConfigShared.formatConfigLabel(key); + } + + // Fallback formatting + return key.replace(/^CFG_/, '').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase()); + } + + addNewDomain() { + //console.log('Add Domain button clicked!'); + + try { + // Find the highest existing domain number + const domainInputs = document.querySelectorAll('input[name^="CFG_DOMAIN_"]'); + const domainNumbers = []; + + domainInputs.forEach(input => { + const match = input.name.match(/CFG_DOMAIN_(\d+)/); + if (match) { + domainNumbers.push(parseInt(match[1])); + } + }); + + // Check if we've reached the maximum of 9 domains (count only existing domains, not empty slots) + if (domainNumbers.length >= 9) { + //console.log('Maximum of 9 domains reached'); + return; + } + + const nextDomainNumber = domainNumbers.length > 0 ? Math.max(...domainNumbers) + 1 : 1; + const newDomainKey = `CFG_DOMAIN_${nextDomainNumber}`; + const newFieldId = `config-${newDomainKey}`; + + // Create new domain building block HTML + const newDomainHTML = ` +
    +
    + ${this.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', { placeholder: 'example.com' })} + +
    +
    +
    + `; + + // Find the domain-building-blocks container and add the new block + const domainContainer = document.querySelector('.domain-building-blocks'); + if (domainContainer) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newDomainHTML; + const newBlock = tempDiv.firstElementChild; + domainContainer.appendChild(newBlock); + + // Focus on the new input field + const newInput = newBlock.querySelector('input'); + if (newInput) { + newInput.focus(); + } + + // Update add domain button state + this.updateDomainDeleteButtons(); + this.attachDnsChecks(); + } + } catch (error) { + console.error('Error adding new domain:', error); + } + } + + deleteDomain(domainKey, buttonElement) { + if (typeof window.DomainManager === 'undefined' || !window.DomainManager.deleteDomain) { + console.error('DomainManager not available for deleteDomain'); + return; + } + //console.log(`Delete domain button clicked for: ${domainKey}`); + + try { + // Find the domain-building-block and remove it + const domainBlock = buttonElement.closest('.domain-building-block'); + if (domainBlock) { + // Clear the input value first + const input = domainBlock.querySelector('input'); + if (input) { + input.value = ''; + } + // Remove the entire building block + domainBlock.remove(); + + // Update delete button states + this.updateDomainDeleteButtons(); + } + } catch (error) { + console.error('Error deleting domain:', error); + } + } + + updateDomainDeleteButtons() { + // Find all domain building blocks + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + + // Find all domain numbers from existing blocks + const allDomainNumbers = []; + allDomainBlocks.forEach(block => { + const input = block.querySelector('input'); + if (input) { + const inputName = input.name; + const match = inputName.match(/CFG_DOMAIN_(\d+)/); + if (match) { + allDomainNumbers.push(parseInt(match[1])); + } + } + }); + + // Update delete button states (only highest numbered domain can be deleted, but NEVER Domain 1) + allDomainBlocks.forEach((block, index) => { + const deleteBtn = block.querySelector('.delete-domain-btn'); + if (deleteBtn) { + const input = block.querySelector('input'); + if (input) { + const inputName = input.name; + const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/); + const domainNumber = domainNum ? parseInt(domainNum[1]) : 0; + + // Find the highest domain number among all visible blocks + const highestDomainNumber = Math.max(...allDomainNumbers); + + // Only highest numbered domain can be deleted, but NEVER Domain 1 + const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1; + + deleteBtn.disabled = !canDelete; + deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`; + deleteBtn.title = canDelete ? 'Delete domain' : domainNumber === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain'; + } + } + }); + } +} + +// Standalone domain management functions - immediately available +window.addDomain = function() { + //console.log('Add Domain button clicked!'); + + try { + // Before adding new domain, validate that all existing domains have valid format + if (!canAddNewDomain()) { + // Find the first invalid domain and focus it with flash + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + for (const input of allDomainInputs) { + if (!validateDomainFormat(input, false)) { // Don't show notification here + input.style.animation = 'flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + return; + } + } + return; + } + + // Find the highest existing domain number + const domainInputs = document.querySelectorAll('input[name^="CFG_DOMAIN_"]'); + const domainNumbers = []; + + domainInputs.forEach(input => { + const match = input.name.match(/CFG_DOMAIN_(\d+)/); + if (match) { + domainNumbers.push(parseInt(match[1])); + } + }); + + // Check if we've reached the maximum of 9 domains + if (domainNumbers.length >= 9) { + //console.log('Maximum of 9 domains reached'); + return; + } + + const nextDomainNumber = domainNumbers.length > 0 ? Math.max(...domainNumbers) + 1 : 1; + const newDomainKey = `CFG_DOMAIN_${nextDomainNumber}`; + const newFieldId = `config-${newDomainKey}`; + + // Create new domain building block HTML + const newDomainHTML = ` +
    +
    + ${typeof ConfigShared !== 'undefined' ? ConfigShared.generateField(newFieldId, newDomainKey, '', `Domain ${nextDomainNumber}`, '', { + placeholder: 'example.com', + className: 'domain-input', + onchange: 'validateDomainFormat(this, true)', + oninput: 'validateDomainFormat(this, true)', + onblur: 'validateDomainFormat(this, true)' + }) : ``} + +
    +
    + `; + + // Find the domain-building-blocks container and add the new block + const domainContainer = document.querySelector('.domain-building-blocks'); + if (domainContainer) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newDomainHTML; + const newBlock = tempDiv.firstElementChild; + domainContainer.appendChild(newBlock); + + // Focus on the new input field + const newInput = newBlock.querySelector('input'); + if (newInput) { + newInput.focus(); + } + + // Update add domain button state + updateDomainDeleteButtons(); + updateAddDomainButton(); + } + } catch (error) { + console.error('Error adding new domain:', error); + } +}; + +window.deleteDomain = function(domainKey, buttonElement) { + //console.log(`Delete domain button clicked for: ${domainKey}`); + + try { + // Find the domain-building-block and remove it + const domainBlock = buttonElement.closest('.domain-building-block'); + if (domainBlock) { + // Clear the input value first + const input = domainBlock.querySelector('input'); + if (input) { + input.value = ''; + } + // Remove the entire building block + domainBlock.remove(); + + // Update add domain button state (re-enable if we're below 9 domains) + updateDomainDeleteButtons(); + updateAddDomainButton(); + } + } catch (error) { + console.error('Error deleting domain:', error); + } +}; + +// Helper function to update delete button states +function updateDomainDeleteButtons() { + // Find all domain building blocks + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + + // Find all domain numbers from existing blocks + const allDomainNumbers = []; + allDomainBlocks.forEach(block => { + const input = block.querySelector('input'); + if (input) { + const inputName = input.name; + const match = inputName.match(/CFG_DOMAIN_(\d+)/); + if (match) { + allDomainNumbers.push(parseInt(match[1])); + } + } + }); + + // Update delete button states (only highest numbered domain can be deleted, but NEVER Domain 1) + allDomainBlocks.forEach((block, index) => { + const deleteBtn = block.querySelector('.delete-domain-btn'); + if (deleteBtn) { + const input = block.querySelector('input'); + if (input) { + const inputName = input.name; + const domainNum = inputName.match(/CFG_DOMAIN_(\d+)/); + const domainNumber = domainNum ? parseInt(domainNum[1]) : 0; + + // Find the highest domain number among all visible blocks + const highestDomainNumber = Math.max(...allDomainNumbers); + + // Only highest numbered domain can be deleted, but NEVER Domain 1 + const canDelete = domainNumber === highestDomainNumber && domainNumber !== 1; + + deleteBtn.disabled = !canDelete; + deleteBtn.className = `delete-domain-btn ${!canDelete ? 'disabled' : ''}`; + deleteBtn.title = canDelete ? 'Delete domain' : domainNumber === 1 ? 'Domain 1 cannot be deleted' : 'Can only delete highest numbered domain'; + } + } + }); +} + +// Helper function to update add domain button state +function updateAddDomainButton() { + const addDomainBtn = document.getElementById('add-domain-btn'); + if (addDomainBtn) { + // Find all domain building blocks + const allDomainBlocks = document.querySelectorAll('.domain-building-block'); + + // Find all domain numbers from existing blocks + const allDomainNumbers = []; + allDomainBlocks.forEach(block => { + const input = block.querySelector('input'); + if (input) { + const inputName = input.name; + const match = inputName.match(/CFG_DOMAIN_(\d+)/); + if (match) { + allDomainNumbers.push(parseInt(match[1])); + } + } + }); + + // Check if we've reached the maximum of 9 domains + const isMaxDomains = allDomainNumbers.length >= 9; + + // Update button state + addDomainBtn.disabled = isMaxDomains; + addDomainBtn.className = `btn ${isMaxDomains ? 'btn-secondary' : 'btn-primary'}`; + addDomainBtn.innerHTML = `${isMaxDomains ? '✓' : '+'}${isMaxDomains ? 'Maximum Domains Reached' : 'Add Domain'}`; + } +} + +// Validate domain format when user tries to add a new domain +function validateDomainFormat(input, showNotifications = true) { + const value = input.value.trim(); + //console.log('validateDomainFormat called with:', value, 'showNotifications:', showNotifications); + //console.log('window.notificationSystem available:', !!window.notificationSystem); + + if (!value) { + // Clear styling for empty fields + input.style.borderColor = '#dc3545'; + input.title = 'Domain cannot be empty'; + if (showNotifications && window.notificationSystem) { + //console.log('Attempting to show empty domain notification'); + window.notificationSystem.error('Domain cannot be empty'); + } else { + console.error('Domain cannot be empty - notification system not available'); + } + return false; // Empty fields are not valid for adding new domains + } + + // Basic domain validation regex - supports subdomains and multiple TLD levels + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + const isValidFormat = domainRegex.test(value); + + // Check for duplicates + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + const duplicates = Array.from(allDomainInputs).filter(otherInput => + otherInput !== input && otherInput.value.trim() === value + ); + const hasDuplicate = duplicates.length > 0; + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid domain format (e.g., example.com)'; + if (showNotifications && window.notificationSystem) { + //console.log('Attempting to show invalid format notification'); + window.notificationSystem.error('Invalid domain format: "' + value + '". Please use a valid domain like example.com'); + } else { + console.error('Invalid domain format - notification system not available'); + } + return false; + } else if (hasDuplicate) { + input.style.borderColor = '#dc3545'; + input.title = 'Domain already exists'; + if (showNotifications && window.notificationSystem) { + //console.log('Attempting to show duplicate notification'); + window.notificationSystem.error('Domain "' + value + '" already exists'); + } else { + console.error('Domain already exists - notification system not available'); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } +} + +// Check if all domains are valid before allowing new domain addition +function canAddNewDomain() { + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + for (const input of allDomainInputs) { + const domainValue = input.value.trim(); + + // Check if domain is empty + if (!domainValue) { + // Flash the empty input field + input.style.animation = 'flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification with safety check + if (window.notificationSystem) { + window.notificationSystem.error('Domain cannot be empty'); + } else { + console.error('Domain cannot be empty'); + } + return false; // Empty domain - don't allow adding new domain + } + + // Check for duplicates with notification (always show) + if (checkForDuplicateDomain(input, domainValue)) { + return false; + } + + // Check domain format with notification (always show) + if (checkForInvalidDomainFormat(input, domainValue)) { + return false; + } + } + return true; // All domains are valid and non-empty +} + +// Separate function to check for invalid format that always shows notifications +function checkForInvalidDomainFormat(input, domainValue) { + if (!domainValue) return false; + + // Basic domain validation regex - supports subdomains and multiple TLD levels + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + const isValidFormat = domainRegex.test(domainValue); + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid domain format (e.g., example.com)'; + if (window.notificationSystem) { + //console.log('Showing invalid domain format notification'); + window.notificationSystem.error('Invalid domain format: "' + domainValue + '". Please use a valid domain like example.com'); + } else { + console.error('Invalid domain format - notification system not available'); + } + return true; + } + + return false; +} + +// Separate function to check for duplicates that always shows notifications +function checkForDuplicateDomain(input, domainValue) { + if (!domainValue) return false; + + const allDomainInputs = document.querySelectorAll('input[id^="config-CFG_DOMAIN_"]'); + const duplicates = Array.from(allDomainInputs).filter(otherInput => + otherInput !== input && otherInput.value.trim() === domainValue + ); + const hasDuplicate = duplicates.length > 0; + + if (hasDuplicate) { + input.style.borderColor = '#dc3545'; + input.title = 'Domain already exists'; + if (window.notificationSystem) { + //console.log('Showing duplicate domain notification'); + window.notificationSystem.error('Domain "' + domainValue + '" already exists'); + } else { + console.error('Domain already exists - notification system not available'); + } + return true; + } + + return false; +} diff --git a/containers/libreportal/frontend/js/components/config/ip-whitelist-manager.js b/containers/libreportal/frontend/js/components/config/ip-whitelist-manager.js new file mode 100755 index 0000000..a0ced79 --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/ip-whitelist-manager.js @@ -0,0 +1,952 @@ +/** + * IP Whitelist Manager for LibrePortal + * Handles multiple IP whitelist fields with validation and management + */ + +class IPWhitelistManager { + constructor() { + this.maxWhitelistEntries = 20; // Maximum number of whitelist entries + } + + // Render IP whitelist section with add/delete functionality + async renderWhitelistSection(configItems, displaySubcategory, subcategoryDescription) { + try { + // Check if Traefik is installed + const traefikStatus = await this.checkTraefikInstallation(); + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    + + +
    + `; + + // Extract existing whitelist entries from config + const whitelistEntries = this.extractWhitelistEntries(configItems); + + // Get the original CFG_IPS_WHITELIST value for the hidden input + const whitelistItem = configItems.find(item => item.key === 'CFG_IPS_WHITELIST'); + const originalValue = whitelistItem ? whitelistItem.value : ''; + + if (whitelistEntries.length === 0) { + // Show empty state with add button + html += ` +
    +

    No IP whitelist entries configured. Add IP addresses, domains, or localhost to allow access to specific Traefik apps.

    +
    + +
    +
    + `; + } else { + // Render existing whitelist entries + whitelistEntries.forEach((entry, index) => { + const entryId = `config-CFG_IPS_WHITELIST_${index + 1}`; + const entryTitle = `Whitelist Entry ${index + 1}`; + + html += ` +
    +
    + ${this.generateField(entryId, `CFG_IPS_WHITELIST`, entry.value, entryTitle, '', { + placeholder: '192.168.1.100, example.com, or localhost', + className: 'whitelist-input', + onblur: 'window.validateWhitelistEntry(this, true)' + })} + +
    +
    + `; + }); + + // Add button for new entries + const isMaxEntries = whitelistEntries.length >= this.maxWhitelistEntries; + html += ` +
    +
    + +
    + `; + } + + html += ` +
    +
    +
    +
    + `; + + // After rendering, set the hidden input value and update button states + setTimeout(() => { + const hiddenInput = document.getElementById('config-CFG_IPS_WHITELIST'); + if (hiddenInput) { + hiddenInput.value = originalValue; + } + + // Update delete button states + this.updateWhitelistDeleteButtons(); + }, 100); + + return html; + } catch (error) { + console.error('Error rendering whitelist section:', error); + return '

    Error loading whitelist configuration.

    '; + } + } + + // Extract whitelist entries from config items + extractWhitelistEntries(configItems) { + const whitelistItem = configItems.find(item => item.key === 'CFG_IPS_WHITELIST'); + if (!whitelistItem || !whitelistItem.value) { + return []; + } + + // Split comma-separated values and filter out empty ones + const entries = whitelistItem.value + .split(',') + .map(entry => entry.trim()) + .filter(entry => entry.length > 0 && entry !== 'HOSTIPHERE'); + + return entries.map((entry, index) => ({ + key: `CFG_IPS_WHITELIST_${index + 1}`, // For field naming only + value: entry, + originalIndex: index // Keep track of original position + })); + } + + // Save whitelist entries back to single CFG_IPS_WHITELIST config + saveWhitelistEntries() { + try { + // Get only the visible whitelist input values (exclude hidden input) + const whitelistInputs = document.querySelectorAll('input[name="CFG_IPS_WHITELIST"]:not([type="hidden"])'); + //console.log('saveWhitelistEntries: Found inputs:', whitelistInputs.length); + whitelistInputs.forEach((input, index) => { + //console.log(` Input ${index}: id="${input.id}", value="${input.value}"`); + }); + + const values = Array.from(whitelistInputs) + .map(input => input.value.trim()) + .filter(value => value.length > 0); // Filter out empty values + + //console.log('saveWhitelistEntries: Filtered values:', values); + + // Find the hidden CFG_IPS_WHITELIST input and update it + const hiddenInput = document.querySelector('input[name="CFG_IPS_WHITELIST"][type="hidden"]'); + if (hiddenInput) { + hiddenInput.value = values.join(', '); + //console.log('Saved whitelist values:', values.join(', ')); + } + } catch (error) { + console.error('Error saving whitelist entries:', error); + } + } + + // Validate individual whitelist entry + validateWhitelistEntry(input, showNotifications = true) { + //console.log('validateWhitelistEntry called with:', input.value, 'showNotifications:', showNotifications); + + const value = input.value.trim(); + + // Clear previous validation styling + input.style.borderColor = ''; + input.style.animation = ''; + input.title = ''; + + if (!value) { + //console.log('Empty value - allowing for now'); + // Empty is allowed for now (validation happens on add) + return true; + } + + // Special case for localhost + if (value.toLowerCase() === 'localhost') { + //console.log('localhost detected - valid'); + return true; + } + + // Check for valid IP address or domain format + const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/; + // Fixed domain pattern - requires at least one dot and proper TLD + const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; + + const isValidIP = ipPattern.test(value) || ipv6Pattern.test(value); + const isValidDomain = domainPattern.test(value); + const isValidFormat = isValidIP || isValidDomain || value.toLowerCase() === 'localhost'; + + //console.log('Format validation:', { + //value: value, + //isValidIP: isValidIP, + //isValidDomain: isValidDomain, + //isValidFormat: isValidFormat + //}); + + if (!isValidFormat) { + //console.log('Invalid format detected - showing error'); + // Flash the input field + input.style.animation = 'whitelist-flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification + if (showNotifications && window.notificationSystem) { + //console.log('Showing notification for invalid format'); + window.notificationSystem.error(`Invalid whitelist format: "${value}". Please use a valid IP address, domain (e.g., example.com), or localhost`); + } else { + //console.log('Notification system not available or showNotifications is false'); + } + + input.title = 'Invalid IP address, domain, or localhost format (e.g., 192.168.1.100, example.com, or localhost)'; + return false; // IMPORTANT: Return false for invalid format + } + + // Check for duplicates + const allWhitelistInputs = document.querySelectorAll('input[name="CFG_IPS_WHITELIST"]:not([type="hidden"])'); + //console.log('Checking for duplicates among', allWhitelistInputs.length, 'inputs'); + + const duplicates = []; + + allWhitelistInputs.forEach(otherInput => { + if (otherInput !== input) { + const otherValue = otherInput.value.trim(); + if (otherValue.toLowerCase() === value.toLowerCase()) { + duplicates.push(otherValue); + } + } + }); + + //console.log('Found duplicates:', duplicates); + + if (duplicates.length > 0) { + //console.log('Duplicate detected - showing error'); + // Flash the input field + input.style.animation = 'whitelist-flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification + if (showNotifications && window.notificationSystem) { + //console.log('Showing notification for duplicate'); + window.notificationSystem.error(`"${value}" already exists in the whitelist`); + } + + input.title = 'This entry already exists in the whitelist'; + return false; // IMPORTANT: Return false for duplicates + } + + // Valid entry - save it + //console.log('Valid entry - saving'); + this.saveWhitelistEntries(); + return true; + } + + // Generate field HTML (similar to ConfigShared.generateField) + generateField(fieldId, fieldName, value, title, description, options = {}) { + const defaultOptions = { + type: 'text', + placeholder: '', + className: '', + required: false + }; + + const fieldOptions = { ...defaultOptions, ...options }; + + return ` +
    + + + ${description ? `${description}` : ''} +
    + `; + } + + // Check if Traefik is installed + async checkTraefikInstallation() { + try { + if (typeof DataLoader !== 'undefined') { + return await DataLoader.isAppInstalled('traefik'); + } + return false; + } catch (error) { + //console.log('Traefik check failed:', error.message); + return false; + } + } + + // Add new whitelist entry + addWhitelistEntry() { + //console.log('Add whitelist entry button clicked!'); + + try { + // Before adding new entry, validate that all existing entries have valid format + //console.log('About to call validateBeforeAddWhitelist()'); + + // Test if the function exists + if (typeof this.validateBeforeAddWhitelist !== 'function') { + console.error('validateBeforeAddWhitelist function does not exist!'); + return; + } + + const canAdd = this.validateBeforeAddWhitelist(); + //console.log('validateBeforeAddWhitelist() returned:', canAdd); + + if (!canAdd) { + //console.log('Validation failed - not adding new entry'); + return; // Validation failed, don't add new entry + } + + //console.log('Validation passed - proceeding with add'); + + // Find the hidden CFG_IPS_WHITELIST input + const hiddenInput = document.querySelector('input[name="CFG_IPS_WHITELIST"]'); + if (!hiddenInput) { + console.error('Hidden CFG_IPS_WHITELIST input not found!'); + return; + } + + // Get current values and split them + const currentValues = hiddenInput.value + .split(',') + .map(entry => entry.trim()) + .filter(entry => entry.length > 0); // Don't filter out empty strings for counting + + //console.log('Current values:', currentValues); + //console.log('Current values length:', currentValues.length); + + // Check if we've reached the maximum + if (currentValues.length >= this.maxWhitelistEntries) { + //console.log('Maximum of ' + this.maxWhitelistEntries + ' whitelist entries reached'); + return; + } + + // Add a new empty entry + // Find the highest existing whitelist number from the DOM (like domain manager) + const allBlocks = document.querySelectorAll('.whitelist-building-block'); + const allNumbers = []; + allBlocks.forEach(block => { + const input = block.querySelector('input'); + if (input) { + const inputId = input.id; + const match = inputId.match(/config-CFG_IPS_WHITELIST_(\d+)/); + if (match) { + allNumbers.push(parseInt(match[1])); + } + } + }); + + const highestNumber = allNumbers.length > 0 ? Math.max(...allNumbers) : 0; + const newEntryNumber = highestNumber + 1; + + //console.log('Highest existing number:', highestNumber, 'New entry number:', newEntryNumber); + + currentValues.push(''); + + // Update the hidden input + hiddenInput.value = currentValues.join(', '); + //console.log('Updated hidden input to:', hiddenInput.value); + + // Create new entry HTML and add to DOM + const newEntryHTML = ` +
    +
    +
    + + +
    + +
    +
    + `; + + // Add the new entry to the DOM + const whitelistContainer = document.querySelector('.whitelist-building-blocks'); + //console.log('Step 1: Found whitelist container:', !!whitelistContainer); + + if (whitelistContainer) { + // Remove empty state if it exists + const emptyState = whitelistContainer.querySelector('.whitelist-empty-state'); + //console.log('Step 2: Found empty state:', !!emptyState); + if (emptyState) { + emptyState.remove(); + //console.log('Step 3: Removed empty state'); + } + + // Add new entry + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newEntryHTML; + const newBlock = tempDiv.firstElementChild; + //console.log('Step 4: Created new block:', !!newBlock); + + whitelistContainer.appendChild(newBlock); + //console.log('Step 5: Added new block to container'); + + // Focus on the new input + const newInput = newBlock.querySelector('input'); + //console.log('Step 6: Found new input:', !!newInput); + if (newInput) { + newInput.focus(); + //console.log('Step 7: Focused new input'); + } + + // Update add button state + this.updateAddEntryButton(); + //console.log('Step 8: Updated add button state'); + + //console.log('Step 9: Add function completed successfully'); + } else { + //console.log('Step 1: Whitelist container not found!'); + } + + } catch (error) { + console.error('Error adding new whitelist entry:', error); + } + } + + // Check if all entries are valid before allowing new entry addition + validateBeforeAddWhitelist() { + //console.log('=== validateBeforeAddWhitelist START ==='); + + // Simple test - just check for empty entries + const allInputs = document.querySelectorAll('input[name="CFG_IPS_WHITELIST"]:not([type="hidden"])'); + //console.log('Found', allInputs.length, 'inputs'); + + allInputs.forEach((input, index) => { + //console.log(`Input ${index}: value="${input.value}"`); + }); + + for (const input of allInputs) { + const entryValue = input.value.trim(); + + // Check if entry is empty + if (!entryValue) { + //console.log('Found empty entry - blocking add'); + // Flash the empty input field + input.style.animation = 'whitelist-flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification + if (window.notificationSystem) { + window.notificationSystem.error('Whitelist entry cannot be empty'); + } + //console.log('=== validateBeforeAddWhitelist END (false - empty) ==='); + return false; + } + + // Check entry format with notification (always show) + if (this.checkForInvalidWhitelistFormat(input, entryValue, false)) { + //console.log('Found invalid format - blocking add'); + //console.log('=== validateBeforeAddWhitelist END (false - invalid) ==='); + return false; + } + + // Check for duplicates with notification (always show) + if (this.checkForDuplicateWhitelistEntry(input, entryValue)) { + //console.log('Found duplicate entry - blocking add'); + //console.log('=== validateBeforeAddWhitelist END (false - duplicate) ==='); + return false; + } + } + + //console.log('=== validateBeforeAddWhitelist END (true) ==='); + return true; + } + + // Check for duplicate whitelist entries + checkForDuplicateWhitelistEntry(input, entryValue) { + const allWhitelistInputs = document.querySelectorAll('input[name="CFG_IPS_WHITELIST"]:not([type="hidden"])'); + const duplicates = []; + + allWhitelistInputs.forEach(otherInput => { + if (otherInput !== input) { + const otherValue = otherInput.value.trim(); + if (otherValue.toLowerCase() === entryValue.toLowerCase()) { + duplicates.push(otherValue); + } + } + }); + + if (duplicates.length > 0) { + // Flash the input field + input.style.animation = 'whitelist-flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification + if (window.notificationSystem) { + window.notificationSystem.error(`"${entryValue}" already exists in the whitelist`); + } else { + console.error(`Duplicate whitelist entry: ${entryValue}`); + } + return true; + } + + return false; + } + + // Check for invalid whitelist entry format + checkForInvalidWhitelistFormat(input, entryValue, showNotifications = true) { + // Special case for localhost + if (entryValue.toLowerCase() === 'localhost') { + return false; // localhost is valid + } + + // Check for valid IP address or domain format + const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/; + // Fixed domain pattern - requires at least one dot and proper TLD + const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; + + const isValidIP = ipPattern.test(entryValue) || ipv6Pattern.test(entryValue); + const isValidDomain = domainPattern.test(entryValue); + + //console.log('checkForInvalidWhitelistFormat validation:', { + //entryValue: entryValue, + //isValidIP: isValidIP, + //isValidDomain: isValidDomain, + //isValidFormat: isValidIP || isValidDomain || entryValue.toLowerCase() === 'localhost' + //}); + + if (!isValidIP && !isValidDomain) { + // Flash the input field + input.style.animation = 'whitelist-flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification only if requested + if (showNotifications && window.notificationSystem) { + window.notificationSystem.error(`Invalid whitelist format: "${entryValue}". Please use a valid IP address, domain (e.g., example.com), or localhost`); + } else if (showNotifications) { + console.error(`Invalid whitelist format: ${entryValue}`); + } + return true; // Invalid format found + } + + return false; // Valid format + } + + // Delete whitelist entry + deleteWhitelistEntry(entryIndex, buttonElement) { + //console.log(`Delete whitelist entry button clicked for index: ${entryIndex}`); + + try { + // Get the actual whitelist number from the input ID + const entryBlock = buttonElement.closest('.whitelist-building-block'); + if (!entryBlock) { + console.error('Could not find entry block'); + return; + } + + const input = entryBlock.querySelector('input'); + if (!input) { + console.error('Could not find input in entry block'); + return; + } + + const inputId = input.id; + const match = inputId.match(/config-CFG_IPS_WHITELIST_(\d+)/); + const whitelistNumber = match ? parseInt(match[1]) : 0; + + //console.log(`Actual whitelist number: ${whitelistNumber}`); + + // Find all whitelist numbers to determine the highest + const allBlocks = document.querySelectorAll('.whitelist-building-block'); + const allNumbers = []; + allBlocks.forEach(block => { + const blockInput = block.querySelector('input'); + if (blockInput) { + const blockId = blockInput.id; + const blockMatch = blockId.match(/config-CFG_IPS_WHITELIST_(\d+)/); + if (blockMatch) { + allNumbers.push(parseInt(blockMatch[1])); + } + } + }); + + const highestNumber = Math.max(...allNumbers); + //console.log(`Highest whitelist number: ${highestNumber}`); + + // Only allow deletion if this is the highest numbered entry AND it's not #1 + if (whitelistNumber === 1) { + //console.log('Cannot delete entry #1'); + if (window.notificationSystem) { + window.notificationSystem.error('Entry 1 cannot be deleted'); + } + return; + } + + if (whitelistNumber !== highestNumber) { + //console.log('Can only delete the highest numbered entry'); + if (window.notificationSystem) { + window.notificationSystem.error('Can only delete the highest numbered entry'); + } + return; + } + + // Clear the input value first (like domain manager) + input.value = ''; + + // Remove the building block from DOM + entryBlock.remove(); + //console.log('Removed entry block from DOM'); + + // Update the hidden input with remaining values + this.saveWhitelistEntries(); + + // Update add button state + this.updateAddEntryButton(); + + // Update delete button states + this.updateWhitelistDeleteButtons(); + + } catch (error) { + console.error('Error deleting whitelist entry:', error); + } + } + + // Rebuild whitelist section after changes + rebuildWhitelistSection() { + try { + // Get the current config data + const configData = window.configManager.core.configData; + const networkData = configData.network || {}; + + // Get the current CFG_IPS_WHITELIST value + const hiddenInput = document.querySelector('input[name="CFG_IPS_WHITELIST"]'); + if (hiddenInput) { + networkData.CFG_IPS_WHITELIST = hiddenInput.value; + } + + // Find the whitelist section and re-render only that part + const whitelistSection = document.querySelector('.config-category'); + if (whitelistSection) { + // Create config items for the whitelist + const configItems = [{ + key: 'CFG_IPS_WHITELIST', + value: hiddenInput ? hiddenInput.value : '' + }]; + + // Re-render the whitelist section + this.renderWhitelistSection(configItems, 'IP Whitelist', 'Allow specific IPs for Specified Traefik Apps') + .then(html => { + whitelistSection.innerHTML = html; + + // Re-attach event listeners and set hidden input value + setTimeout(() => { + if (hiddenInput) { + const newHiddenInput = document.getElementById('config-CFG_IPS_WHITELIST'); + if (newHiddenInput) { + newHiddenInput.value = hiddenInput.value; + } + } + }, 100); + }); + } + } catch (error) { + console.error('Error rebuilding whitelist section:', error); + // Fallback to full render if partial fails + if (window.configManager) { + window.configManager.renderConfig('network'); + } + } + } + + // Update delete button states + updateWhitelistDeleteButtons() { + // Find all whitelist building blocks + const allWhitelistBlocks = document.querySelectorAll('.whitelist-building-block'); + + // Find all whitelist numbers from existing blocks (using input IDs since all have same name) + const allWhitelistNumbers = []; + allWhitelistBlocks.forEach(block => { + const input = block.querySelector('input'); + if (input) { + const inputId = input.id; + const match = inputId.match(/config-CFG_IPS_WHITELIST_(\d+)/); + if (match) { + allWhitelistNumbers.push(parseInt(match[1])); + } + } + }); + + // Update delete button states (only highest numbered whitelist can be deleted, but NEVER Entry 1) + allWhitelistBlocks.forEach((block, index) => { + const deleteBtn = block.querySelector('.delete-whitelist-btn'); + if (deleteBtn) { + const input = block.querySelector('input'); + if (input) { + const inputId = input.id; + const whitelistNum = inputId.match(/config-CFG_IPS_WHITELIST_(\d+)/); + const whitelistNumber = whitelistNum ? parseInt(whitelistNum[1]) : 0; + + // Find the highest whitelist number among all visible blocks + const highestWhitelistNumber = Math.max(...allWhitelistNumbers); + + // Only highest numbered whitelist can be deleted, but NEVER Entry 1 + const canDelete = whitelistNumber === highestWhitelistNumber && whitelistNumber !== 1; + + deleteBtn.disabled = !canDelete; + deleteBtn.className = `delete-whitelist-btn ${!canDelete ? 'disabled' : ''}`; + deleteBtn.title = canDelete + ? 'Delete whitelist entry' + : whitelistNumber === 1 + ? 'Entry 1 cannot be deleted' + : 'Can only delete highest numbered entry'; + } + } + }); + } + + // Update add entry button state + updateAddEntryButton() { + const addEntryBtn = document.getElementById('add-whitelist-btn'); + if (addEntryBtn) { + // Find all whitelist building blocks + const allWhitelistBlocks = document.querySelectorAll('.whitelist-building-block'); + + // Check if we've reached the maximum of 20 entries + const isMaxEntries = allWhitelistBlocks.length >= this.maxWhitelistEntries; + + // Update button state + addEntryBtn.disabled = isMaxEntries; + addEntryBtn.className = `btn ${isMaxEntries ? 'btn-secondary' : 'btn-primary'}`; + addEntryBtn.innerHTML = `${isMaxEntries ? '✓' : '+'}${isMaxEntries ? 'Maximum Entries Reached' : 'Add IP/Domain'}`; + } + + // Also update delete button states + this.updateWhitelistDeleteButtons(); + } + + // Check if all entries are valid before allowing new entry addition + canAddNewEntry() { + const allWhitelistInputs = document.querySelectorAll('input[name^="CFG_IPS_WHITELIST_"]'); + for (const input of allWhitelistInputs) { + const entryValue = input.value.trim(); + + // Check if entry is empty + if (!entryValue) { + // Flash empty input field + input.style.animation = 'flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + + // Show notification with safety check + if (window.notificationSystem) { + window.notificationSystem.error('Whitelist entry cannot be empty'); + } else { + console.error('Whitelist entry cannot be empty'); + } + return false; // Empty entry - don't allow adding new entry + } + + // Check for duplicates with notification (always show) + if (checkForDuplicateWhitelistEntry(input, entryValue)) { + return false; + } + + // Check entry format + if (!validateWhitelistEntry(input, false)) { // Don't show notifications during bulk check + // Flash invalid input field + input.style.animation = 'flash 0.5s ease-in-out 2'; + input.focus(); + setTimeout(() => { + input.style.animation = ''; + }, 1000); + return false; // At least one entry has invalid format or duplicate + } + } + return true; // All entries are valid and non-empty + } +} + +// Separate function to check for duplicates that always shows notifications +function checkForDuplicateWhitelistEntry(input, entryValue) { + if (!entryValue) return false; + + const allWhitelistInputs = document.querySelectorAll('input[name^="CFG_IPS_WHITELIST_"]'); + const duplicates = Array.from(allWhitelistInputs).filter(otherInput => + otherInput !== input && otherInput.value.trim() === entryValue + ); + const hasDuplicate = duplicates.length > 0; + + if (hasDuplicate) { + input.style.borderColor = '#dc3545'; + input.title = 'Whitelist entry already exists'; + if (window.notificationSystem) { + //console.log('Showing duplicate whitelist entry notification'); + window.notificationSystem.error('Whitelist entry "' + entryValue + '" already exists'); + } else { + console.error('Whitelist entry already exists - notification system not available'); + } + return true; + } + + return false; +} + +// Separate function to check for invalid format that always shows notifications +function checkForInvalidWhitelistFormat(input, entryValue) { + if (!entryValue) return false; + + // IP address regex (supports IPv4 and IPv6) + const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/i; + // Domain regex (supports subdomains and multiple TLD levels) + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + + const isValidIP = ipRegex.test(entryValue); + const isValidDomain = domainRegex.test(entryValue); + const isValidFormat = isValidIP || isValidDomain; + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid IP address or domain format (e.g., 192.168.1.100 or example.com)'; + if (window.notificationSystem) { + //console.log('Showing invalid whitelist format notification'); + window.notificationSystem.error('Invalid whitelist format: "' + entryValue + '". Please use a valid IP address or domain like 192.168.1.100 or example.com'); + } else { + console.error('Invalid whitelist format - notification system not available'); + } + return true; + } + + return false; +} + +// Validate whitelist entry format when user tries to add a new entry +function validateWhitelistEntry(input, showNotifications = true) { + const value = input.value.trim(); + //console.log('validateWhitelistEntry called with:', value, 'showNotifications:', showNotifications); + //console.log('window.notificationSystem available:', !!window.notificationSystem); + + if (!value) { + // Clear styling for empty fields + input.style.borderColor = '#dc3545'; + input.title = 'Whitelist entry cannot be empty'; + if (showNotifications && window.notificationSystem) { + //console.log('Attempting to show empty whitelist notification'); + window.notificationSystem.error('Whitelist entry cannot be empty'); + } else { + console.error('Whitelist entry cannot be empty - notification system not available'); + } + return false; // Empty fields are not valid for adding new entries + } + + // Special case for localhost + if (value.toLowerCase() === 'localhost') { + input.style.borderColor = ''; + input.title = ''; + return true; // localhost is always valid + } + + // IP address regex (supports IPv4 and IPv6) + const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/i; + // Domain regex (supports subdomains and multiple TLD levels) + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$/; + + const isValidIP = ipRegex.test(value); + const isValidDomain = domainRegex.test(value); + const isValidFormat = isValidIP || isValidDomain || value.toLowerCase() === 'localhost'; + + if (!isValidFormat) { + input.style.borderColor = '#dc3545'; + input.title = 'Invalid IP address, domain, or localhost format (e.g., 192.168.1.100, example.com, or localhost)'; + if (showNotifications && window.notificationSystem) { + //console.log('Attempting to show invalid whitelist format notification'); + window.notificationSystem.error('Invalid whitelist format: "' + value + '". Please use a valid IP address, domain, or localhost like 192.168.1.100, example.com, or localhost'); + } else { + console.error('Invalid whitelist format - notification system not available'); + } + return false; + } else { + input.style.borderColor = ''; + input.title = ''; + return true; + } +} + +// Standalone whitelist management functions - immediately available +window.addWhitelistEntry = function() { + if (typeof window.IPWhitelistManager !== 'undefined' && window.IPWhitelistManager.addWhitelistEntry) { + return window.IPWhitelistManager.addWhitelistEntry(); + } + console.error('addWhitelistEntry called but IPWhitelistManager not available'); +}; + +window.deleteWhitelistEntry = function(entryKey, buttonElement) { + if (typeof window.IPWhitelistManager !== 'undefined' && window.IPWhitelistManager.deleteWhitelistEntry) { + return window.IPWhitelistManager.deleteWhitelistEntry(entryKey, buttonElement); + } + console.error('deleteWhitelistEntry called but IPWhitelistManager not available'); +}; + +window.validateWhitelistEntry = function(input, showNotifications) { + if (typeof window.IPWhitelistManager !== 'undefined' && window.IPWhitelistManager.validateWhitelistEntry) { + return window.IPWhitelistManager.validateWhitelistEntry(input, showNotifications); + } + console.error('validateWhitelistEntry called but IPWhitelistManager not available'); +}; + +// Standalone functions - immediately available +window.saveWhitelistEntries = function() { + if (typeof window.IPWhitelistManager !== 'undefined' && window.IPWhitelistManager.saveWhitelistEntries) { + return window.IPWhitelistManager.saveWhitelistEntries(); + } + console.error('saveWhitelistEntries called but IPWhitelistManager not available'); +}; + +// IP Whitelist Manager initialization is now handled by SystemLoader +// IPWhitelistManager instance will be created centrally diff --git a/containers/libreportal/frontend/js/components/config/toggle-manager.js b/containers/libreportal/frontend/js/components/config/toggle-manager.js new file mode 100755 index 0000000..3d508f0 --- /dev/null +++ b/containers/libreportal/frontend/js/components/config/toggle-manager.js @@ -0,0 +1,319 @@ +// Universal Toggle Manager - Complete solution for all config toggles +class ToggleManager { + constructor() { + this.toggles = new Map(); + this.init(); + } + + init() { + //console.log('ToggleManager: Initializing...'); + + // Auto-discover all toggle configurations + this.discoverToggles(); + //console.log('ToggleManager: Discovered', this.toggles.size, 'toggle configurations'); + + // Debug: Log discovered toggles + if (this.toggles.size > 0) { + //console.log('ToggleManager: No toggles found - will retry after config loads...'); + // Set up a mutation observer to detect when config content is added + this.setupContentObserver(); + } else { + //console.log('ToggleManager: Discovered toggles:', Array.from(this.toggles.keys())); + } + + // Also set up observer in case more toggles are added later + this.setupContentObserver(); + + // Retry discovery after a short delay to handle timing issues + setTimeout(() => { + //console.log('ToggleManager: Retrying toggle discovery...'); + this.rediscoverToggles(); + }, 100); + } + + // Set up observer to detect when config content is loaded + setupContentObserver() { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'childList') { + // Check if any new elements with data-toggle-config were added + const newToggles = Array.from(mutation.addedNodes).filter(node => node.nodeType === Node.ELEMENT_NODE && (node.dataset?.toggleConfig || node.querySelector('[data-toggle-config]'))); + + if (newToggles.length > 0) { + //console.log('ToggleManager: New toggle elements detected, re-discovering...'); + this.rediscoverToggles(); + observer.disconnect(); // Stop observing once we find toggles + } + } + }); + }); + + // Start observing the main content area + const contentContainer = document.getElementById('main-content') || document.querySelector('.main'); + if (contentContainer) { + observer.observe(contentContainer, { + childList: true, + subtree: true + }); + } + } + + // Re-discover toggles (called after content is loaded) + rediscoverToggles() { + //console.log('ToggleManager: Re-discovering toggles...'); + this.toggles.clear(); // Clear existing toggles + this.discoverToggles(); + //console.log('ToggleManager: Re-discovered', this.toggles.size, 'toggle configurations'); + + if (this.toggles.size > 0) { + //console.log('ToggleManager: Successfully discovered toggles:', Array.from(this.toggles.keys())); + } + } + + // Auto-discover toggle configurations from the page + discoverToggles() { + //console.log('ToggleManager: Looking for elements with [data-toggle-config]...'); + + // Find all elements with data-toggle-config attribute + const toggleElements = document.querySelectorAll('[data-toggle-config]'); + //console.log('ToggleManager: Found', toggleElements.length, 'elements with data-toggle-config'); + + toggleElements.forEach((element, index) => { + const config = element.dataset.toggleConfig; + const sectionId = element.dataset.sectionId; + const toggleType = element.dataset.toggleType || 'checkbox'; + + //console.log(`ToggleManager: Processing element ${index}:`, { + //config: config, + //sectionId: sectionId, + //toggleType: toggleType, + //element: element.tagName + (element.id ? '#' + element.id : '') + (element.name ? '[name=' + element.name + ']' : '') + //}); + + if (config && sectionId) { + this.toggles.set(config, { + config: config, + sectionId: sectionId, + toggleType: toggleType, + element: element + }); + //console.log(`ToggleManager: Registered toggle for config: ${config}`); + } else { + //console.log(`ToggleManager: Skipping element - missing config or sectionId`); + } + }); + } + + // Universal toggle function - works for any config option + toggle(configKey, isEnabled) { + //console.log('=== TOGGLE MANAGER DEBUG ==='); + //console.log('configKey:', configKey); + //console.log('isEnabled:', isEnabled); + + const toggle = this.toggles.get(configKey); + if (!toggle) { + console.error('ToggleManager: No toggle found for config:', configKey); + return false; + } + + //console.log('Toggle found:', toggle); + + const sectionContent = document.getElementById(toggle.sectionId); + const fields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea'); + + //console.log('sectionContent found:', !!sectionContent); + //console.log('fields found:', fields ? fields.length : 0); + + if (sectionContent && fields) { + if (isEnabled) { + //console.log('Enabling section...'); + sectionContent.classList.remove('hidden'); + fields.forEach((field, index) => { + //console.log(`Enabling field ${index}:`, field); + field.disabled = false; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '1'; + fieldGroup.style.pointerEvents = 'auto'; + } + }); + } else { + //console.log('Disabling section...'); + sectionContent.classList.add('hidden'); + fields.forEach((field, index) => { + //console.log(`Disabling field ${index}:`, field); + field.disabled = true; + const fieldGroup = field?.closest('.field-group'); + if (fieldGroup) { + fieldGroup.style.opacity = '0.5'; + fieldGroup.style.pointerEvents = 'none'; + } + }); + } + + //console.log('=== TOGGLE MANAGER SUCCESS ==='); + return true; + } else { + console.error('ToggleManager: Section content or fields not found'); + return false; + } + } + + // Universal toggle section renderer - works for ANY config option + static renderToggleSection(configKey, configItems, displaySubcategory, subcategoryDescription, config) { + const isEnabled = configKey.value === 'true' || configKey.value === 'git'; + const sectionId = `${configKey.key.replace(/[^a-zA-Z0-9]/g, '-')}-${configKey.key}`; + const toggleId = `${configKey.key.toLowerCase()}-toggle`; + + // Determine toggle type and class based on config key + let toggleType = 'checkbox'; + let toggleClass = 'generic-master-toggle'; + + if (configKey.key.includes('INSTALL_MODE')) { + toggleType = 'select'; + toggleClass = 'git-master-toggle'; + } else if (configKey.key.includes('MAIL')) { + toggleType = 'checkbox'; + toggleClass = 'mail-master-toggle'; + } else if (configKey.key.includes('BACKUP_REMOTE_')) { + // Specifically target BACKUP_REMOTE_1_ENABLED, BACKUP_REMOTE_2_ENABLED, etc. + toggleType = 'checkbox'; + toggleClass = 'backup-remote-toggle'; + } + + let html = ` +
    +
    +
    +
    +

    ${displaySubcategory}

    +

    ${subcategoryDescription}

    +
    +
    +
    +
    +
    + `; + + if (toggleType === 'select') { + // Git/Select toggle + html += ` + + + `; + } else { + // Checkbox toggle + html += ` + + `; + } + + html += ` +
    +
    +
    +
    + `; + + // Add all other fields (excluding the toggle key itself) + configItems.filter(item => item.key !== configKey.key).forEach(item => { + const fieldId = `config-${item.key}`; + html += window.ConfigShared?.generateField(fieldId, item.key, item.value, item.title, item.description, item.options, config) || ''; + }); + + // Add special buttons for specific config types + if (configKey.key.includes('MAIL')) { + html += ` +
    + + +
    + `; + } + + html += ` +
    +
    +
    +
    +
    + `; + + return html; + } + + // Register a new toggle configuration + register(configKey, sectionId, toggleType = 'checkbox') { + this.toggles.set(configKey, { + config: configKey, + sectionId: sectionId, + toggleType: toggleType + }); + } + + // Get all registered toggles + getToggles() { + return Array.from(this.toggles.keys()); + } + + // Check if a toggle exists + hasToggle(configKey) { + return this.toggles.has(configKey); + } +} + +// Global instance +window.toggleManager = new ToggleManager(); +// Make static method available on instance too +window.toggleManager.renderToggleSection = ToggleManager.renderToggleSection; + +// Add method to manually trigger discovery when config is loaded +window.toggleManager.forceRediscover = function() { + //console.log('ToggleManager: Force re-discovering toggles...'); + window.toggleManager.rediscoverToggles(); +}; + +// Static access for backward compatibility +window.ConfigShared = window.ConfigShared || {}; +window.ConfigShared.toggleSection = function(sectionId, isEnabled) { + // Try to find the config key from the section + const toggleElements = document.querySelectorAll(`[data-section-id="${sectionId}"]`); + if (toggleElements.length > 0) { + const configKey = toggleElements[0].dataset.toggleConfig; + return window.toggleManager.toggle(configKey, isEnabled); + } else { + console.error('ConfigShared.toggleSection: No toggle found for section:', sectionId); + return false; + } +}; diff --git a/containers/libreportal/frontend/js/components/confirmation-dialog.js b/containers/libreportal/frontend/js/components/confirmation-dialog.js new file mode 100755 index 0000000..ff0a76e --- /dev/null +++ b/containers/libreportal/frontend/js/components/confirmation-dialog.js @@ -0,0 +1,167 @@ +/** + * Confirmation Dialog - Simple and Working + */ + +class ConfirmationDialog { + constructor() { + this.overlay = null; + this.dialog = null; + this.callback = null; + this.init(); + } + + init() { + // Create overlay + this.overlay = document.createElement('div'); + this.overlay.className = 'confirmation-overlay'; + + // Create dialog as child of overlay + this.dialog = document.createElement('div'); + this.dialog.className = 'confirmation-dialog'; + + // Add dialog INSIDE overlay + this.overlay.appendChild(this.dialog); + + // Add overlay to body + document.body.appendChild(this.overlay); + + // Event listeners + this.overlay.addEventListener('click', () => this.hide()); + this.dialog.addEventListener('click', (e) => e.stopPropagation()); + + //console.log('Confirmation dialog initialized'); + } + + show(title, message, onConfirm, confirmText = 'Confirm', cancelText = 'Cancel', confirmClass = 'primary', showDataLossCheckbox = false) { + //console.log('Showing confirmation dialog'); + + this.callback = onConfirm; + + // Build dialog content + this.dialog.innerHTML = ` +
    +

    ${this.escapeHtml(title)}

    + +
    +
    +
    +
    ⚠️
    +
    ${this.escapeHtml(message)}
    +
    + ${showDataLossCheckbox ? ` +
    + +
    + ` : ''} +
    + + `; + + // Show dialog + this.overlay.classList.add('active'); + + // Debug: Check if class was added + //console.log('Active class added:', this.overlay.className); + //console.log('Has active class:', this.overlay.classList.contains('active')); + + // Handle checkbox if present + if (showDataLossCheckbox) { + const checkbox = document.getElementById('dataLossCheckbox'); + const confirmBtn = document.getElementById('confirmBtn'); + // Initial state + this.updateConfirmButton(checkbox.checked); + } + + // Escape key + const handleEscape = (e) => { + if (e.key === 'Escape') { + this.hide(); + document.removeEventListener('keydown', handleEscape); + } + }; + document.addEventListener('keydown', handleEscape); + + //console.log('Confirmation dialog shown'); + } + + updateConfirmButton(isChecked) { + const confirmBtn = document.getElementById('confirmBtn'); + if (confirmBtn) { + if (isChecked) { + confirmBtn.classList.add('confirmation-btn-ticked'); + confirmBtn.disabled = false; + } else { + confirmBtn.classList.remove('confirmation-btn-ticked'); + confirmBtn.disabled = true; + } + } + } + + confirm(showDataLossCheckbox) { + if (showDataLossCheckbox) { + const checkbox = document.getElementById('dataLossCheckbox'); + if (!checkbox.checked) { + return; + } + } + + //console.log('Confirmation dialog confirmed'); + if (this.callback) { + this.callback(); + } + this.hide(); + } + + hide() { + //console.log('Hiding confirmation dialog'); + //console.log('Before remove - classes:', this.overlay.className); + + this.overlay.classList.remove('active'); + + //console.log('After remove - classes:', this.overlay.className); + //console.log('Has active class after remove:', this.overlay.classList.contains('active')); + + this.callback = null; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} + +// Initialize +let confirmationDialog = null; + +// Initialize immediately when script loads +function initConfirmationDialog() { + if (!confirmationDialog) { + confirmationDialog = new ConfirmationDialog(); + window.confirmationDialog = confirmationDialog; + } +} + +// Confirmation dialog initialization is now handled by SystemLoader +// initConfirmationDialog() will be called centrally + +// Global function +window.showConfirmation = (title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox) => { + // Ensure dialog is initialized + initConfirmationDialog(); + + if (confirmationDialog) { + confirmationDialog.show(title, message, onConfirm, confirmText, cancelText, confirmClass, showDataLossCheckbox); + } else { + // Fallback to native confirm + if (confirm(message)) { + onConfirm(); + } + } +}; diff --git a/containers/libreportal/frontend/js/components/dashboard.js b/containers/libreportal/frontend/js/components/dashboard.js new file mode 100755 index 0000000..d358d02 --- /dev/null +++ b/containers/libreportal/frontend/js/components/dashboard.js @@ -0,0 +1,128 @@ +// Dashboard functionality +// loadSystemInfo() and updateDiskChart() live in data-loader.js — that version +// uses waitForDashboardElements() so it doesn't fire before the dashboard HTML +// is in the DOM. Defining them here too overrode the safer version and produced +// the spurious "Disk chart elements not found" errors when called from non-dashboard pages. + +// Load installed apps and render icon grid on dashboard +async function loadInstalledApps() { + if (!window.apps || window.apps.length === 0) { + try { + const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); + const data = await response.json(); + window.apps = data.apps || []; + } catch (e) { + return; + } + } + renderInstalledApps(); +} + +function renderInstalledApps() { + const section = document.getElementById('frontpage-apps-section'); + const container = document.getElementById('frontpage-apps-container'); + if (!section || !container) return; + + const installed = (window.apps || []).filter(a => a.installed); + if (installed.length === 0) return; + + container.innerHTML = installed.map(app => createInstalledAppCard(app)).join(''); + section.style.display = ''; + + populateDashboardServiceButtons(installed); +} + +function createInstalledAppCard(app) { + const appName = app.command.split(' ').pop(); + let icon = app.icon || 'icons/apps/default.svg'; + if (!icon.startsWith('/')) icon = '/' + icon; + const shortName = app.name.split(' - ')[0].trim(); + + return ` +
    +
    + ${shortName} +
    +
    + ${shortName} +
    + `; +} + +async function populateDashboardServiceButtons(installedApps) { + let services = []; + + if (window.serviceButtons) { + if (window.serviceButtons.services.length === 0) await window.serviceButtons.loadServices(); + services = window.serviceButtons.services; + } else { + try { + const res = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); + const data = await res.json(); + services = data.services || []; + } catch (e) { + return; + } + } + + const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http'; + + installedApps.forEach(app => { + const appName = app.command.split(' ').pop(); + const shortName = app.name.split(' - ')[0].trim(); + const overlay = document.getElementById(`frontpage-overlay-${appName}`); + if (!overlay) return; + + const appServices = services.filter(s => s.app === appName && s.buttonEnabled === true); + + // Multi-button render via the shared expandServiceLinks() helper. + const serviceButtons = appServices.flatMap(s => + window.expandServiceLinks(s).map(({ url, label }) => ` + + + + + + + ${label} + + `) + ).filter(Boolean).join(''); + + overlay.innerHTML = serviceButtons + ``; + }); +} + +// Setup event listeners +function setupEventListeners() { + setupMobileMenu(); + loadInstalledApps(); +} + +// Navigate to app page using SPA router +function navigateToApp(appName) { + // Use proper SPA navigation to the app page + if (window.librePortalSPA && typeof window.librePortalSPA.navigate === 'function') { + window.librePortalSPA.navigate(`/app?=${appName}`); + } else if (window.navigateToRoute && typeof window.navigateToRoute === 'function') { + window.navigateToRoute(`app?=${appName}`); + } else { + // Fallback to direct navigation + window.location.href = `/app?=${appName}`; + } +} + +// Filter apps by search term (removed - not used in dashboard) +function filterApps(searchTerm) { + //console.log('Filter apps functionality removed from dashboard'); +} + +// Filter apps by category (removed - not used in dashboard) +function filterAppsByCategory(category) { + //console.log('Filter apps by category functionality removed from dashboard'); +} + +// Populate category filter (removed - not used in dashboard) +function populateCategoryFilter() { + //console.log('Category filter population removed from dashboard'); +} diff --git a/containers/libreportal/frontend/js/components/eo-modal.js b/containers/libreportal/frontend/js/components/eo-modal.js new file mode 100644 index 0000000..acf4379 --- /dev/null +++ b/containers/libreportal/frontend/js/components/eo-modal.js @@ -0,0 +1,222 @@ +// eo-modal — unified modal helper. CSS in modal.css under ".eo-modal". +// +// API: +// const m = openEoModal({ +// id, size, className, // 'sm' | 'md' (default) | 'lg' +// icon, iconAlt, eyebrow, title, desc, // header +// body, // string | HTMLElement | array of either +// actions, // [{label, variant, onClick(modal)}] +// closeOnBackdrop = true, +// onClose, +// }); +// m.close(); // remove from DOM, fires onClose +// m.bodyEl; // the .eo-modal-body element (mutate as needed) +// m.contentEl; // the .eo-modal-content element +// m.el; // the backdrop .eo-modal element +// +// Section primitives (return HTML strings; pass as part of body): +// eoSection(title, content) +// eoBadgeRow([{icon, label, variant}]) variant: success|info|purple|warning|danger +// eoUrlList([{url, label}]) +// eoCredList([{title, username, password}]) +// eoEmpty(text) + +(function () { + function escHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>'); + } + function escAttr(s) { + return escHtml(s).replace(/"/g, '"').replace(/'/g, '''); + } + function toBodyNode(input) { + if (input == null) return document.createTextNode(''); + if (typeof input === 'string') { + const t = document.createElement('div'); + t.style.display = 'contents'; + t.innerHTML = input; + return t; + } + if (Array.isArray(input)) { + const f = document.createDocumentFragment(); + input.forEach((p) => f.appendChild(toBodyNode(p))); + return f; + } + if (input instanceof HTMLElement || input instanceof DocumentFragment) return input; + return document.createTextNode(String(input)); + } + + window.openEoModal = function openEoModal(opts) { + opts = opts || {}; + const id = opts.id || `eo-modal-${Math.random().toString(36).slice(2, 9)}`; + const size = opts.size || 'md'; + + const existing = document.getElementById(id); + if (existing) existing.remove(); + + const root = document.createElement('div'); + root.className = `eo-modal ${opts.className || ''}`.trim(); + root.id = id; + root.dataset.size = size; + + const headerHasIcon = !!opts.icon; + const titleHtml = ` +
    + ${opts.eyebrow ? `
    ${escHtml(opts.eyebrow)}
    ` : ''} + ${opts.title ? `

    ${escHtml(opts.title)}

    ` : ''} + ${opts.desc ? `

    ${escHtml(opts.desc)}

    ` : ''} +
    `; + + const endIconHtml = opts.endIcon + ? `` + : ''; + + root.innerHTML = ` +
    + ${(opts.icon || opts.title || opts.eyebrow) ? ` +
    +
    + ${headerHasIcon ? `${escAttr(opts.iconAlt || '')}` : ''} + ${titleHtml} +
    + ${endIconHtml} + +
    ` : ''} +
    + ${(opts.actions && opts.actions.length) ? '' : ''} +
    `; + + const contentEl = root.querySelector('.eo-modal-content'); + const bodyEl = root.querySelector('.eo-modal-body'); + const footerEl = root.querySelector('.eo-modal-footer'); + const closeBtn = root.querySelector('.eo-modal-close'); + + bodyEl.appendChild(toBodyNode(opts.body)); + + const m = { + el: root, + contentEl, + bodyEl, + close: () => { + root.remove(); + if (typeof opts.onClose === 'function') { + try { opts.onClose(); } catch (e) { console.error(e); } + } + } + }; + + if (closeBtn) closeBtn.addEventListener('click', m.close); + if (opts.closeOnBackdrop !== false) { + root.addEventListener('click', (e) => { if (e.target === root) m.close(); }); + } + + if (footerEl && Array.isArray(opts.actions)) { + opts.actions.forEach((a) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `btn btn-${a.variant || 'secondary'}`; + btn.textContent = a.label || 'OK'; + btn.addEventListener('click', () => { + if (typeof a.onClick === 'function') a.onClick(m); + else m.close(); + }); + footerEl.appendChild(btn); + }); + } + + document.body.appendChild(root); + return m; + }; + + // ----- Section primitives ----- + + window.eoSection = function (title, content) { + return `
    + ${title ? `
    ${escHtml(title)}
    ` : ''} + ${content || ''} +
    `; + }; + + window.eoBadgeRow = function (badges) { + if (!Array.isArray(badges) || badges.length === 0) return ''; + const html = badges.map((b) => { + const variant = b.variant ? ` ${b.variant}` : ''; + return `${b.icon ? escHtml(b.icon) + ' ' : ''}${escHtml(b.label)}`; + }).join(''); + return `
    ${html}
    `; + }; + + window.eoUrlList = function (urls) { + if (!Array.isArray(urls) || urls.length === 0) return ''; + const arrow = ``; + const rows = urls.map((u) => ` + + ${escHtml(u.label)} + ${escHtml(u.url)} + ${arrow} + `).join(''); + return `
    ${rows}
    `; + }; + + window.eoCredList = function (creds) { + if (!Array.isArray(creds) || creds.length === 0) return ''; + const copyBtn = (val) => ``; + return creds.map((c) => { + const userLabel = c.userLabel + || (typeof c.username === 'string' && c.username.includes('@') ? 'Email' : 'User'); + const passLabel = c.passLabel || 'Pass'; + return ` +
    + ${c.title ? `
    ${escHtml(c.title)}
    ` : ''} + ${c.username != null ? `
    + ${escHtml(userLabel)} + ${escHtml(c.username)} + ${copyBtn(c.username)} +
    ` : ''} + ${c.password != null ? `
    + ${escHtml(passLabel)} + •••••••• + + ${copyBtn(c.password)} +
    ` : ''} +
    `; + }).join(''); + }; + + window.eoEmpty = function (text) { + return `

    ${escHtml(text || '')}

    `; + }; + + document.addEventListener('click', (e) => { + const btn = e.target.closest && e.target.closest('.eo-modal-cred-toggle'); + if (!btn) return; + const code = btn.parentElement && btn.parentElement.querySelector('.eo-cred-pass'); + if (!code) return; + const revealed = code.dataset.revealed === 'true'; + code.textContent = revealed ? '••••••••' : code.dataset.value; + code.dataset.revealed = revealed ? 'false' : 'true'; + btn.textContent = revealed ? 'Show' : 'Hide'; + }); + + // Copy-to-clipboard for cred rows. Briefly swaps the icon for a check. + document.addEventListener('click', (e) => { + const btn = e.target.closest && e.target.closest('.eo-modal-cred-copy'); + if (!btn) return; + const value = btn.dataset.copy || ''; + const done = () => { + btn.classList.add('copied'); + const original = btn.innerHTML; + btn.innerHTML = ``; + setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = original; }, 1100); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value).then(done).catch(() => done()); + } else { + const ta = document.createElement('textarea'); + ta.value = value; ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); } catch (_) {} + document.body.removeChild(ta); done(); + } + }); +})(); diff --git a/containers/libreportal/frontend/js/components/mobile-menu.js b/containers/libreportal/frontend/js/components/mobile-menu.js new file mode 100755 index 0000000..a491890 --- /dev/null +++ b/containers/libreportal/frontend/js/components/mobile-menu.js @@ -0,0 +1,70 @@ +// Mobile Menu Handler — wires the topbar burger to the slide-in drawer +// (#mobile-drawer). On pages that ship a page sidebar (#sidebar), the +// drawer borrows its contents while open so the user gets one unified +// nav surface on mobile. +function setupMobileMenu() { + const toggle = document.getElementById('mobile-menu-toggle'); + const drawer = document.getElementById('mobile-drawer'); + const overlay = document.getElementById('mobile-overlay'); + + if (!toggle || !drawer || !overlay) { + setTimeout(setupMobileMenu, 100); + return; + } + + const pageSection = document.getElementById('mobile-drawer-page-section'); + let borrowedNodes = []; + let sidebarOrigin = null; + + function borrowSidebar() { + const sidebar = document.getElementById('sidebar'); + if (!sidebar || !pageSection) return; + sidebarOrigin = sidebar; + borrowedNodes = Array.from(sidebar.children); + borrowedNodes.forEach((node) => pageSection.appendChild(node)); + } + + function returnSidebar() { + if (!sidebarOrigin || borrowedNodes.length === 0) return; + borrowedNodes.forEach((node) => sidebarOrigin.appendChild(node)); + borrowedNodes = []; + sidebarOrigin = null; + } + + function openMenu() { + borrowSidebar(); + drawer.classList.add('mobile-open'); + overlay.classList.add('active'); + document.body.style.overflow = 'hidden'; + } + + function closeMenu() { + drawer.classList.remove('mobile-open'); + overlay.classList.remove('active'); + document.body.style.overflow = ''; + returnSidebar(); + } + + function toggleMenu() { + if (drawer.classList.contains('mobile-open')) closeMenu(); + else openMenu(); + } + + toggle.addEventListener('click', toggleMenu); + overlay.addEventListener('click', closeMenu); + + // Close when any nav-item, sidebar-item, or category gets clicked + // (they trigger navigation, so the drawer should dismiss itself). + drawer.addEventListener('click', (e) => { + const dismisser = e.target.closest('.nav-item, .sidebar-item, .category'); + if (dismisser) closeMenu(); + }); + + window.addEventListener('resize', () => { + if (window.innerWidth > 768 && drawer.classList.contains('mobile-open')) { + closeMenu(); + } + }); + + window.closeMobileMenu = closeMenu; +} diff --git a/containers/libreportal/frontend/js/components/notifications.js b/containers/libreportal/frontend/js/components/notifications.js new file mode 100755 index 0000000..d3714ff --- /dev/null +++ b/containers/libreportal/frontend/js/components/notifications.js @@ -0,0 +1,448 @@ +/** + * Enhanced Notification System for LibrePortal + * Provides consistent, modular notifications with app icons and proper layout + */ +class NotificationSystem { + constructor() { + this.container = null; + this.init(); + } + + init() { + // Create notification container + this.container = document.createElement('div'); + this.container.className = 'notification-container'; + document.body.appendChild(this.container); + + // Restore any pending notifications from localStorage + this.restoreNotifications(); + } + + /** + * Show a notification with consistent layout + * Layout: [Type Icon] [App Icon] [Message] [Action Button] [Close] + * + * `customIcon` is an optional override for the leftmost icon slot — when + * supplied, the notification renders that string/HTML there instead of + * the level-derived SVG (success tick / error cross / warning triangle). + * Used by task notifications so the icon reflects the task *type* + * (install ✅, backup 💾, restore 📦, …) rather than just success/fail. + */ + show(message, type = 'info', appName = null, appUrl = null, appIcon = null, customIcon = null) { + const notification = this.createNotificationElement(type, message, appName, appUrl, appIcon, customIcon); + this.addNotificationToContainer(notification); + this.saveNotificationToStorage({ message, type, appName, appUrl, appIcon, customIcon }); + this.setupAutoRemove(notification); + return notification; + } + + /** + * Create notification element with consistent structure + */ + createNotificationElement(type, message, appName, appUrl, appIcon, customIcon = null) { + const notification = document.createElement('div'); + notification.className = `notification notification-${type}`; + + // Add data attributes for dynamic sizing + if (appIcon) { + notification.setAttribute('data-has-app', 'true'); + } + if (appName && appUrl) { + notification.setAttribute('data-has-action', 'true'); + } + + const typeIcon = customIcon != null && customIcon !== '' ? customIcon : this.getIcon(type); + const content = this.buildNotificationContent(typeIcon, message, appName, appUrl, appIcon, type); + + notification.innerHTML = content; + + // Attach event listeners dynamically for action button + if (appName && appUrl) { + const actionBtn = notification.querySelector('.notification-action-btn'); + if (actionBtn) { + actionBtn.addEventListener('click', (e) => { + console.log('🔗 Notification action button clicked for URL:', appUrl); + e.preventDefault(); + e.stopPropagation(); + if (window.handleNotificationNavigation) { + window.handleNotificationNavigation(appUrl); + } else { + console.error('❌ handleNotificationNavigation not available'); + } + }); + // Remove any other click handlers that might interfere + actionBtn.style.cursor = 'pointer'; + } else { + console.warn('⚠️ Action button not found in notification'); + } + } + + // Attach event listener for close button + const closeBtn = notification.querySelector('.notification-close'); + if (closeBtn) { + closeBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + notification.remove(); + }); + } + + return notification; + } + + /** + * Build notification HTML content + */ + buildNotificationContent(typeIcon, message, appName, appUrl, appIcon, type) { + let content = '
    '; + + // Type icon (always first) + content += `
    ${typeIcon}
    `; + + // App icon (if provided) + if (appIcon) { + content += ` +
    + ${appName} +
    + `; + } + + // Message section + content += `
    ${message}
    `; + + // Action button (context-aware) + if (appName && appUrl) { + let buttonText = 'Manage'; + if (appUrl.includes('task=')) { + buttonText = 'View Task'; + } else if (type === 'success' && message.includes('install')) { + buttonText = 'Configure'; + } else if (type === 'info' || type === 'warning') { + buttonText = 'View Task'; + } + + content += ` + + `; + } + + // Close button (always last) + content += ` + +
    `; + + return content; + } + + /** + * Get icon based on notification type + */ + getIcon(type) { + const icons = { + success: '', + error: '', + warning: '', + info: '', + uninstall: '' + }; + return icons[type] || icons.info; + } + + /** + * Add notification to container with animation + */ + addNotificationToContainer(notification) { + this.container.appendChild(notification); + + // Trigger animation + setTimeout(() => { + notification.classList.add('notification-show'); + }, 10); + } + + /** + * Setup auto-remove after 10 seconds + */ + setupAutoRemove(notification) { + setTimeout(() => { + if (notification.parentElement) { + notification.classList.add('notification-hide'); + setTimeout(() => { + if (notification.parentElement) { + notification.remove(); + } + }, 300); + } + }, 10000); + } + + /** + * Save notification to localStorage for cross-page persistence + */ + saveNotificationToStorage(notificationData) { + try { + const notifications = JSON.parse(localStorage.getItem('libreportal_notifications') || '[]'); + notificationData.id = Date.now().toString(); + notificationData.timestamp = Date.now(); + notifications.push(notificationData); + + // Keep only last 5 notifications to avoid clutter + if (notifications.length > 5) { + notifications.shift(); + } + + localStorage.setItem('libreportal_notifications', JSON.stringify(notifications)); + } catch (error) { + console.error('Error saving notification to localStorage:', error); + } + } + + /** + * Restore notifications from localStorage on page load + */ + restoreNotifications() { + try { + const notifications = JSON.parse(localStorage.getItem('libreportal_notifications') || '[]'); + const now = Date.now(); + + notifications.forEach(notificationData => { + const age = now - notificationData.timestamp; + const remainingTime = 10000 - age; // 10 seconds + + if (remainingTime > 0) { + const notification = this.createNotificationElement( + notificationData.type, + notificationData.message, + notificationData.appName, + notificationData.appUrl, + notificationData.appIcon, + notificationData.customIcon + ); + + this.addNotificationToContainer(notification); + this.setupAutoRemove(notification, remainingTime); + } + }); + } catch (error) { + console.error('Error restoring notifications:', error); + } + } + + /** + * Remove notification from localStorage + */ + removeNotification(notificationId) { + try { + const notifications = JSON.parse(localStorage.getItem('libreportal_notifications') || '[]'); + const filteredNotifications = notifications.filter(n => n.id !== notificationId); + localStorage.setItem('libreportal_notifications', JSON.stringify(filteredNotifications)); + } catch (error) { + console.error('Error removing notification:', error); + } + } + + /** + * Convenience methods + */ + success(message, appName = null, appUrl = null, appIcon = null) { + return this.show(message, 'success', appName, appUrl, appIcon); + } + + error(message, appName = null, appUrl = null, appIcon = null) { + return this.show(message, 'error', appName, appUrl, appIcon); + } + + warning(message, appName = null, appUrl = null, appIcon = null) { + return this.show(message, 'warning', appName, appUrl, appIcon); + } + + info(message, appName = null, appUrl = null, appIcon = null) { + return this.show(message, 'info', appName, appUrl, appIcon); + } + + /** + * Show install notification with short app name + */ + showInstallNotification(appName, appUrl, appIcon = null) { + const shortName = appName.split(' - ')[0]; + const message = `${shortName} has been installed.`; + return this.show(message, 'success', appName, appUrl, appIcon); + } + + /** + * Show uninstall notification with short app name + */ + showUninstallNotification(appName, appIcon = null) { + const shortName = appName.split(' - ')[0]; + const message = `${shortName} has been uninstalled.`; + return this.show(message, 'uninstall', appName, null, appIcon); + } +} + +// Notification system initialization is now handled by SystemLoader +// NotificationSystem instance will be created centrally + +// Resolve a slug (e.g. "ipinfo") to its proper display name from window.apps +// (e.g. "IPInfo"). Falls back to a capitalized slug if window.apps isn't loaded +// or no match is found. The slug is the trailing token of an app's `command`. +window.getAppDisplayName = function (slug) { + if (!slug) return ''; + const apps = window.apps || []; + const match = apps.find(a => { + const command = a.command || ''; + return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase(); + }); + if (match && match.name) { + return match.name.split(' - ')[0].trim(); + } + return slug.charAt(0).toUpperCase() + slug.slice(1); +}; + +// Expose helper functions globally +window.ensureNotificationSystem = () => { + if (!window.notificationSystem) { + console.warn('Notification system not initialized, creating new instance'); + window.notificationSystem = new NotificationSystem(); + } + return window.notificationSystem; +}; + +window.removeNotification = (notificationId) => { + if (window.notificationSystem) { + window.notificationSystem.removeNotification(notificationId); + } +}; + +window.handleNotificationNavigation = (url) => { + try { + console.log('🔗 handleNotificationNavigation called with URL:', url); + + // Parse the URL to extract task ID and app name + const urlObj = new URL(url, window.location.origin); + const urlParams = new URLSearchParams(urlObj.search); + + // Extract app name from ?= parameter (the key is empty string, = is the separator) + const appName = urlParams.get('') || urlParams.get('='); + const taskId = urlParams.get('task'); + const tab = urlParams.get('tab') || 'tasks'; + + console.log('🔗 Parsed URL:', { url, appName, taskId, tab, currentPath: window.location.pathname }); + + // Check if we're on an app page or tasks page + const currentPath = window.location.pathname; + + if (currentPath.includes('/app') && appName) { + console.log('🔗 On app page, appTabbedManager available:', !!window.appTabbedManager); + console.log('🔗 Current app:', window.appTabbedManager?.currentApp, 'Target app:', appName); + + // We're on an app page - navigate to the specified app and tab + if (window.appTabbedManager) { + // Update the URL to the target app/tab/task + const newUrl = `/app?=${appName}&tab=${tab}&task=${taskId}`; + console.log('🔗 Pushing state to URL:', newUrl); + window.history.pushState({}, '', newUrl); + + // If already on this app, just switch tab and highlight task + if (window.appTabbedManager.currentApp === appName) { + console.log('🔗 Same app, switching to tab:', tab); + window.appTabbedManager.switchTab(tab); + if (tab === 'tasks' && taskId) { + // Wait for tasks to load and render, then open the task details + setTimeout(() => { + console.log('🔗 Opening task details for:', taskId); + if (typeof window.toggleAppTaskDetails === 'function') { + window.toggleAppTaskDetails(taskId); + // Scroll to task after opening details + if (window.appTabbedManager && window.appTabbedManager.scrollToTask) { + window.appTabbedManager.scrollToTask(taskId); + } + } + }, 800); + } + console.log('🔗 Navigation completed successfully'); + } else { + // Different app - need to load the full app detail + console.log('🔗 Different app, loading app detail for:', appName); + window.appTabbedManager.showAppDetail(appName); + // Schedule the tab switch and task highlight after app loads + setTimeout(() => { + if (window.appTabbedManager) { + console.log('🔗 Switching to tab:', tab); + window.appTabbedManager.switchTab(tab); + if (tab === 'tasks' && taskId) { + // Wait for tasks to load and render, then open the task details + setTimeout(() => { + console.log('🔗 Opening task details for:', taskId); + if (typeof window.toggleAppTaskDetails === 'function') { + window.toggleAppTaskDetails(taskId); + // Scroll to task after opening details + if (window.appTabbedManager && window.appTabbedManager.scrollToTask) { + window.appTabbedManager.scrollToTask(taskId); + } + } + }, 800); + } + } + }, 500); + console.log('🔗 Navigation to different app started'); + } + return true; + } else { + console.warn('⚠️ appTabbedManager not available'); + } + } else if (currentPath.includes('/tasks')) { + // We're on the tasks page, navigate to the specified task + if (taskId) { + console.log('🔗 On tasks page, opening task:', taskId); + window.history.pushState({}, '', `/tasks?=all&task=${taskId}`); + setTimeout(() => { + if (typeof window.toggleTaskDetails === 'function') { + console.log('🔗 Opening task details for:', taskId); + window.toggleTaskDetails(taskId); + } + }, 300); + return true; + } + } else { + // Not on app or tasks page - navigate to the app's tasks tab + if (appName && tab) { + window.history.pushState({}, '', `/app?=${appName}&tab=${tab}&task=${taskId}`); + // Let the SPA handle the navigation + if (window.appTabbedManager) { + window.appTabbedManager.showAppDetail(appName); + setTimeout(() => { + if (window.appTabbedManager) { + window.appTabbedManager.switchTab(tab); + if (window.tasksManager && taskId) { + window.tasksManager.highlightedTaskId = taskId; + if (window.tasksManager.loadTaskLogs) { + window.tasksManager.loadTaskLogs(taskId); + } + } + } + }, 500); + return true; + } + } + } + + // If we get here and no managers were available, fallback + console.warn('⚠️ Falling back to page reload for URL:', url); + window.location.href = url; + return false; + } catch (error) { + console.error('❌ Error handling notification navigation:', error); + // Fallback to direct navigation if parsing fails + console.warn('⚠️ Falling back to page reload due to error for URL:', url); + window.location.href = url; + return false; + } +}; diff --git a/containers/libreportal/frontend/js/components/task/task-actions.js b/containers/libreportal/frontend/js/components/task/task-actions.js new file mode 100755 index 0000000..3720bb0 --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-actions.js @@ -0,0 +1,407 @@ +/** + * Task Actions - Action implementations for individual task operations + * Handles app installations, management operations, etc. + */ + +class TaskActions { + constructor(tasksManager, commands) { + this.tasksManager = tasksManager; + this.commands = commands; + } + + /** + * Install an application + */ + async installApp(appName, config = '', resetNetwork = false) { + try { + this.commands.validateCommand('install', { appName }); + + if (resetNetwork) { + const parts = ['libreportal', 'app', 'install', appName]; + if (config) parts.push(`'${config.replace(/'/g, "'\\''")}'`); + parts.push('--reset-network'); + return await this.executeTask('install', appName, parts.join(' ')); + } + return await this.executeTask('install', appName, config); + } catch (error) { + throw new Error(`Failed to install ${appName}: ${error.message}`); + } + } + + /** + * Uninstall an application + */ + async uninstallApp(appName, deleteImage = false, deleteTasks = false) { + try { + this.commands.validateCommand('uninstall', { appName }); + // Build a verbatim command when the user opted into any flag so the + // --delete-images / --delete-tasks switches survive executeTask's + // standard command-builder (which would otherwise quote them as config args). + const flags = []; + if (deleteImage) flags.push('--delete-images'); + if (deleteTasks) flags.push('--delete-tasks'); + if (flags.length) { + const command = `libreportal app uninstall ${appName} ${flags.join(' ')}`; + return await this.executeTask('uninstall', appName, command); + } + return await this.executeTask('uninstall', appName); + } catch (error) { + throw new Error(`Failed to uninstall ${appName}: ${error.message}`); + } + } + + /** + * Restart an application + */ + async restartApp(appName) { + try { + this.commands.validateCommand('restart', { appName }); + return await this.executeTask('restart', appName); + } catch (error) { + throw new Error(`Failed to restart ${appName}: ${error.message}`); + } + } + + /** + * Start an application + */ + async startApp(appName) { + try { + this.commands.validateCommand('start', { appName }); + return await this.executeTask('start', appName); + } catch (error) { + throw new Error(`Failed to start ${appName}: ${error.message}`); + } + } + + /** + * Stop an application + */ + async stopApp(appName) { + try { + this.commands.validateCommand('stop', { appName }); + return await this.executeTask('stop', appName); + } catch (error) { + throw new Error(`Failed to stop ${appName}: ${error.message}`); + } + } + + /** + * Create backup for an application + */ + async backupApp(appName, customPassword = '') { + try { + this.commands.validateCommand('backup', { appName }); + return await this.executeTask('backup', appName, customPassword); + } catch (error) { + throw new Error(`Failed to backup ${appName}: ${error.message}`); + } + } + + async restoreApp(appName, location, backupFile, password) { + try { + if (!appName) throw new Error('appName is required'); + if (!backupFile) throw new Error('backupFile is required'); + + const parts = ['libreportal', 'app', 'restore', appName]; + if (location) parts.push(location); + parts.push(backupFile); + if (password) parts.push(password); + return await this.executeTask('restore', appName, parts.join(' ')); + } catch (error) { + throw new Error(`Failed to restore ${appName}: ${error.message}`); + } + } + + /** + * Delete backup file for an application + */ + async deleteBackup(appName, backupFile, deleteRemote1 = 'false', deleteRemote2 = 'false') { + try { + // Build the command with all parameters + const command = `libreportal backup app delete ${appName} ${backupFile} ${deleteRemote1} ${deleteRemote2}`; + + // Create task directly with the full command + // createTask will handle monitoring + const task = await this.tasksManager.taskManager.createTask(command, 'delete', appName, backupFile); + + // Emit taskCreated event for AppTabbedManager to track the task + // This is needed for tab re-enabling on task completion + window.dispatchEvent(new CustomEvent('taskCreated', { + detail: { + taskId: task.id, + appName: appName, + action: 'delete', + timestamp: Date.now() + } + })); + + return task; + } catch (error) { + throw new Error(`Failed to delete backup ${backupFile}: ${error.message}`); + } + } + + /** + * Delete all backup files for an application + */ + async deleteAllBackups(appName, deleteRemote1 = 'false', deleteRemote2 = 'false') { + try { + // Build the command with all parameters + const command = `libreportal backup app delete_all ${appName} ${deleteRemote1} ${deleteRemote2}`; + + // Create task directly with the full command + // createTask will handle monitoring + const task = await this.tasksManager.taskManager.createTask(command, 'delete_all', appName, 'all'); + + // Emit taskCreated event for AppTabbedManager to track the task + // This is needed for tab re-enabling on task completion + window.dispatchEvent(new CustomEvent('taskCreated', { + detail: { + taskId: task.id, + appName: appName, + action: 'delete_all', + timestamp: Date.now() + } + })); + + return task; + } catch (error) { + throw new Error(`Failed to delete all backups for ${appName}: ${error.message}`); + } + } + + /** + * Update application configuration + */ + async updateConfig(appName) { + try { + this.commands.validateCommand('update_config', { appName }); + + await this.executeTask('update_config', appName); + return; + } catch (error) { + throw new Error(`Failed to update config for ${appName}: ${error.message}`); + } + } + +async configUpdate(changes) { + try { + if (!changes) throw new Error('No changes to save'); + const command = `libreportal config update ${changes}`; + return await this.executeTask('config_update', 'system', command); + } catch (error) { + throw new Error(`Failed to update configuration: ${error.message}`); + } + } + + /** + * Run a per-app tool from the Tools tab. Builds: + * libreportal app tool '' + * argsString is pipe-encoded: key=value|key=value (matches the install + * config pattern so the bash side can use the same parser). + */ + async runTool(appName, toolName, toolArgs = '', toolLabel = '') { + try { + if (!appName) throw new Error('appName is required'); + if (!toolName) throw new Error('toolName is required'); + + const safeTool = String(toolName).replace(/[^A-Za-z0-9_.-]/g, ''); + if (!safeTool) throw new Error('Invalid tool name'); + + const parts = ['libreportal', 'app', 'tool', appName, safeTool]; + if (toolArgs) { + parts.push(`'${String(toolArgs).replace(/'/g, "'\\''")}'`); + } + return await this.executeTask('tool', appName, parts.join(' '), toolLabel); + } catch (error) { + throw new Error(`Failed to run tool ${toolName} on ${appName}: ${error.message}`); + } + } + + /** + * System operations + */ + async systemUpdate() { + try { + await this.executeTask('system_update', 'system'); + return; + } catch (error) { + throw new Error(`Failed to update system: ${error.message}`); + } + } + + /** + * Create a task object + */ + createTask(type, command, metadata = {}) { + const task = { + id: Date.now().toString(), + command: command, + type: type, + status: 'running', + createdAt: new Date().toISOString(), + output: `Starting ${type} operation...`, + error: null, + ...metadata + }; + + return task; + } + + /** + * Execute a task with enhanced event emission + */ + async executeTask(action, appName, config = '', displayLabel = '') { + try { + // If config is a full `libreportal …` command, use it verbatim. Otherwise + // build the standard form and single-quote the config arg. + let command; + if (config && config.startsWith('libreportal')) { + command = config; + } else { + command = `libreportal app ${action} ${appName}`; + if (config) command += ` '${config.replace(/'/g, "'\\''")}'`; + } + + // Create task — POSTs to /api/tasks and returns the authoritative task. + const task = await this.tasksManager.taskManager.createTask(command, action, appName, config); + + // Hook the UI side-effects (auto-expand, log streaming, status interval). + // The bus also delivers `taskCreated` from the SSE feed; AppTabbedManager + // dedupes by appName|action so the double-fire is harmless. + if (this.tasksManager && typeof this.tasksManager.monitorTask === 'function') { + this.tasksManager.monitorTask(task.id, appName, action); + } + + window.dispatchEvent(new CustomEvent('taskCreated', { + detail: { + taskId: task.id, + appName: appName, + action: action, + timestamp: Date.now() + } + })); + + // Show success notification + let appData = null; + try { + appData = this.commands.getAppData ? this.commands.getAppData(appName) : null; + } catch (error) { + console.warn('Could not get app data:', error); + } + const appIcon = appData?.icon || `icons/apps/${task.app}.svg`; + + let taskUrl; + const currentUrl = window.location.href; + + if (currentUrl.includes('/app?=') && appName) { + taskUrl = `/app?=${appName}&tab=tasks&task=${task.id}`; + } else { + taskUrl = `/tasks?=all&task=${task.id}`; + } + + if (window.notificationSystem) { + const displayName = window.getAppDisplayName ? window.getAppDisplayName(appName) : appName; + const typeIcon = (window.tasksManager && window.tasksManager.getTaskTypeIcon + ? window.tasksManager.getTaskTypeIcon({ type: action }) + : null)?.icon || ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + const headline = displayLabel + ? `${displayLabel} task started!` + : `${action.charAt(0).toUpperCase() + action.slice(1)} task started!`; + window.notificationSystem.show( + `App: ${displayName}
    ${headline}`, + 'success', + appName, + taskUrl, + appIcon, + customIcon + ); + } + + return task; + } catch (error) { + console.error(`❌ Failed to execute ${action} task for ${appName}:`, error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to start ${action} task for ${appName}: ${error.message}`); + } + throw error; + } + } + + /** + * Execute task monitoring (separated to avoid duplicate task creation) + */ + async executeTaskMonitoring(task, appName, action) { + // Emit task creation event for AppTabbedManager + window.dispatchEvent(new CustomEvent('taskCreated', { + detail: { + taskId: task.id, + appName: appName, + action: action, + timestamp: Date.now() + } + })); + + // console.log(`✅ Task created: ${task.id} for ${action} on ${appName}`); + + // Set up monitoring for this specific task + this.tasksManager.monitorTask(task.id, appName, action); + + // Try to get app data for better notifications + let appData = null; + try { + appData = this.commands.getAppData ? this.commands.getAppData(appName) : null; + } catch (error) { + console.warn('Could not get app data:', error); + } + const appIcon = appData?.icon || `icons/apps/${task.app}.svg`; + + // Smart URL generation - always include app name + let taskUrl; + const currentUrl = window.location.href; + // console.log('🔍 TaskActions: Current URL:', currentUrl); + + // Always generate URL with app name for proper navigation + if (currentUrl.includes('/app?=') && appName) { + // We're on an app page, maintain app context + taskUrl = `/app?=${appName}&tab=tasks&task=${task.id}`; + } else { + // We're on main tasks page, use normal URL + taskUrl = `/tasks?=all&task=${task.id}`; + } + + // Show success notification with app icon and direct link + if (window.notificationSystem) { + const displayName = window.getAppDisplayName ? window.getAppDisplayName(appName) : appName; + window.notificationSystem.show( + `App: ${displayName}
    + ${action.charAt(0).toUpperCase() + action.slice(1)} task started!`, + 'success', + appName, + taskUrl, + appIcon + ); + } + + return task; + } + + /** + * Update task in the manager + */ + updateTaskInManager(updatedTask) { + const taskIndex = this.tasksManager.tasks.findIndex(t => t.id === updatedTask.id); + if (taskIndex !== -1) { + this.tasksManager.tasks[taskIndex] = updatedTask; + this.tasksManager.renderTasks(); + this.tasksManager.updateStats(); + this.tasksManager.updateSidebarCounts(); + this.tasksManager.generateAppCategories(); + } + } +} + +// Export for use +window.TaskActions = TaskActions; diff --git a/containers/libreportal/frontend/js/components/task/task-commands.js b/containers/libreportal/frontend/js/components/task/task-commands.js new file mode 100755 index 0000000..e33fd0c --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-commands.js @@ -0,0 +1,360 @@ +/** + * Task Commands - Pre-built command templates and execution + * Handles SSH command execution and validation for individual tasks + */ + +class TaskCommands { + constructor() { + this.commandTemplates = { + // App Commands (✅ IMPLEMENTED) + install: 'libreportal app install {appName} {config}', + uninstall: 'libreportal app uninstall {appName}', + restart: 'libreportal app restart {appName}', + start: 'libreportal app start {appName}', + stop: 'libreportal app stop {appName}', + backup: 'libreportal app backup {appName}', + status: 'libreportal app status {appName}', + + // Docker Compose Management (✅ IMPLEMENTED) + up: 'libreportal app up {appName}', + down: 'libreportal app down {appName}', + reload: 'libreportal app reload {appName}', + + // System Commands (✅ IMPLEMENTED) + system_status: 'libreportal system status', + system_update: 'libreportal system update', + system_reset: 'libreportal system reset', + + // Future Commands (❌ NOT YET IMPLEMENTED) + // restore: 'libreportal app restore {appName} {backupId}', + // update_config: 'libreportal config update {appName}', + // system_info: 'libreportal system info', + // system_disk: 'libreportal system disk', + // system_memory: 'libreportal system memory' + }; + + this.commandStatus = { + // ✅ Available in CLI + install: 'implemented', + uninstall: 'implemented', + restart: 'implemented', + start: 'implemented', + stop: 'implemented', + backup: 'implemented', + status: 'implemented', + up: 'implemented', + down: 'implemented', + reload: 'implemented', + system_status: 'implemented', + system_update: 'implemented', + system_reset: 'implemented', + + // ❌ Not yet implemented in CLI + restore: 'not_implemented', + update_config: 'not_implemented', + system_info: 'not_implemented', + system_disk: 'not_implemented', + system_memory: 'not_implemented' + }; + } + + /** + * Generate command from template with parameters + */ + generateCommand(type, params = {}) { + const template = this.commandTemplates[type]; + if (!template) { + throw new Error(`Unknown command type: ${type}`); + } + + let command = template; + Object.keys(params).forEach(key => { + const value = params[key] || ''; + command = command.replace(`{${key}}`, value); + }); + + // Clean up double spaces and trailing spaces + command = command.replace(/\s+/g, ' ').trim(); + + return command; + } + + /** + * Check if command is implemented in CLI + */ + isCommandImplemented(type) { + return this.commandStatus[type] === 'implemented'; + } + + /** + * Get command status + */ + getCommandStatus(type) { + return this.commandStatus[type] || 'unknown'; + } + + /** + * Get only implemented commands + */ + getImplementedCommands() { + return Object.keys(this.commandStatus).filter(cmd => this.commandStatus[cmd] === 'implemented'); + } + + /** + * Get all commands with their status + */ + getAllCommandsWithStatus() { + return Object.keys(this.commandStatus).map(cmd => ({ + command: cmd, + template: this.commandTemplates[cmd], + status: this.commandStatus[cmd] + })); + } + + /** + * Validate command and check if implemented + */ + validateCommand(type, params) { + // Check if command exists + const template = this.commandTemplates[type]; + if (!template) { + throw new Error(`Unknown command type: ${type}`); + } + + // Check if command is implemented + if (!this.isCommandImplemented(type)) { + throw new Error(`Command '${type}' is not yet implemented in the CLI system`); + } + + const requiredParams = { + install: ['appName'], // config is optional + uninstall: ['appName'], + restart: ['appName'], + start: ['appName'], + stop: ['appName'], + backup: ['appName'], + status: ['appName'], + up: ['appName'], + down: ['appName'], + reload: ['appName'] + }; + + const required = requiredParams[type] || []; + const missing = required.filter(param => !params[param]); + + if (missing.length > 0) { + throw new Error(`Missing required parameters: ${missing.join(', ')}`); + } + + return true; + } + + /** + * Execute command via API + */ + async executeCommand(command, taskId) { + try { + //// // console.log(`🚀 Executing command: ${command}`); + + // Add task to local queue using generic endpoint + const taskId = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const taskFileName = `${taskId}.json`; + const taskFilePath = `tasks/queue/${taskFileName}`; + + // console.log('🔍 Creating task file:', taskFilePath); + // console.log('🔍 Task ID:', taskId); + + const task = { + id: taskId, + type: 'install', + app: this.extractAppName(command), + command: command, + config: null, + status: 'queued', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + // console.log('🔍 Task object:', task); + + const response = await fetch('/write-file', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + path: taskFilePath, + content: JSON.stringify(task, null, 2) + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error(`❌ API Error Response:`, errorData); + throw new Error(`Command execution failed: ${response.statusText} - ${errorData.error || 'Unknown error'}`); + } + + const result = await response.json(); + //// // console.log(`✅ Task queued successfully:`, result); + + return { + success: true, + output: `Task queued: ${command}`, + taskId: result.taskId, + exitCode: 0, + queued: true + }; + + } catch (error) { + console.error(`❌ Command execution failed:`, error); + throw new Error(`Command execution failed: ${error.message}`); + } + } + + /** + * Extract app name from command + */ + extractAppName(command) { + const match = command.match(/libreportal app (\w+) (.+)/); + return match ? match[2] : 'unknown'; + } + + /** + * Execute command via file-based task system + */ + async executeCommand(command, taskId) { + //// // console.log(`🔧 Creating task for command: ${command}`); + + try { + // Create task file in queue + const task = { + id: taskId, + command: command, + status: 'queued', + created: new Date().toISOString() + }; + + // Extract app name from command for better task tracking + const appNameMatch = command.match(/libreportal app (\w+) (\w+)/); + if (appNameMatch) { + task.app = appNameMatch[2]; + task.type = appNameMatch[1]; + } + + // Write task file to queue + await this.createTaskFile(task); + + //// // console.log(`✅ Task created: ${taskId}`); + + return { + success: true, + taskId: taskId, + message: 'Task queued for execution' + }; + + } catch (error) { + console.error(`❌ Failed to create task:`, error); + throw new Error(`Task creation failed: ${error.message}`); + } + } + + /** + * Create task file in queue directory + */ + async createTaskFile(task) { + const taskFileName = `${task.id}.json`; + const taskFilePath = `tasks/queue/${taskFileName}`; + + try { + // Write task file using generic endpoint + const response = await fetch('/write-file', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + path: taskFilePath, + content: JSON.stringify(task, null, 2) + }) + }); + + if (!response.ok) { + throw new Error(`Failed to create task file: ${response.statusText}`); + } + + return await response.json(); + + } catch (error) { + // Fallback: create file directly (for development) + console.warn('API not available, using fallback method'); + await this.writeTaskFileDirectly(task); + } + } + + /** + * Direct task file creation (fallback method) + */ + async writeTaskFileDirectly(task) { + // This would be implemented server-side + // For now, we'll simulate the task creation + //// // console.log(`Task file created: ${task.id}.json`); + return { success: true, taskId: task.id }; + } + + /** + * Get available command types + */ + getCommandTypes() { + return Object.keys(this.commandTemplates); + } + + /** + * Get command template for a type + */ + getCommandTemplate(type) { + return this.commandTemplates[type]; + } + + /** + * Get app data for icon and URL information + */ + getAppData(appName) { + if (window.apps && Array.isArray(window.apps)) { + const target = String(appName || '').toLowerCase(); + const app = window.apps.find(app => { + const appCommandName = (app.command || '').split(' ').pop(); + return appCommandName.toLowerCase() === target; + }); + if (app) return app; + } + + // Fallback: create minimal app data + return { + name: appName, + icon: `icons/apps/${appName}.svg`, + command: `libreportal app install ${appName}` + }; + } + + /** + * Trigger enhanced notification with app icon and action button + * + * `customIcon` is forwarded to NotificationSystem.show so callers can + * supply a task-type emoji (install ✅, backup 💾, etc.) for the leftmost + * icon slot — same style every other task-related notification uses. + */ + triggerNotification(message, type = 'info', appName = null, appUrl = null, appIcon = null, customIcon = null) { + if (window.notificationSystem && typeof window.notificationSystem.show === 'function') { + // Use enhanced notification system + window.notificationSystem.show(message, type, appName, appUrl, appIcon, customIcon); + } else if (typeof ConfigShared !== 'undefined' && ConfigShared.showNotification) { + // Fallback to basic notification + ConfigShared.showNotification(message, type); + } else { + //// // console.log(`🔔 ${type.toUpperCase()}: ${message}`); + } + } +} + +// Export for use +window.TaskCommands = TaskCommands; diff --git a/containers/libreportal/frontend/js/components/task/task-event-bus.js b/containers/libreportal/frontend/js/components/task/task-event-bus.js new file mode 100755 index 0000000..3b75415 --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-event-bus.js @@ -0,0 +1,139 @@ +/** + * TaskEventBus — single SSE connection for the page. + * + * Connects to /api/tasks/events. Translates the server's SSE events + * (task.upsert, task.deleted, task.log) into the existing window-level + * CustomEvents that the rest of the UI already listens for: + * - taskCreated (when a brand-new task appears) + * - taskUpdated (status change while still active) + * - taskCompleted (status -> completed | failed | cancelled) + * - taskLog (new log lines for a running task) + * - taskDeleted (task removed) + * + * The bus also exposes a `tasks` Map keyed by id holding the latest known + * task object — components can read this synchronously instead of fetching. + */ + +class TaskEventBus { + constructor() { + this.tasks = new Map(); // id -> latest task object + this.eventSource = null; + this.reconnectTimer = null; + this.connected = false; + + // Track previous status per task so we can decide created vs updated vs completed. + this._lastStatus = new Map(); + } + + start() { + if (this.eventSource) return; + this._open(); + } + + stop() { + if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } + this.connected = false; + } + + // Convenience accessors used by UI components. + getTask(id) { return this.tasks.get(id) || null; } + getRunningTasks() { + const out = []; + for (const t of this.tasks.values()) { + if (t.status === 'running' || t.status === 'queued' || t.status === 'pending') out.push(t); + } + return out; + } + getRunningForApp(appName) { + return this.getRunningTasks().filter(t => t.app === appName); + } + + // ---- internals -------------------------------------------------------- + + _open() { + try { + this.eventSource = new EventSource('/api/tasks/events'); + } catch (err) { + this._scheduleReconnect(); + return; + } + + this.eventSource.addEventListener('ready', () => { + this.connected = true; + window.dispatchEvent(new CustomEvent('taskBusReady')); + }); + + this.eventSource.addEventListener('task.upsert', (e) => { + let task; try { task = JSON.parse(e.data); } catch { return; } + if (!task || !task.id) return; + this._handleUpsert(task); + }); + + this.eventSource.addEventListener('task.deleted', (e) => { + let payload; try { payload = JSON.parse(e.data); } catch { return; } + if (!payload || !payload.id) return; + this.tasks.delete(payload.id); + this._lastStatus.delete(payload.id); + window.dispatchEvent(new CustomEvent('taskDeleted', { detail: { id: payload.id } })); + }); + + this.eventSource.addEventListener('task.log', (e) => { + let payload; try { payload = JSON.parse(e.data); } catch { return; } + if (!payload || !payload.id || typeof payload.chunk !== 'string') return; + window.dispatchEvent(new CustomEvent('taskLog', { + detail: { id: payload.id, chunk: payload.chunk } + })); + }); + + this.eventSource.onerror = () => { + // Browser will auto-retry, but we want a deterministic backoff on top + // so we don't hammer the server during a long outage. + this.connected = false; + this.eventSource && this.eventSource.close(); + this.eventSource = null; + this._scheduleReconnect(); + }; + } + + _scheduleReconnect() { + if (this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this._open(); + }, 3000); + } + + _handleUpsert(task) { + const prevStatus = this._lastStatus.get(task.id); + const isNew = !this.tasks.has(task.id); + this.tasks.set(task.id, task); + this._lastStatus.set(task.id, task.status); + + const detail = { + taskId: task.id, + appName: task.app || null, + action: task.type || 'unknown', + status: task.status, + task, + timestamp: Date.now() + }; + + if (isNew) { + window.dispatchEvent(new CustomEvent('taskCreated', { detail })); + } + + const isTerminal = task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'; + const wasTerminal = prevStatus === 'completed' || prevStatus === 'failed' || prevStatus === 'cancelled'; + + if (isTerminal && !wasTerminal) { + window.dispatchEvent(new CustomEvent('taskCompleted', { detail })); + } else if (!isNew) { + window.dispatchEvent(new CustomEvent('taskUpdated', { detail })); + } + } +} + +// One instance per page. +window.taskEventBus = window.taskEventBus || new TaskEventBus(); +window.TaskEventBus = TaskEventBus; diff --git a/containers/libreportal/frontend/js/components/task/task-global-functions.js b/containers/libreportal/frontend/js/components/task/task-global-functions.js new file mode 100755 index 0000000..50bc28a --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-global-functions.js @@ -0,0 +1,28 @@ +/** + * Global Functions for Individual Task Actions + * Extends the tasks manager with global action functions for individual tasks + */ + +// Task global functions initialization is now handled by SystemLoader +// Global task functions will be set up centrally when TasksManager is available + +function setupTaskGlobalFunctions() { + if (window.TasksManager || window.tasksManager) { + // Use whichever instance is available + const tasksManager = window.tasksManager || window.TasksManager; + + // Add modular action functions to global scope + window.installApp = (appName, config = '') => tasksManager.router.routeAction('install', { appName, config }); + window.uninstallApp = (appName) => tasksManager.router.routeAction('uninstall', { appName }); + window.restartApp = (appName) => tasksManager.router.routeAction('restart', { appName }); + window.startApp = (appName) => tasksManager.router.routeAction('start', { appName }); + window.stopApp = (appName) => tasksManager.router.routeAction('stop', { appName }); + window.backupApp = (appName) => tasksManager.router.routeAction('backup', { appName }); + window.updateConfig = (appName) => tasksManager.router.routeAction('update_config', { appName }); + window.systemUpdate = () => tasksManager.router.routeAction('system_update'); + + //console.log('✅ Task action functions registered globally'); + } else { + console.warn('⚠️ TasksManager not found, action functions not registered'); + } +} diff --git a/containers/libreportal/frontend/js/components/task/task-manager.js b/containers/libreportal/frontend/js/components/task/task-manager.js new file mode 100755 index 0000000..3182018 --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-manager.js @@ -0,0 +1,178 @@ +/** + * TaskManager — thin client over /api/tasks. + * + * Replaces the old direct-file-operation approach. We never touch task files + * or the queue from the browser anymore — the server owns them. State updates + * arrive via SSE through TaskEventBus; this class just performs CRUD. + */ + +class TaskManager { + constructor() {} + + /** + * Create a new task. Returns the server's authoritative task object. + * Side effect: a `taskCreated` window event will fire from the SSE bus + * within milliseconds — but the POST response also contains the task, + * so callers that need the id immediately can use the return value. + */ + async createTask(command, type = 'custom', app = null, config = '') { + const res = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command, type, app, config }) + }); + if (!res.ok) throw new Error(`Failed to create task: HTTP ${res.status}`); + const task = await res.json(); + + if (window.tasksManager) window.tasksManager.highlightedTaskId = task.id; + return task; + } + + async getTask(taskId) { + if (window.taskEventBus) { + const cached = window.taskEventBus.getTask(taskId); + if (cached) return cached; + } + const res = await fetch(`/api/tasks/${taskId}`); + if (res.status === 404) return null; + if (!res.ok) return null; + return res.json(); + } + + async getTaskSummary(taskId) { + const task = await this.getTask(taskId); + if (!task) return null; + return { + id: task.id, + command: task.command, + type: task.type, + app: task.app, + config: task.config, + status: task.status, + createdAt: task.created_at || task.createdAt, + startedAt: task.started_at || task.startedAt, + completedAt: task.completed_at || task.completedAt, + hasOutput: false, + hasError: !!task.error_message, + outputLength: 0, + errorLength: task.error_message ? task.error_message.length : 0 + }; + } + + async listTasks() { + const res = await fetch('/api/tasks'); + if (!res.ok) return []; + return res.json(); + } + + async cancelTask(taskId) { + const res = await fetch(`/api/tasks/${taskId}/cancel`, { method: 'POST' }); + if (!res.ok) throw new Error(`Cancel failed: HTTP ${res.status}`); + return res.json(); + } + + async deleteTask(taskId, { force = false } = {}) { + const url = force + ? `/api/tasks/${taskId}?force=1` + : `/api/tasks/${taskId}`; + const res = await fetch(url, { method: 'DELETE' }); + if (!res.ok) throw new Error(`Delete failed: HTTP ${res.status}`); + return true; + } + + /** + * Read the task log. Returns the last `maxLines` lines as an array. + * The SSE bus delivers incremental log chunks via `taskLog` events; this + * is for "open the modal and dump the full output" use-cases. + */ + async readTaskLog(taskId, maxLines = 1000) { + try { + const res = await fetch(`/api/tasks/${taskId}/log`); + if (!res.ok) return []; + const text = await res.text(); + const lines = text.split('\n'); + return lines.length > maxLines ? lines.slice(-maxLines) : lines; + } catch { return []; } + } + + async readFullTaskLog(taskId) { + try { + const res = await fetch(`/api/tasks/${taskId}/log`); + if (!res.ok) return ''; + return res.text(); + } catch { return ''; } + } + + /** + * Lightweight log streamer for callers that want a callback per chunk. + * Backed by SSE: registers a window listener for `taskLog` events that + * match the requested taskId. + */ + streamTaskLog(taskId, onNewLines, onError) { + let stopped = false; + const handler = (event) => { + if (stopped) return; + const detail = event.detail || {}; + if (detail.id !== taskId || typeof detail.chunk !== 'string') return; + try { + const lines = detail.chunk.split('\n').filter(l => l.length > 0); + if (lines.length > 0) onNewLines(lines); + } catch (err) { onError && onError(err); } + }; + window.addEventListener('taskLog', handler); + return { + stop: () => { stopped = true; window.removeEventListener('taskLog', handler); }, + isStreaming: () => !stopped + }; + } + + /** + * ANSI -> HTML colour parser (kept from the previous TaskManager so log + * rendering doesn't lose formatting). + */ + parseAnsiColors(text) { + const ansiRegex = /\x1b\[[0-9;]*m/g; + const colorMap = { + '30':'black','31':'red','32':'green','33':'yellow','34':'blue','35':'magenta','36':'cyan','37':'white', + '90':'darkgray','91':'lightred','92':'lightgreen','93':'lightyellow','94':'lightblue','95':'lightmagenta','96':'lightcyan','97':'lightwhite' + }; + const bgColorMap = { + '40':'black','41':'red','42':'green','43':'yellow','44':'blue','45':'magenta','46':'cyan','47':'white', + '100':'darkgray','101':'lightred','102':'lightgreen','103':'lightyellow','104':'lightblue','105':'lightmagenta','106':'lightcyan','107':'lightwhite' + }; + let result = ''; + let cursor = 0; + let m; + while ((m = ansiRegex.exec(text)) !== null) { + result += text.substring(cursor, m.index); + const codes = m[0].slice(2, -1).split(';'); + const styles = []; + let reset = false; + for (const code of codes) { + if (code === '0' || code === '') reset = true; + else if (code === '1') styles.push('font-weight: bold'); + else if (code === '4') styles.push('text-decoration: underline'); + else if (colorMap[code]) styles.push(`color: ${colorMap[code]}`); + else if (bgColorMap[code]) styles.push(`background-color: ${bgColorMap[code]}`); + } + if (styles.length) result += ``; + else if (reset) result += ''; + cursor = m.index + m[0].length; + } + result += text.substring(cursor); + const open = (result.match(//g) || []).length; + result += ''.repeat(Math.max(0, open - close)); + return result; + } + + generateTaskId() { + return `task_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = TaskManager; +} else { + window.TaskManager = TaskManager; +} diff --git a/containers/libreportal/frontend/js/components/task/task-router.js b/containers/libreportal/frontend/js/components/task/task-router.js new file mode 100755 index 0000000..a2ee193 --- /dev/null +++ b/containers/libreportal/frontend/js/components/task/task-router.js @@ -0,0 +1,227 @@ +/** + * Task Router - Action routing and dispatch for individual tasks + * Handles action routing, queuing, and error management + */ + +class TaskRouter { + constructor(tasksManager, actions) { + this.tasksManager = tasksManager; + this.actions = actions; + this.commandQueue = []; + this.isProcessing = false; + } + + /** + * Route action to appropriate handler + */ + async routeAction(action, params = {}) { + try { + //console.log(`🎯 Routing action: ${action}`, params); + + switch (action) { + case 'install': + return await this.actions.installApp(params.appName, params.config, params.resetNetwork); + + case 'uninstall': + return await this.actions.uninstallApp(params.appName, params.deleteImage, params.deleteTasks); + + case 'restart': + return await this.actions.restartApp(params.appName); + + case 'start': + return await this.actions.startApp(params.appName); + + case 'stop': + return await this.actions.stopApp(params.appName); + + case 'backup': + return await this.actions.backupApp(params.appName, params.customPassword); + + case 'restore': + return await this.actions.restoreApp( + params.appName, + params.location, + params.backupFile, + params.password + ); + + case 'delete': + return await this.actions.deleteBackup(params.appName, params.backupFile, params.deleteRemote1, params.deleteRemote2); + + case 'delete_all': + return await this.actions.deleteAllBackups(params.appName, params.deleteRemote1, params.deleteRemote2); + + case 'update_config': + return await this.actions.updateConfig(params.appName); + + case 'config_update': + return await this.actions.configUpdate(params.changes); + + case 'system_update': + return await this.actions.systemUpdate(); + + case 'tool': + return await this.actions.runTool(params.appName, params.toolName, params.toolArgs, params.toolLabel); + + default: + throw new Error(`Unknown action: ${action}`); + } + } catch (error) { + console.error(`❌ Action routing failed: ${error.message}`); + throw error; + } + } + + /** + * Queue action for execution + */ + queueAction(action, params = {}) { + const queuedTask = { + id: Date.now().toString(), + action: action, + params: params, + timestamp: new Date().toISOString(), + status: 'queued' + }; + + this.commandQueue.push(queuedTask); + //console.log(`📋 Queued action: ${action}`, params); + + // Start processing if not already running + if (!this.isProcessing) { + this.processQueue(); + } + + return queuedTask.id; + } + + /** + * Process the command queue + */ + async processQueue() { + if (this.isProcessing || this.commandQueue.length === 0) { + return; + } + + this.isProcessing = true; + //console.log('🔄 Processing command queue...'); + + while (this.commandQueue.length > 0) { + const queuedTask = this.commandQueue.shift(); + + try { + //console.log(`⚡ Executing queued action: ${queuedTask.action}`); + await this.routeAction(queuedTask.action, queuedTask.params); + + queuedTask.status = 'completed'; + //console.log(`✅ Completed queued action: ${queuedTask.action}`); + + } catch (error) { + queuedTask.status = 'failed'; + queuedTask.error = error.message; + console.error(`❌ Failed queued action: ${queuedTask.action}`, error); + + if (window.notificationSystem) { + // Pull the per-action icon if we know it; falls back to the + // generic error SVG via customIcon=null. + const typeIcon = (window.tasksManager && window.tasksManager.getTaskTypeIcon + ? window.tasksManager.getTaskTypeIcon({ type: queuedTask.action }) + : null)?.icon || ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + window.notificationSystem.show( + `Queued action failed: ${error.message}`, + 'error', + null, + null, + null, + customIcon + ); + } + } + } + + this.isProcessing = false; + //console.log('✅ Command queue processing complete'); + } + + /** + * Get queue status + */ + getQueueStatus() { + return { + isProcessing: this.isProcessing, + queueLength: this.commandQueue.length, + queuedTasks: [...this.commandQueue] + }; + } + + /** + * Clear the queue + */ + clearQueue() { + const clearedCount = this.commandQueue.length; + this.commandQueue = []; + + if (window.notificationSystem) { + const customIcon = '🗑️'; + window.notificationSystem.show( + `Cleared ${clearedCount} queued actions`, + 'info', + null, + null, + null, + customIcon + ); + } + + return clearedCount; + } + + /** + * Get available actions + */ + getAvailableActions() { + return [ + 'install', + 'uninstall', + 'restart', + 'start', + 'stop', + 'backup', + 'delete', + 'delete_all', + 'update_config', + 'system_update', + 'tool' + ]; + } + + /** + * Validate action parameters + */ + validateAction(action, params) { + const requiredParams = { + install: ['appName'], + uninstall: ['appName'], + restart: ['appName'], + start: ['appName'], + stop: ['appName'], + backup: ['appName'], + delete: ['appName', 'backupFile'], + update_config: ['appName'], + tool: ['appName', 'toolName'] + }; + + const required = requiredParams[action] || []; + const missing = required.filter(param => !params[param]); + + if (missing.length > 0) { + throw new Error(`Missing required parameters for ${action}: ${missing.join(', ')}`); + } + + return true; + } +} + +// Export for use +window.TaskRouter = TaskRouter; diff --git a/containers/libreportal/frontend/js/components/tasks/tasks-manager.js b/containers/libreportal/frontend/js/components/tasks/tasks-manager.js new file mode 100755 index 0000000..476b197 --- /dev/null +++ b/containers/libreportal/frontend/js/components/tasks/tasks-manager.js @@ -0,0 +1,2457 @@ +/** + * Tasks Manager - Tasks Page UI Management + * Handles the tasks page display, filtering, and UI interactions + * Uses individual task operations from the task/ folder + */ +class TasksManager { + constructor() { + this.tasks = []; + this.currentCategory = 'all'; + this.highlightedTaskId = null; + this.taskManager = new TaskManager(); + this.init(); + + // Start global live log updater + this.startGlobalLiveLogUpdater(); + + this.refreshInterval = null; + this.activeLogStreams = new Map(); // Track active log streams + this.autoRefreshIntervals = new Map(); // Track auto-refresh intervals for running tasks + + // Initialize modular components for individual task operations (only if available) + try { + this.commands = new TaskCommands(); + this.actions = new TaskActions(this, this.commands); + this.router = new TaskRouter(this, this.actions); + this.taskManager = new TaskManager(); + } catch (error) { + console.warn('⚠️ Task system components not yet loaded, will initialize later:', error.message); + this.commands = null; + this.actions = null; + this.router = null; + this.taskManager = null; + } + + // Initialize from URL parameters + this.initializeFromURL(); + + // Load tasks and setup (only if task system is available) + if (this.commands) { + this.loadTasks(); + } else { + //// // console.log('⏳ Task system will be initialized later'); + } + + // Setup global functions + this.setupGlobalFunctions(); + + // Subscribe to the SSE bus once for the page so every visible task row + // reacts to status changes, not just ones spawned in this session. + this.setupTaskBusListeners(); + + // Setup mobile menu + this.setupMobileMenu(); + + //// // console.log('✅ TasksManager initialized with modular architecture'); + } + + // Initialize task system after scripts are loaded + initializeTaskSystem() { + try { + //// // console.log('🔧 Initializing task system components...'); + + // Check if TaskManager is available + if (typeof TaskManager === 'undefined') { + console.warn('⚠️ TaskManager not available yet, deferring initialization'); + return false; + } + + this.commands = new TaskCommands(); + this.actions = new TaskActions(this, this.commands); + this.router = new TaskRouter(this, this.actions); + this.taskManager = new TaskManager(); // Add TaskManager for task operations + + // Now load tasks since system is ready + this.loadTasks(); + + //// // console.log('✅ Task system initialized successfully'); + return true; + } catch (error) { + console.error('❌ Failed to initialize task system:', error); + return false; + } + } + + // Main initialization method for the tasks page + async init() { + //// // console.log('🔧 Initializing TasksManager...'); + + // Initialize task system if not already done + if (!this.taskManager) { + let initialized = false; + let attempts = 0; + const maxAttempts = 5; + + while (!initialized && attempts < maxAttempts) { + //// // console.log(`🔄 Attempting to initialize task system (${attempts + 1}/${maxAttempts})...`); + initialized = this.initializeTaskSystem(); + + if (!initialized) { + attempts++; + await new Promise(resolve => setTimeout(resolve, 200)); // Wait 200ms + } + } + + if (!initialized) { + console.warn('⚠️ Task system initialization failed after retries'); + } + } + + // Setup refresh interval + this.setupRefreshInterval(); + + // Reconstructor() { + this.tasks = []; + this.taskManager = new TaskManager(); + this.activeLogStreams = new Map(); // Track active log streams + // console.log('🔍 TasksManager initialized'); + } + + initializeFromURL() { + const currentUrl = new URL(window.location.href); + const searchParams = currentUrl.searchParams; + + // Check if we're on the main tasks page (not app page) + const isMainTasksPage = currentUrl.pathname === '/tasks' || currentUrl.pathname === '/tasks.html'; + + if (isMainTasksPage) { + // On main tasks page, get category from URL + this.currentCategory = searchParams.get('') || 'all'; + + // Only check for specific task parameter if we're not coming from an app page + const taskParam = searchParams.get('task'); + if (taskParam) { + // console.log(`🎯 Found task parameter in URL: ${taskParam} on main tasks page`); + this.highlightedTaskId = taskParam; + } else { + // Clear any existing highlighted task when on main tasks page without task param + this.highlightedTaskId = null; + // console.log(`🎯 Clearing highlighted task on main tasks page`); + } + } else { + // Not on main tasks page, get default filter from localStorage + this.currentCategory = localStorage.getItem('tasksDefaultFilter') || 'all'; + this.highlightedTaskId = null; // Always clear when not on tasks page + } + + // console.log(`🎯 Tasks category from URL: ${this.currentCategory}`); + } + + updateURL(category, taskId = null) { + // Update URL without page reload and without hash + let newURL = `/tasks?=${category}`; + if (taskId) { + newURL += `&task=${taskId}`; + } + + // Prevent the SPA from interfering + if (window.librePortalSPA) { + window.librePortalSPA.currentRoute = newURL; + } + + // Use a timeout to avoid conflicts with SPA routing + setTimeout(() => { + window.history.pushState({ category, taskId }, '', newURL); + }, 0); + } + + async init() { + //// // console.log('🔧 Initializing TasksManager...'); + + // Load initial tasks and refresh sidebar counts + await this.loadTasks(); + + // Force a refresh to ensure latest data + // console.log('🔄 Refreshing tasks data on initialization...'); + await this.loadTasks(); + + // Setup auto-refresh + this.setupAutoRefresh(); + + // Setup global functions + this.setupGlobalFunctions(); + + // Subscribe to the SSE bus once for the page so every visible task row + // reacts to status changes, not just ones spawned in this session. + this.setupTaskBusListeners(); + + // Setup mobile menu + this.setupMobileMenu(); + + //// // console.log('✅ TasksManager initialized'); + } + + async refreshTasks() { + // Show refresh notification + const refreshNotification = window.notificationSystem.info( + '🔄 Refreshing tasks...', + 'Tasks', + null, + null + ); + + try { + await this.loadTasks(); + + // Remove refresh notification and show success + if (refreshNotification && refreshNotification.remove) { + refreshNotification.remove(); + } + + if (window.notificationSystem) { + window.notificationSystem.success( + '🔄 Tasks refreshed successfully', + 'Tasks', + null, + null + ); + } + } catch (error) { + console.error('Error refreshing tasks:', error); + + // Remove refresh notification and show error + if (refreshNotification && refreshNotification.remove) { + refreshNotification.remove(); + } + + if (window.notificationSystem) { + window.notificationSystem.error( + `⚠️ Failed to refresh tasks: ${error.message}`, + 'Tasks', + null, + null + ); + } + } + } + + async loadTasks() { + try { + //// // console.log('🔄 Loading tasks from file system...'); + + // Check if task system is available + if (!this.taskManager) { + console.warn('⚠️ Task system not yet initialized, skipping task loading'); + this.tasks = []; + return; + } + + // Get tasks using new system + // console.log('📥 Getting tasks using new queue system...'); + + // Get queue and current status + let queue = []; + let current = {}; + + try { + const queueResponse = await fetch('/read-file?path=tasks/queue.json'); + if (queueResponse.ok) { + const queueText = await queueResponse.text(); + if (queueText.trim()) { // Only parse if not empty + try { + queue = JSON.parse(queueText); + } catch (parseError) { + console.warn('⚠️ Invalid queue.json format, starting with empty queue'); + queue = []; + } + } + } + } catch (error) { + // console.log('📝 Queue file not found, starting with empty queue'); + } + + try { + const currentResponse = await fetch('/read-file?path=tasks/current.json'); + if (currentResponse.ok) { + const currentText = await currentResponse.text(); + if (currentText.trim()) { // Only parse if not empty + try { + current = JSON.parse(currentText); + } catch (parseError) { + console.warn('⚠️ Invalid current.json format, treating as empty'); + current = {}; + } + } + } + } catch (error) { + // console.log('📝 Current file not found, no current task'); + } + + // Load individual task files + const allTasks = []; + + // Add queued tasks + for (const taskId of queue) { + try { + const task = await this.taskManager.getTask(taskId); + if (task) allTasks.push(task); + } catch (error) { + console.warn(`⚠️ Failed to load queued task ${taskId}:`, error); + } + } + + // Add current task if different from queue + if (current.id && !queue.includes(current.id)) { + try { + const task = await this.taskManager.getTask(current.id); + if (task) allTasks.push(task); + } catch (error) { + console.warn(`⚠️ Failed to load current task ${current.id}:`, error); + } + } + + // Scan tasks folder for all task files (including completed ones) - OPTIMIZED + try { + // console.log('🔍 Scanning tasks folder for all task files...'); + const tasksResponse = await fetch('/read-directory?path=tasks'); + if (tasksResponse.ok) { + const files = await tasksResponse.json(); + const taskFiles = files.filter(file => + file.endsWith('.json') && + file !== 'queue.json' && + file !== 'current.json' + ); + + // console.log(`📁 Found ${taskFiles.length} task files in folder`); + + // OPTIMIZATION: Batch load tasks instead of individual calls + const missingTaskIds = taskFiles + .map(file => file.replace('.json', '')) + .filter(taskId => !allTasks.find(task => task.id === taskId)); + + if (missingTaskIds.length > 0) { + // console.log(`📦 Batch loading ${missingTaskIds.length} missing tasks...`); + try { + const batchResponse = await fetch('/read-tasks-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskIds: missingTaskIds }) + }); + + if (batchResponse.ok) { + const batchTasks = await batchResponse.json(); + batchTasks.forEach(task => { + if (task) { + allTasks.push(task); + // console.log(`✅ Added completed task ${task.id} from batch load`); + } + }); + } else { + // Fallback to individual loading if batch endpoint not available + // console.log('⚠️ Batch endpoint not available, falling back to individual loading'); + await this.loadTasksIndividually(missingTaskIds, allTasks); + } + } catch (error) { + console.warn('⚠️ Batch loading failed, falling back to individual loading:', error); + await this.loadTasksIndividually(missingTaskIds, allTasks); + } + } + } + } catch (error) { + console.warn('⚠️ Failed to scan tasks folder:', error); + } + + //// // console.log('📊 Task counts:', { + //queued: queuedTasks.length, + //processing: processingTasks.length, + //completed: completedTasks.length + //}); + + // Combine all tasks + this.tasks = allTasks; + + // Sort by creation time (newest first) + this.tasks.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + // console.log(`✅ Loaded ${this.tasks.length} tasks`); + //// // console.log('📋 All tasks:', this.tasks); + + this.renderTasks(); + this.updateStats(); + this.updateSidebarCounts(); + this.generateAppCategories(); + } catch (error) { + console.error('❌ Failed to load tasks:', error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to load tasks: ${error.message}`); + } + this.tasks = []; + this.renderTasks(); + this.updateStats(); + this.updateSidebarCounts(); + this.generateAppCategories(); + } + } + + async loadTasksIndividually(taskIds, allTasks) { + // Fallback method for individual task loading + for (const taskId of taskIds) { + try { + const task = await this.taskManager.getTask(taskId); + if (task) { + allTasks.push(task); + // console.log(`✅ Added completed task ${taskId} from individual load`); + } + } catch (error) { + console.warn(`⚠️ Failed to load task ${taskId}:`, error); + } + } + } + + renderTasks() { + const container = document.getElementById('tasks-list'); + if (!container) return; + + // Capture which task panels are currently open and where the user + // is scrolled to *before* the innerHTML rebuild. Without this, + // every refresh slams every expanded log shut and snaps to the top + // — which makes watching a live task feel hostile. + const expandedIds = new Set(); + container.querySelectorAll('.task-details.task-details-open').forEach(el => { + const taskId = (el.id || '').replace(/^details-/, ''); + if (taskId) expandedIds.add(taskId); + }); + const scrollParent = container.closest('.main') || document.scrollingElement || document.documentElement; + const savedScrollTop = scrollParent ? scrollParent.scrollTop : window.scrollY; + + // Filter tasks based on current category and specific task + let filteredTasks = this.filterTasksByCategory(this.tasks, this.currentCategory); + + // Note: Don't filter out other tasks when one is highlighted + // highlightedTaskId is only for auto-expansion, not for filtering + + if (filteredTasks.length === 0) { + let message; + if (this.highlightedTaskId) { + message = `Task ${this.highlightedTaskId} not found`; + } else { + const categoryName = this.getCategoryDisplayName(this.currentCategory); + message = `No ${categoryName.toLowerCase()} tasks found`; + } + + container.innerHTML = ` +
    +
    +
    + info + $ ${message} + just now +
    +
    +
    +
    + +
    +
    Run a task or install an application to see your task list here
    +
    +
    + `; + return; + } + + // Sort tasks by creation time (newest first) + const sortedTasks = filteredTasks.sort((a, b) => + new Date(b.createdAt) - new Date(a.createdAt) + ); + + const html = sortedTasks.map(task => this.renderTask(task)).join(''); + container.innerHTML = html; + + // Re-open everything that was open before the rebuild and re-attach + // log streaming so live tasks keep updating without an extra click. + expandedIds.forEach(taskId => { + const details = document.getElementById(`details-${taskId}`); + if (!details) return; + details.style.display = 'block'; + details.classList.add('task-details-open'); + const btn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`); + if (btn) btn.classList.add('expanded'); + + const t = this.tasks.find(x => x.id === taskId); + if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) { + if (typeof this.startLogStreaming === 'function') this.startLogStreaming(taskId, t); + } else { + if (typeof this.loadTaskLogs === 'function') this.loadTaskLogs(taskId); + } + }); + + // Restore scroll. Defer one frame so the browser has laid out the + // new content height before we scroll back into it. + if (scrollParent) { + requestAnimationFrame(() => { scrollParent.scrollTop = savedScrollTop; }); + } + } + + filterTasksByCategory(tasks, category) { + switch (category) { + case 'all': + return tasks; + + case 'queued': + case 'running': + case 'completed': + case 'failed': + return tasks.filter(task => task.status === category); + + case 'install': + case 'uninstall': + return tasks.filter(task => task.type === category); + + case 'management': + return tasks.filter(task => + ['restart', 'start', 'stop'].includes(task.type) + ); + + case 'backup': + return tasks.filter(task => + ['backup', 'restore', 'delete'].includes(task.type) + ); + + case 'config': + return tasks.filter(task => task.type === 'update_config'); + + default: + // Assume it's an app name + return tasks.filter(task => task.app === category); + } + } + + getCategoryDisplayName(category) { + const displayNames = { + 'all': 'All Tasks', + 'queued': 'Queued', + 'running': 'Running', + 'completed': 'Completed', + 'failed': 'Failed', + 'install': 'Install', + 'uninstall': 'Uninstall', + 'management': 'Management', + 'backup': 'Backups', + 'config': 'Configuration', + 'libreportal': 'LibrePortal' + }; + + return displayNames[category] || category.charAt(0).toUpperCase() + category.slice(1); + } + + renderTask(task) { + // console.log(`🔍 renderTask called with task:`, task); + + // Debug undefined status + if (!task.status) { + console.warn(`⚠️ Task ${task.id} has undefined status:`, task); + } + + const statusClass = `status-${task.status || 'unknown'}`; + const timeAgo = this.getTimeAgo(task.createdAt); + const isRunning = task.status === 'running'; + const isFailed = task.status === 'failed'; + const hasOutput = task.output && task.output.length > 0; + const hasError = task.error && task.error.length > 0; + const hasLogs = task.log && Array.isArray(task.log) && task.log.length > 0; + + // console.log(`🔍 Task fields check:`, { + //hasOutput: hasOutput, + //hasError: hasError, + //hasLogs: hasLogs, + //isRunning: isRunning, + //outputLength: task.output ? task.output.length : 0, + //error: task.error, + //logCount: task.log ? task.log.length : 0 + //}); + + const executionTime = task.startedAt && task.completedAt ? + this.calculateExecutionTime(task.startedAt, task.completedAt) : null; + + // console.log('🔍 renderTask debug:', { + //taskStatus: task.status, + //statusClass: statusClass, + //statusDisplay: task.status ? task.status.toUpperCase() : 'UNKNOWN' + //}); + + return ` +
    +
    +
    + ${this.renderTaskIcons(task)} + ${this.formatCommandForUser(task)} + ${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'} + ${timeAgo} + ${executionTime ? `⏱️ ${executionTime}` : ''} +
    +
    + ${isFailed ? ` + + ` : ''} + + +
    +
    + + +
    + +
    +
    + Task ID: ${task.id} +
    +
    + Type: ${task.type || 'unknown'} +
    +
    + App: ${task.app ? `${task.app}` : 'system'} +
    +
    + Created: ${new Date(task.createdAt).toLocaleString()} +
    + ${task.startedAt ? ` +
    + Started: ${new Date(task.startedAt).toLocaleString()} +
    + ` : ''} + ${task.completedAt ? ` +
    + Completed: ${new Date(task.completedAt).toLocaleString()} +
    + ` : ''} + ${executionTime ? ` +
    + Execution Time: ${executionTime} +
    + ` : ''} +
    + + +
    +
    + ${hasLogs ? + task.log.map(log => `
    ${this.taskManager.parseAnsiColors(log)}
    `).join('') : + '
    Loading logs...
    ' + } +
    +
    +
    + + `; + } + + getStatusIcon(status) { + const icons = { + queued: '⏳', + running: '🔄', + completed: '✅', + failed: '❌' + }; + return icons[status] || '📋'; + } + + formatDuration(seconds) { + if (seconds < 60) { + return `${seconds}s`; + } else if (seconds < 3600) { + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + } else { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + } + + calculateExecutionTime(startedAt, completedAt) { + if (!startedAt || !completedAt) return null; + + const start = new Date(startedAt); + const end = new Date(completedAt); + const durationMs = end - start; + const durationSeconds = Math.floor(durationMs / 1000); + + return this.formatDuration(durationSeconds); + } + + extractTimestamp(logEntry) { + const match = logEntry.match(/\[([^\]]+)\]/); + return match ? match[1] : ''; + } + + extractLogMessage(logEntry) { + const match = logEntry.match(/\] (.+)$/); + return match ? match[1] : logEntry; + } + + async viewTaskLogs(taskId) { + // Open logs in a modal or new view + const task = this.tasks.find(t => t.id === taskId); + if (!task) return; + + // Load full logs for modal + const fullLogs = await this.taskManager.readFullTaskLog(taskId); + + // Create modal with streaming logs + const modal = document.createElement('div'); + modal.className = 'task-logs-modal'; + modal.innerHTML = ` + + + `; + + document.body.appendChild(modal); + + // Update line count + const lineCount = fullLogs.split('\n').length; + const lineCountElement = modal.querySelector('.log-line-count'); + if (lineCountElement) { + lineCountElement.textContent = `(${lineCount} lines)`; + } + + // Start streaming if task is running + if (task.status === 'running' || task.status === 'queued') { + this.startLogStreaming(taskId, modal); + } + + // Store streaming controller + this.activeLogStreams = this.activeLogStreams || new Map(); + } + + startLogStreaming(taskId, modal) { + const statusIndicator = document.getElementById(`log-status-${taskId}`); + const toggleButton = document.getElementById(`log-toggle-${taskId}`); + const logViewer = document.getElementById(`log-viewer-${taskId}`); + const lineCountElement = modal.querySelector('.log-line-count'); + + if (!logViewer) return; + + let lineCount = logViewer.children.length; + + const stream = this.taskManager.streamTaskLog( + taskId, + (newLines) => { + // Add new lines to existing content + const preElement = logViewer.querySelector('pre'); + let currentContent = preElement ? preElement.textContent : ''; + const separator = currentContent.trim() && !currentContent.includes('Waiting for logs...') ? '\n' : ''; + let cleanedContent = currentContent.replace('Waiting for logs...', '').trim(); + const newContent = cleanedContent + separator + newLines.join('\n'); + preElement.innerHTML = this.taskManager.parseAnsiColors(newContent); + logViewer.appendChild(lineElement); + lineCount++; + if (lineCountElement) { + lineCountElement.textContent = `(${lineCount} lines)`; + } + + // Auto-scroll to bottom + logViewer.scrollTop = logViewer.scrollHeight; + + // Update status + if (statusIndicator) { + statusIndicator.textContent = '🔴 Live'; + statusIndicator.className = 'status-indicator live'; + } + + // Remove new-line highlighting after a moment + setTimeout(() => { + const newLines = logViewer.querySelectorAll('.new-line'); + newLines.forEach(line => line.classList.remove('new-line')); + }, 2000); + }, + (error) => { + console.error('Log streaming error:', error); + if (statusIndicator) { + statusIndicator.textContent = '❌ Error'; + statusIndicator.className = 'status-indicator error'; + } + } + ); + + // Store stream controller + this.activeLogStreams.set(taskId, { stream, modal, isPaused: false }); + + // Update toggle button + if (toggleButton) { + toggleButton.textContent = '⏸️ Pause'; + } + } + + toggleLogStreaming(taskId) { + const streamData = this.activeLogStreams?.get(taskId); + if (!streamData) return; + + const toggleButton = document.getElementById(`log-toggle-${taskId}`); + const statusIndicator = document.getElementById(`log-status-${taskId}`); + + if (streamData.isPaused) { + // Resume streaming + streamData.isPaused = false; + if (toggleButton) toggleButton.textContent = '⏸️ Pause'; + if (statusIndicator) { + statusIndicator.textContent = '🔴 Live'; + statusIndicator.className = 'status-indicator live'; + } + } else { + // Pause streaming + streamData.isPaused = true; + if (toggleButton) toggleButton.textContent = '▶️ Resume'; + if (statusIndicator) { + statusIndicator.textContent = '⏸️ Paused'; + statusIndicator.className = 'status-indicator paused'; + } + } + } + + formatCommandForUser(task) { + if (!task.command) return 'Unknown Task'; + + const displayName = (slug) => window.getAppDisplayName ? window.getAppDisplayName(slug) : (slug.charAt(0).toUpperCase() + slug.slice(1)); + + if (/^libreportal setup config\b/.test(task.command)) return 'LibrePortal - Apply Configuration'; + if (/^libreportal setup finalize\b/.test(task.command)) return 'LibrePortal - Finalize Setup'; + if (/^libreportal setup apply\b/.test(task.command)) return 'LibrePortal - Setup Wizard'; + + // Backup engine — per-app actions. + const backupDeleteAllMatch = task.command.match(/libreportal backup app delete_all (\w+)/); + if (backupDeleteAllMatch) return `${displayName(backupDeleteAllMatch[1])} - Delete All Backups`; + + const backupDeleteMatch = task.command.match(/libreportal backup app delete (\w+) (.+)/); + if (backupDeleteMatch) return `${displayName(backupDeleteMatch[1])} - Delete Backup`; + + const backupCreateMatch = task.command.match(/^libreportal backup app create (\w+)/); + if (backupCreateMatch) return `${displayName(backupCreateMatch[1])} - Create Backup`; + + const backupScheduleMatch = task.command.match(/^libreportal backup app schedule (\w+)/); + if (backupScheduleMatch) return `${displayName(backupScheduleMatch[1])} - Scheduled Backup`; + + const backupListMatch = task.command.match(/^libreportal backup app list (\w+)/); + if (backupListMatch) return `${displayName(backupListMatch[1])} - List Backups`; + + // Backup engine — restore actions. + const restoreStartMatch = task.command.match(/^libreportal restore app start (\w+)/); + if (restoreStartMatch) return `${displayName(restoreStartMatch[1])} - Restore Backup`; + + const restoreListMatch = task.command.match(/^libreportal restore app list (\w+)/); + if (restoreListMatch) return `${displayName(restoreListMatch[1])} - List Backups`; + + const migrateAppMatch = task.command.match(/^libreportal restore migrate app (\w+)/); + if (migrateAppMatch) return `${displayName(migrateAppMatch[1])} - Migrate from Host`; + + // Backup engine — system-level actions (no per-app target). + if (/^libreportal backup all\b/.test(task.command)) return 'LibrePortal - Backup All Apps'; + if (/^libreportal backup verify\b/.test(task.command)) return 'LibrePortal - Verify Backups'; + if (/^libreportal backup location add\b/.test(task.command)) return 'LibrePortal - Add Backup Location'; + if (/^libreportal backup location remove\b/.test(task.command)) return 'LibrePortal - Remove Backup Location'; + if (/^libreportal backup location init\b/.test(task.command)) return 'LibrePortal - Initialise Backup Locations'; + if (/^libreportal backup location check\b/.test(task.command)) return 'LibrePortal - Check Backup Locations'; + if (/^libreportal backup location list\b/.test(task.command)) return 'LibrePortal - List Backup Locations'; + if (/^libreportal backup location stats\b/.test(task.command)) return 'LibrePortal - Backup Location Stats'; + if (/^libreportal restore migrate system\b/.test(task.command)) return 'LibrePortal - Migrate System'; + if (/^libreportal restore migrate discover\b/.test(task.command)) return 'LibrePortal - Discover Backups'; + if (/^libreportal restore first-run\b/.test(task.command)) return 'LibrePortal - First-Run Restore'; + + // `libreportal app tool ['']` — show the + // tool's friendly label instead of "Tool Application". Pull the + // label from window.toolsCatalog if loaded; otherwise titlecase + // the snake_case tool id. + const toolMatch = task.command.match(/libreportal app tool (\S+) (\S+)/); + if (toolMatch) { + const appName = toolMatch[1]; + const toolId = toolMatch[2]; + let label = null; + const cat = window.toolsCatalog; + if (cat && cat.apps && cat.apps[appName] && Array.isArray(cat.apps[appName].tools)) { + const t = cat.apps[appName].tools.find(x => x.id === toolId); + if (t && t.label) label = t.label; + } + if (!label) { + label = toolId.split('_').map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' '); + } + return `${displayName(appName)} - ${label}`; + } + + // Parse libreportal commands. Capture only the app name token — anything after + // (e.g. config overrides like `CFG_FOO=bar|...`) is for the CLI, not the title. + const libreportalMatch = task.command.match(/libreportal app (\w+) (\S+)/); + if (libreportalMatch) { + const action = libreportalMatch[1]; + const appName = libreportalMatch[2]; + + const actionMap = { + 'install': 'Install Application', + 'uninstall': 'Uninstall Application', + 'restart': 'Restart Application', + 'start': 'Start Application', + 'stop': 'Stop Application', + 'update': 'Update Application', + 'rebuild': 'Rebuild Application', + 'delete': 'Delete Backup', + 'backup': 'Backup Application' + }; + + const formattedAction = actionMap[action] || `${action.charAt(0).toUpperCase() + action.slice(1)} Application`; + return `${displayName(appName)} - ${formattedAction}`; + } + + // Handle other common patterns + if (task.command.includes('docker-compose')) { + return 'Docker Compose Operation'; + } + + if (task.command.includes('docker')) { + return 'Docker Operation'; + } + + // Return first 50 chars of command as fallback + return task.command.length > 50 ? task.command.substring(0, 47) + '...' : task.command; + } + + getTaskTypeIcon(task) { + if (!task.type) return { icon: '⚙️', class: 'custom' }; + + const iconMap = { + 'install': { icon: '✅', class: 'install' }, + 'app-install': { icon: '✅', class: 'install' }, + 'uninstall': { icon: '❌', class: 'uninstall' }, + 'restart': { icon: '🔄', class: 'restart' }, + 'start': { icon: '▶️', class: 'start' }, + 'stop': { icon: '⏹️', class: 'stop' }, + 'update': { icon: '⬆️', class: 'update' }, + 'rebuild': { icon: '🔨', class: 'rebuild' }, + 'backup': { icon: '💾', class: 'backup' }, + 'restore': { icon: '📦', class: 'restore' }, + 'delete': { icon: '🗑️', class: 'delete' }, + 'delete_all': { icon: '🗑️', class: 'delete' }, + 'setup-config': { icon: '🛠️', class: 'setup' }, + 'setup-finalize': { icon: '🎉', class: 'setup' }, + 'custom': { icon: '⚙️', class: 'custom' } + }; + + return iconMap[task.type] || iconMap['custom']; + } + + /* Detect a task that's an LibrePortal system action (no specific app) so + the row can show the LibrePortal logo instead of a blank icon slot. */ + isLibrePortalSystemTask(task) { + if (!task || !task.command || task.app) return false; + return /^libreportal (setup|backup\s+all|backup\s+verify|backup\s+location|backup\s+repo|restore\s+migrate\s+system|restore\s+migrate\s+discover|restore\s+first-run|webui|config)\b/.test(task.command); + } + + /* Render the leading icon(s) on a task row: + - Per-app task → emoji type icon + app icon + - System task → emoji type icon + LibrePortal logo + - Anything else → emoji type icon only + Keeps the layout consistent across every row regardless of source. */ + renderTaskIcons(task) { + const typeIcon = `${this.getTaskTypeIcon(task).icon}`; + if (task.app) { + const appIconPath = this.getAppIconPath(task); + return `${typeIcon}${task.app}`; + } + if (this.isLibrePortalSystemTask(task)) { + return `${typeIcon}LibrePortal`; + } + return typeIcon; + } + + getAppIconPath(task) { + if (!task.app) return null; + + // Try to get icon from commands if available + if (this.commands && this.commands.getAppData) { + const appData = this.commands.getAppData(task.app); + if (appData && appData.icon) { + return appData.icon; + } + } + + // Default icon path + return `icons/apps/${task.app}.svg`; + } + + updateStats() { + const stats = { + queued: this.tasks.filter(t => t.status === 'queued').length, + running: this.tasks.filter(t => t.status === 'running').length, + completed: this.tasks.filter(t => t.status === 'completed').length, + failed: this.tasks.filter(t => t.status === 'failed').length + }; + + Object.keys(stats).forEach(status => { + const element = document.getElementById(`${status}-count`); + if (element) element.textContent = stats[status]; + }); + } + + updateSidebarCounts() { + // Update all sidebar category counts + const categories = ['all', 'queued', 'running', 'completed', 'failed', 'install', 'uninstall', 'management', 'backup', 'config']; + + categories.forEach(category => { + const count = this.filterTasksByCategory(this.tasks, category).length; + const element = document.getElementById(`count-${category}`); + if (element) element.textContent = count; + }); + + // Update app-specific counts + const apps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))]; + apps.forEach(app => { + const count = this.tasks.filter(task => task.app === app).length; + const element = document.getElementById(`count-app-${app}`); + if (element) element.textContent = count; + }); + } + + async generateAppCategories() { + const container = document.getElementById('app-categories'); + if (!container) return; + + try { + // Show loading state + container.innerHTML = '

    Loading apps...

    '; + + // Try to load apps data if not already available + let appsData = window.apps || []; + + // If window.apps is not available, try to load it + if (!appsData || appsData.length === 0) { + try { + const appsResponse = await fetch('/read-file?path=apps.json'); + if (appsResponse.ok) { + const appsText = await appsResponse.text(); + if (appsText.trim()) { + appsData = JSON.parse(appsText); + window.apps = appsData; // Cache for future use + } + } + } catch (error) { + // console.log('Could not load apps.json, will use fallback'); + } + } + + // Filter for installed apps only and extract slugs + const installedApps = appsData + .filter(app => app.installed === true || app.status === 'installed') + .map(app => { + // Extract slug from command like "libreportal app install adguard" + const command = app.command || ''; + const slugMatch = command.match(/libreportal app install\s+(.+)$/); + const slug = slugMatch ? slugMatch[1].trim() : ''; + + // Extract title from "Title - Description" format + const fullName = app.name || ''; + const title = fullName.split(' - ')[0].trim(); + + return { slug, title }; + }) + .filter(app => app.slug && app.title); + + // If no installed apps found from apps data, fall back to task-based apps + if (installedApps.length === 0) { + // console.log('No installed apps found, using task-based app list'); + const taskApps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))]; + + if (taskApps.length === 0) { + container.innerHTML = '
    No apps found
    '; + return; + } + + const appCategories = taskApps.map(app => { + const taskCount = this.tasks.filter(task => task.app === app).length; + const displayName = window.getAppDisplayName ? window.getAppDisplayName(app) : (app.charAt(0).toUpperCase() + app.slice(1)); + return ` + + + + + + + ${displayName} + ${taskCount} + + `; + }).join(''); + + container.innerHTML = appCategories; + return; + } + + const appCategories = installedApps.map(app => { + const taskCount = this.tasks.filter(task => task.app === app.slug).length; + return ` + +
    + ${app.title} + ${taskCount} +
    + `; + }).join(''); + + container.innerHTML = appCategories; + } catch (error) { + console.error('Error generating app categories:', error); + // Final fallback to task-based app list + const apps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))]; + + if (apps.length === 0) { + container.innerHTML = '
    No apps found
    '; + return; + } + + const appCategories = apps.map(app => { + const taskCount = this.tasks.filter(task => task.app === app).length; + const displayName = window.getAppDisplayName ? window.getAppDisplayName(app) : (app.charAt(0).toUpperCase() + app.slice(1)); + return ` + + + + + + + ${displayName} + ${taskCount} + + `; + }).join(''); + + container.innerHTML = appCategories; + } + } + + setupAutoRefresh() { + // Refresh every 5 seconds + this.refreshInterval = setInterval(() => { + this.loadTasks(); + }, 5000); + } + + setupMobileMenu() { + // Setup mobile menu toggle (if needed) + const mobileOverlay = document.getElementById('mobile-overlay'); + const sidebar = document.getElementById('sidebar'); + + if (mobileOverlay && sidebar) { + mobileOverlay.addEventListener('click', () => { + sidebar.classList.remove('mobile-open'); + mobileOverlay.classList.remove('active'); + }); + } + } + + filterTasksByCategoryHandler(category) { + this.currentCategory = category; + + // Clear specific task filter when switching categories + this.highlightedTaskId = null; + + // Update URL + this.updateURL(category); + + // Update sidebar active state + document.querySelectorAll('.sidebar-item').forEach(item => { + item.classList.toggle('active', item.dataset.category === category); + }); + + this.renderTasks(); + } + + setupAutoRefresh() { + // Only refresh when tasks page is visible and every 30 seconds + this.refreshInterval = setInterval(() => { + // Only refresh if we're on the tasks page + if (window.location.pathname === '/tasks' || window.location.hash.includes('tasks')) { + this.loadTasks(); + } + }, 30000); // 30 seconds instead of 5 + } + + setupGlobalFunctions() { + // Make functions available globally for onclick handlers + window.filterTasksByCategory = (category) => { + event.preventDefault(); + this.filterTasksByCategoryHandler(category); + }; + + // Delegated click handler for the Task ID and App links rendered inside + // each task row. Behaviour: + // * Task ID always lands on the global /tasks page with that task open. + // If we're already there, just push the URL and toggle the row open + // (no content reload); otherwise SPA-navigate to /tasks. + // * App link goes to that app's /app page on whatever its default tab + // is (we don't pin tab=… so the app's own logic picks one). + if (!window.__taskMetaLinksBound) { + window.__taskMetaLinksBound = true; + document.addEventListener('click', (e) => { + const link = e.target.closest('a.task-id-link, a.task-app-link'); + if (!link) return; + const href = link.getAttribute('href'); + if (!href) return; + e.preventDefault(); + e.stopPropagation(); + + const navigate = (url) => { + if (window.spaClean && typeof window.spaClean.navigate === 'function') { + window.spaClean.navigate(url); + } else { + window.location.href = url; + } + }; + + if (link.classList.contains('task-id-link')) { + const taskId = link.dataset.taskId; + const onTasksPage = window.location.pathname.startsWith('/tasks'); + if (onTasksPage && taskId && typeof window.toggleTaskDetails === 'function') { + // Already on /tasks — soft-update the URL and open the row. + window.history.pushState({}, '', href); + if (window.tasksManager) window.tasksManager.highlightedTaskId = taskId; + window.toggleTaskDetails(taskId); + } else { + // Coming from /app or anywhere else — go to the tasks page; its + // initializeFromURL picks up `task=` and auto-expands. + navigate(href); + } + } else { + // App link — go to the app page on its default tab. + // + // `task-parameter-preserve.js` stashes any `task=…` from the + // initial page URL into sessionStorage.pendingTaskId, and + // app-tabbed-manager's init reads that as a fallback when the + // current URL has no task param. Without clearing it here, an old + // task id from /tasks would hijack the app page, force-switching + // to the Tasks tab and opening that task. Clear it so the app + // page lands on its actual default tab. + try { sessionStorage.removeItem('pendingTaskId'); } catch {} + navigate(href); + } + }); + } + window.refreshTasks = () => this.refreshTasks(); + window.toggleTaskDetails = (taskId) => this.toggleTaskDetails(taskId); + window.retryTask = (taskId) => this.retryTask(taskId); + window.deleteTask = (taskId) => this.deleteTask(taskId); + window.clearAllTasks = () => this.clearAllTasks(); + window.viewTaskLogs = (taskId) => this.viewTaskLogs(taskId); + window.toggleLogStreaming = (taskId) => this.toggleLogStreaming(taskId); + window.closeTaskLogsModal = () => { + const modal = document.querySelector('.task-logs-modal'); + if (modal) { + // Stop all active streaming for this modal + this.activeLogStreams?.forEach((streamData, streamTaskId) => { + if (streamData.modal === modal) { + streamData.stream.stop(); + this.activeLogStreams.delete(streamTaskId); + } + }); + modal.remove(); + } + }; + + window.createAndExecuteTask = async (action, appName, config = '') => { + try { + // Create task using NEW signature + const task = await this.taskManager.createTask(`libreportal app ${action} ${appName}`, action, appName, config); + + // Emit task creation event for AppTabbedManager + window.dispatchEvent(new CustomEvent('taskCreated', { + detail: { + taskId: task.id, + appName: appName, + action: action, + timestamp: Date.now() + } + })); + + // Set up monitoring for this specific task + this.monitorTask(task.id, appName, action); + + // Show success notification with app icon and direct link + if (window.notificationSystem) { + const taskUrl = `/tasks?=all&task=${task.id}`; + const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon({ type: action })?.icon : ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + window.notificationSystem.show( + `Task created: ${action} ${appName}`, + 'info', + appName, + taskUrl, + `icons/apps/${appName}.svg`, + customIcon + ); + } + + return task; + } catch (error) { + console.error(`❌ Failed to create ${action} task for ${appName}:`, error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to start ${action} task for ${appName}: ${error.message}`); + } + throw error; + } + }; + } + + // Single page-wide subscription to the TaskEventBus. This complements + // monitorTask (which only fires for tasks created in this session) and + // ensures any visible row updates when its status changes — including the + // running -> completed/failed/cancelled transition that previously only + // refreshed when the user switched tabs and came back. + // + // The guard is on `window`, not `this`, because TasksManager is constructed + // in several places (system-loader, app-tabbed-manager, …). Without a + // global guard each instance would attach its own listener and the user + // would see N notifications for one task completion. + setupTaskBusListeners() { + if (window.__tasksManagerBusBound) return; + window.__tasksManagerBusBound = true; + + const upsertLocal = (task) => { + if (!task || !task.id) return; + const idx = this.tasks.findIndex(t => t.id === task.id); + if (idx >= 0) this.tasks[idx] = task; else this.tasks.unshift(task); + }; + + window.addEventListener('taskCreated', (e) => { + const task = e.detail && e.detail.task; + if (!task) return; + upsertLocal(task); + // Don't re-render the entire list here — `renderTasks` would blow away + // any open dropdown's DOM. A targeted update is enough; the next + // category switch / refresh will pull the new row in. + this.updateTaskDisplay(task); + }); + + window.addEventListener('taskUpdated', (e) => { + const task = e.detail && e.detail.task; + if (!task) return; + upsertLocal(task); + if (task.status === 'running') { + this.updateTaskStructure(task.id, task); + this.startLogStreaming(task.id, task); + } + this.updateTaskDisplay(task); + }); + + window.addEventListener('taskCompleted', (e) => { + const task = e.detail && e.detail.task; + const taskId = (task && task.id) || (e.detail && e.detail.taskId); + if (!taskId) return; + if (task) upsertLocal(task); + if (task) this.updateTaskDisplay(task); + + // Final render of any buffered log content, then stop the SSE listener. + const stream = this.activeLogStreams && this.activeLogStreams.get(taskId); + if (stream && typeof stream.render === 'function') stream.render(); + this.stopLogStreaming(taskId); + + // User-visible notification so they know without staring at the page. + // Layout matches the " task started!" format used by task-actions + // and backup-manager so started/completed look like a matched pair. + if (window.notificationSystem && task) { + const appName = task.app || null; + const action = task.type || 'task'; + const friendlyActionMap = { + 'install': 'Install', 'app-install': 'Install', + 'uninstall': 'Uninstall', 'restart': 'Restart', + 'start': 'Start', 'stop': 'Stop', + 'update': 'Update', 'rebuild': 'Rebuild', + 'backup': 'Backup', 'restore': 'Restore', + 'delete': 'Delete Backup', 'delete_all': 'Delete All Backups', + 'setup-config': 'Apply Configuration', + 'setup-finalize': 'Finalize Setup' + }; + let actionTitle = friendlyActionMap[action] || (action.charAt(0).toUpperCase() + action.slice(1)); + // Tool tasks: override the generic "Tool" label with the tool's + // friendly name (e.g. "Manage Shortcuts") so completion toasts + // match what the user clicked. + const toolCmdMatch = (task.command || '').match(/libreportal app tool (\S+) (\S+)/); + if (toolCmdMatch) { + const toolApp = toolCmdMatch[1]; + const toolId = toolCmdMatch[2]; + let toolLabel = null; + const cat = window.toolsCatalog; + if (cat && cat.apps && cat.apps[toolApp] && Array.isArray(cat.apps[toolApp].tools)) { + const t = cat.apps[toolApp].tools.find(x => x.id === toolId); + if (t && t.label) toolLabel = t.label; + } + if (!toolLabel) { + toolLabel = toolId.split('_').map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' '); + } + actionTitle = toolLabel; + } + const isSystemTask = action.startsWith('setup-'); + const displayName = isSystemTask + ? 'LibrePortal' + : ((appName && window.getAppDisplayName) + ? window.getAppDisplayName(appName) + : (appName || (task.command || `Task ${taskId}`))); + const subjectLabel = isSystemTask ? 'System' : 'App'; + const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps'); + const url = (onAppPage && appName) + ? `/app?=${appName}&tab=tasks&task=${taskId}` + : `/tasks?=all&task=${taskId}`; + const icon = appName ? `icons/apps/${appName}.svg` : null; + + // Match the per-action emoji used in the task list rows (see + // `getTaskTypeIcon`). Passed as the 6th `customIcon` arg so the + // notification's leftmost icon slot shows the task *type* (install + // ✅, backup 💾, restore 📦, …) instead of the generic level tick. + const typeIcon = (this.getTaskTypeIcon ? this.getTaskTypeIcon(task) : null)?.icon || ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + + let body; + let level; + if (task.status === 'completed') { + body = `${subjectLabel}: ${displayName}
    ${actionTitle} task completed!`; + level = 'success'; + } else if (task.status === 'failed') { + body = `${subjectLabel}: ${displayName}
    ${actionTitle} task failed.`; + level = 'error'; + } else if (task.status === 'cancelled') { + body = `${subjectLabel}: ${displayName}
    ${actionTitle} task cancelled.`; + level = 'warning'; + } + if (body) window.notificationSystem.show(body, level, appName, url, icon, customIcon); + + // Belt-and-braces: when the completion notification fires we also + // tell the app-tabbed manager to re-enable that app's tabs and + // buttons. The taskCompleted listener inside app-tabbed-manager + // does this too, plus the 5s reconcile sweep — but routing it + // through here as well means any one path being broken or + // de-bound still leaves the user with a usable UI rather than + // permanently-disabled tabs. + if (appName && window.appTabbedManager && typeof window.appTabbedManager.enableAppButtons === 'function') { + try { window.appTabbedManager.enableAppButtons(appName); } catch {} + } + } + }); + + window.addEventListener('taskDeleted', (e) => { + const id = e.detail && e.detail.id; + if (!id) return; + this.tasks = this.tasks.filter(t => t.id !== id); + const el = document.querySelector(`.task-item[data-task-id="${id}"]`); + if (!el) return; + const parent = el.parentElement; + el.remove(); + if (!parent || parent.querySelector('.task-item')) return; + if (parent.id === 'tasks-list') { + this.renderTasks(); + } else if (parent.id === 'app-tasks') { + const appName = (window.appTabbedManager && window.appTabbedManager.currentApp) || ''; + parent.innerHTML = `

    No tasks found for ${appName}.

    `; + } + }); + } + + // Load task logs on demand + async loadTaskLogs(taskId) { + try { + // console.log(`📋 Loading logs for task ${taskId}...`); + + // Show loading state + const detailsElement = document.getElementById(`details-${taskId}`); + if (detailsElement) { + const logsContainer = detailsElement.querySelector('.task-logs'); + if (logsContainer) { + logsContainer.innerHTML = '
    📋 Loading logs...
    '; + } + } + + // Load task data directly from task file + const task = this.tasks.find(t => t.id === taskId); + let output = ''; + + if (task) { + output = task.output || ''; + } else { + // Try to fetch task data directly + try { + const response = await fetch(`/api/tasks/${taskId}`); + if (response.ok) { + const taskData = await response.json(); + output = taskData.output || ''; + } + } catch (error) { + console.warn('Failed to fetch task data:', error); + } + } + + if (output && output.trim().length > 0) { + // Display the output + const logsHtml = output.split('\n').map(log => `
    ${this.parseAnsiColors(log)}
    `).join(''); + + if (detailsElement) { + const logsContainer = detailsElement.querySelector('.task-logs'); + if (logsContainer) { + logsContainer.innerHTML = ` +

    📋 Execution Logs

    +
    ${logsHtml}
    + `; + } + } + + // console.log(`✅ Loaded logs for task ${taskId}`); + } else { + // No logs available + if (detailsElement) { + const logsContainer = detailsElement.querySelector('.task-logs'); + if (logsContainer) { + logsContainer.innerHTML = '
    📋 No output available for this task.
    '; + } + } + + // console.log(`ℹ️ No logs available for task ${taskId}`); + } + + return output; + } catch (error) { + console.error(`❌ Failed to load logs for task ${taskId}:`, error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to load logs: ${error.message}`); + } + return ''; + } + } + + // Monitor a specific task. State changes now arrive via SSE through + // TaskEventBus, so this is mostly a hook for the UI to: + // - auto-expand the task if it's the one we just started + // - start log streaming when the task transitions to running + // - clean up local intervals when it terminates + // No more polling; the only fetches happen on-demand via TaskManager. + monitorTask(taskId, appName, action) { + if (!this.highlightedTaskId || this.highlightedTaskId === taskId) { + setTimeout(() => this.autoExpandTask(taskId), 1500); + } + + let statusUpdateInterval = null; + + const onUpdate = (event) => { + const t = event.detail && event.detail.task; + if (!t || t.id !== taskId) return; + + if (t.status === 'running') { + this.updateTaskStructure(taskId, t); + this.startLogStreaming(taskId, t); + if (this.highlightedTaskId === taskId && !statusUpdateInterval) { + statusUpdateInterval = setInterval(() => this.updateHighlightedTaskStatus(taskId), 2000); + } + } else { + this.updateTaskDisplay(t); + } + }; + + const onComplete = (event) => { + if (!event.detail || event.detail.taskId !== taskId) return; + if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; } + this.stopLogStreaming(taskId); + window.removeEventListener('taskUpdated', onUpdate); + window.removeEventListener('taskCompleted', onComplete); + }; + + window.addEventListener('taskUpdated', onUpdate); + window.addEventListener('taskCompleted', onComplete); + } + + // SSE-driven log streaming. Subscribes to `taskLog` events from the bus and + // appends incoming chunks. Initial backlog is fetched once via the API. + // + // Resilient to DOM replacement: the logs container can be wiped out by + // `renderTasks()` (the 30s auto-refresh) or by `loadTaskLogs()` (toggle + // re-open). Instead of capturing a `preElement` reference once, `render()` + // re-locates / re-creates it on every call from the cumulative `buffered` + // string. Idempotent: if already streaming, a second call just re-renders. + async startLogStreaming(taskId, task) { + if (!this.activeLogStreams) this.activeLogStreams = new Map(); + + if (this.activeLogStreams.has(taskId)) { + const existing = this.activeLogStreams.get(taskId); + if (existing && typeof existing.render === 'function') existing.render(); + return; + } + + const state = { buffered: '' }; + + const render = () => { + const logsContainer = document.getElementById(`logs-${taskId}`); + if (!logsContainer) return; + const overlay = logsContainer.querySelector('div[style*="position: absolute"]'); + if (overlay) overlay.remove(); + let preElement = logsContainer.querySelector('pre.output-content'); + if (!preElement) { + logsContainer.innerHTML = ''; + preElement = document.createElement('pre'); + preElement.className = 'output-content terminal-style'; + logsContainer.appendChild(preElement); + } + const atBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10; + if (state.buffered) { + preElement.innerHTML = this.taskManager.parseAnsiColors(state.buffered); + } else { + preElement.innerHTML = 'Waiting for logs...'; + } + if (atBottom) logsContainer.scrollTop = logsContainer.scrollHeight; + }; + + // Initial backlog via the API. + try { + const initial = await this.taskManager.readFullTaskLog(taskId); + if (initial && initial.length) state.buffered = initial; + } catch { /* fall through to placeholder */ } + render(); + + const onLog = (event) => { + const detail = event.detail || {}; + if (detail.id !== taskId || typeof detail.chunk !== 'string') return; + state.buffered += detail.chunk; + render(); + }; + window.addEventListener('taskLog', onLog); + + // SSE catch-up: when the backend restarts mid-task (e.g., libreportal + // recreates itself during a CrowdSec install), the SSE event source + // drops. EventSource auto-reconnects, but task.log events emitted + // during the gap are lost. taskBusReady fires after every reconnect — + // pull the missed bytes via the existing /:id/log?position=N endpoint + // and splice them in. Skips on the initial connect (no gap). + let initialReadyFired = false; + const onBusReady = async () => { + if (!initialReadyFired) { initialReadyFired = true; return; } + try { + const res = await fetch(`/api/tasks/${encodeURIComponent(taskId)}/log?position=${state.buffered.length}`); + if (!res.ok) return; + const missed = await res.text(); + if (missed) { + state.buffered += missed; + render(); + } + } catch { /* network blip, next ready will retry */ } + }; + window.addEventListener('taskBusReady', onBusReady); + + this.activeLogStreams.set(taskId, { + stream: { stop: () => { + window.removeEventListener('taskLog', onLog); + window.removeEventListener('taskBusReady', onBusReady); + this.activeLogStreams.delete(taskId); + } }, + render, + isPaused: false + }); + } + + // Stop log streaming for a task + stopLogStreaming(taskId) { + if (this.activeLogStreams && this.activeLogStreams.has(taskId)) { + const streamData = this.activeLogStreams.get(taskId); + streamData.stream.stop(); + this.activeLogStreams.delete(taskId); + // console.log(`⏹️ Stopped log streaming for task ${taskId}`); + } + } + + // Update task structure for live logs + updateTaskStructure(taskId, task) { + const taskElement = document.querySelector(`[data-task-id="${taskId}"]`); + if (!taskElement) return; + + const detailsElement = taskElement.querySelector('.task-details'); + if (!detailsElement) return; + + // console.log(`🔄 Updating task structure for ${taskId} to show simplified logs`); + + // Check if logs container already exists + const existingLogs = detailsElement.querySelector('.task-logs .log-container'); + if (existingLogs) { + // console.log(`🔄 Logs container already exists for ${taskId}`); + return; // Already exists, no need to update + } + + // Add simplified logs section for running tasks + if (task.status === 'running') { + const logsHtml = ` +
    +
    +
    Loading logs...
    +
    + + `; + + // Insert logs section at the bottom of details + detailsElement.insertAdjacentHTML('beforeend', logsHtml); + // console.log(`✅ Added simplified logs section for task ${taskId}`); + + // Auto-load logs + this.loadTaskLogs(taskId); + } + } + + // Update task display in real-time + updateTaskDisplay(task) { + const taskElement = document.querySelector(`[data-task-id="${task.id}"]`); + if (!taskElement) { + // The monitored task isn't always rendered (different tab, list filtered out, + // task already removed). Silently skip — this is the normal case. + return; + } + + // console.log(`🔄 Found task element:`, taskElement); + + // Update status and content + const statusElement = taskElement.querySelector('.task-status'); + const contentElement = taskElement.querySelector('.task-content'); + + if (statusElement) { + const statusClass = `status-${task.status || 'unknown'}`; + statusElement.className = `task-status ${statusClass}`; + statusElement.innerHTML = `${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}`; + } else { + console.warn(`⚠️ Status element not found for task ${task.id}`); + } + + // Mirror the status into the details panel's metadata block too — that + // copy of the status was previously left stale until the page reloaded. + const detailsStatus = taskElement.querySelector(`#details-${task.id} .task-meta .status-running, #details-${task.id} .task-meta .status-queued, #details-${task.id} .task-meta .status-pending, #details-${task.id} .task-meta .status-completed, #details-${task.id} .task-meta .status-failed, #details-${task.id} .task-meta .status-cancelled, #details-${task.id} .task-meta [class^="status-"]`); + if (detailsStatus) { + detailsStatus.className = `status-${task.status || 'unknown'}`; + detailsStatus.innerHTML = `${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}`; + } + + if (contentElement) { + contentElement.textContent = task.command; + } + + // console.log(`🔄 Updated task ${task.id} display: ${task.status}`); + } + + // Start global live log updater - simple 2-second updates for all running tasks + startGlobalLiveLogUpdater() { + // console.log(`🔄 Starting global live log updater`); + + // Update every 2 seconds + setInterval(async () => { + // console.log(`🔄 Global updater running - checking tasks...`); + + // Find all running tasks + const runningTasks = this.tasks.filter(task => task.status === 'running'); + // console.log(`🔄 Found ${runningTasks.length} running tasks:`, runningTasks.map(t => t.id)); + + if (runningTasks.length > 0) { + // console.log(`🔄 Updating live logs for ${runningTasks.length} running tasks`); + + // Update each running task's live logs + for (const task of runningTasks) { + // console.log(`🔄 About to update live logs for task ${task.id}`); + await this.updateLiveLogsSimple(task.id); + } + } else { + // console.log(`🔄 No running tasks found, skipping live log updates`); + } + }, 2000); // Every 2 seconds + } + + // Simple live log update - no complex polling logic + async updateLiveLogsSimple(taskId) { + const liveLogsElement = document.getElementById(`live-logs-${taskId}`); + if (!liveLogsElement) { + // console.log(`⚠️ Live logs element not found for task ${taskId}`); + return; // Silently skip if element not found + } + + try { + // console.log(`🔄 Reading log file for task ${taskId}`); + + // Read the log file content + const response = await fetch(`/read-file?path=tasks/${taskId}.log`); + // console.log(`🔄 Log file response status: ${response.status} for task ${taskId}`); + + if (response.ok) { + const logContent = await response.text(); + // console.log(`🔄 Log file content length: ${logContent.length} chars for task ${taskId}`); + // console.log(`🔄 First 100 chars of log content: "${logContent.substring(0, 100)}..."`); + + if (logContent.trim()) { + // Split into lines and display + const lines = logContent.split('\n').filter(line => line.trim()); + // console.log(`🔄 Displaying ${lines.length} log lines for task ${taskId}`); + + liveLogsElement.innerHTML = lines.map(line => + `
    ${this.parseAnsiColors(line)}
    ` + ).join(''); + // Auto-scroll to bottom + liveLogsElement.scrollTop = liveLogsElement.scrollHeight; + } else { + // console.log(`🔄 Log file is empty for task ${taskId}`); + liveLogsElement.innerHTML = '
    🔄 Waiting for logs...
    '; + } + } else { + console.warn(`⚠️ Failed to read log file for task ${taskId}: ${response.status}`); + liveLogsElement.innerHTML = '
    ⚠️ Unable to read logs
    '; + } + } catch (error) { + // Silently handle errors + console.warn(`⚠️ Error reading live logs for task ${taskId}:`, error); + liveLogsElement.innerHTML = '
    ❌ Error loading logs
    '; + } + } + + // Load task logs automatically + async loadTaskLogs(taskId) { + try { + const logsContainer = document.getElementById(`logs-${taskId}`); + if (!logsContainer) { + console.warn(`⚠️ Logs container not found for task ${taskId}`); + return; + } + + const task = this.tasks.find(t => t.id === taskId); + const inMemoryLog = (task && Array.isArray(task.log) && task.log.length > 0) ? task.log : null; + + const renderInMemory = () => { + if (!inMemoryLog) return false; + logsContainer.innerHTML = inMemoryLog + .map(line => `
    ${this.taskManager.parseAnsiColors(line)}
    `) + .join(''); + return true; + }; + + logsContainer.innerHTML = '
    🔄 Loading logs...
    '; + const isScrolledToBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10; + + const logResponse = await fetch(`/read-file?path=tasks/${taskId}.log`); + if (logResponse.ok) { + const logContent = await logResponse.text(); + if (logContent.trim()) { + logsContainer.innerHTML = `
    ${this.taskManager.parseAnsiColors(logContent)}
    `; + if (isScrolledToBottom) logsContainer.scrollTop = logsContainer.scrollHeight; + return; + } + } + + if (renderInMemory()) return; + + logsContainer.innerHTML = '
    ℹ️ No logs available for this task.
    '; + + } catch (error) { + console.error(`❌ Error loading logs for task ${taskId}:`, error); + const logsContainer = document.getElementById(`logs-${taskId}`); + if (logsContainer) { + logsContainer.innerHTML = '
    ❌ Failed to load logs.
    '; + } + } + } + + // Load task output on demand + async loadTaskOutput(taskId) { + try { + // Read the task file to get output + const task = await this.taskManager.getTask(taskId); + if (!task) return; + + const outputElement = document.querySelector(`[data-task-id="${taskId}"] .task-output`); + if (!outputElement) return; + + // Show loading state + outputElement.innerHTML = ` +
    Loading output...
    + `; + + // Check if task has output + if (task.output && task.output.trim()) { + outputElement.innerHTML = ` +

    📤 Output

    +
    ${this.taskManager.parseAnsiColors(task.output)}
    + `; + } else if (task.error && task.error.trim()) { + outputElement.innerHTML = ` +

    ❌ Error

    +
    ${this.escapeHtml(task.error)}
    + `; + } else { + // Try to read from log file + const logResponse = await fetch(`/read-file?path=tasks/${taskId}.log`); + if (logResponse.ok) { + const logContent = await logResponse.text(); + if (logContent.trim()) { + outputElement.innerHTML = ` +
    ${this.taskManager.parseAnsiColors(logContent)}
    + `; + } else { + outputElement.innerHTML = ` +

    ℹ️ Information

    +
    No output available for this task.
    + `; + } + } else { + outputElement.innerHTML = ` +

    ℹ️ Information

    +
    No output available for this task.
    + `; + } + } + } catch (error) { + console.error(`❌ Error loading task output for ${taskId}:`, error); + const outputElement = document.querySelector(`[data-task-id="${taskId}"] .task-output`); + if (outputElement) { + outputElement.innerHTML = ` +

    ❌ Error

    +
    Failed to load task output: ${error.message}
    + `; + } + } + } + + // Auto-expand a task when it's created + async autoExpandTask(taskId) { + // console.log(`🔄 Auto-expanding task ${taskId}`); + + // Wait for task to be rendered + let attempts = 0; + const maxAttempts = 10; + + const tryExpand = async () => { + attempts++; + // console.log(`🔄 Auto-expand attempt ${attempts}/${maxAttempts} for task ${taskId}`); + + // Check if task element exists + const taskElement = document.querySelector(`[data-task-id="${taskId}"]`); + if (!taskElement) { + // console.log(`⚠️ Task element not found for ${taskId}, attempt ${attempts}`); + if (attempts < maxAttempts) { + setTimeout(tryExpand, 500); // Try again in 500ms + } else { + console.warn(`⚠️ Could not find task element for ${taskId} after ${maxAttempts} attempts`); + } + return; + } + + // console.log(`✅ Found task element for ${taskId}`); + + // Get the details element + const details = document.getElementById(`details-${taskId}`); + if (!details) { + // console.log(`⚠️ Details element not found for ${taskId}, attempt ${attempts}`); + if (attempts < maxAttempts) { + setTimeout(tryExpand, 500); + } else { + console.warn(`⚠️ Could not find details element for ${taskId}`); + } + return; + } + + // console.log(`✅ Found details element for ${taskId}`); + + // Expand the task details + details.style.display = 'block'; + details.classList.add('task-details-open'); + + // Update toggle button + const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`); + if (toggleBtn) { + toggleBtn.classList.add('expanded'); + } + + // Scroll to the task + taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + // Load the output if task is completed + const task = await this.taskManager.getTask(taskId); + if (task && (task.status === 'completed' || task.status === 'failed')) { + setTimeout(() => { + this.loadTaskOutput(taskId); + }, 1000); + } + + // console.log(`✅ Auto-expanded task ${taskId}`); + }; + + tryExpand(); + } + + // Update highlighted task status and UI + async updateHighlightedTaskStatus(taskId) { + try { + // Use lightweight summary for status updates + const task = await this.taskManager.getTaskSummary(taskId); + if (!task) return; + + // console.log(`🔄 Updating highlighted task ${taskId} status: ${task.status}`); + + // Update task display + this.updateTaskDisplay(task); + + // If task completed or failed, always load output + if ((task.status === 'completed' || task.status === 'failed')) { + // console.log(`🔄 Task ${taskId} is ${task.status}, loading output...`); + + const details = document.getElementById(`details-${taskId}`); + if (details && details.style.display === 'block') { + // Load output regardless of current content + this.loadTaskOutput(taskId); + } else if (details) { + // If details aren't open, mark for loading when opened + details.setAttribute('data-load-output-on-open', 'true'); + } + } + } catch (error) { + console.error(`❌ Error updating highlighted task status for ${taskId}:`, error); + } + } + + toggleTaskDetails(taskId) { + const details = document.getElementById(`details-${taskId}`); + const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`); + + if (details) { + const isOpen = details.style.display === 'block'; + + // Close all other task details and reset their buttons + if (isOpen) { + document.querySelectorAll('.task-details').forEach(otherDetails => { + if (otherDetails.id !== `details-${taskId}`) { + otherDetails.style.display = 'none'; + otherDetails.classList.remove('task-details-open'); + } + }); + + document.querySelectorAll('.task-btn.toggle-details').forEach(otherBtn => { + if (!otherBtn.getAttribute('onclick').includes(taskId)) { + otherBtn.classList.remove('expanded'); + } + }); + + // Close current + details.style.display = 'none'; + details.classList.remove('task-details-open'); + if (toggleBtn) toggleBtn.classList.remove('expanded'); + } else { + // Open current + details.style.display = 'block'; + details.classList.add('task-details-open'); + if (toggleBtn) toggleBtn.classList.add('expanded'); + + // Auto-load logs when opened. For active tasks, hand off to the live + // streamer so SSE chunks keep updating the panel; for terminal tasks + // a one-shot snapshot is enough. + const t = this.tasks.find(x => x.id === taskId); + if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) { + this.startLogStreaming(taskId, t); + } else { + this.loadTaskLogs(taskId); + } + + // Scroll to task + const taskElement = document.querySelector(`[data-task-id="${taskId}"]`); + if (taskElement) { + taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + + // Update URL to include task parameter + this.updateURL(this.currentCategory, isOpen ? null : taskId); + this.highlightedTaskId = isOpen ? null : taskId; + } else { + // Remove task parameter from URL + this.updateURL(this.currentCategory); + this.highlightedTaskId = null; + } + } + + async retryTask(taskId) { + if (!confirm('Are you sure you want to retry this task?')) return; + + try { + const task = this.tasks.find(t => t.id === taskId); + if (!task) { + throw new Error('Task not found'); + } + + // Create a new task with the same command + const newTask = await this.taskManager.createTask( + task.command, + task.type, + task.app, + task.config + ); + + //// // console.log(`✅ Task retried: ${newTask.id}`); + + // Refresh tasks to show the new one + await this.loadTasks(); + + if (window.notificationSystem) { + // Use the source task's type icon — retrying a backup shows 💾 etc. + const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : ''; + const customIcon = typeIcon ? `${typeIcon}` : null; + window.notificationSystem.show('Task retried successfully', 'success', null, null, null, customIcon); + } + } catch (error) { + console.error('Error retrying task:', error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to retry task: ${error.message}`); + } + } + } + + async deleteTask(taskId) { + // Get the latest known status before deciding what to do. The server + // refuses to delete tasks that are still running or queued (HTTP 409), + // and we used to just propagate that as a red banner — but the user is + // almost always trying to clean up a row that looked finished, or a + // genuinely active task they want gone. Either way the right answer is + // "cancel first, then delete", which is what we do here. + const cached = (window.taskEventBus && window.taskEventBus.getTask) + ? window.taskEventBus.getTask(taskId) + : null; + let task = cached || (this.tasks && this.tasks.find(t => t.id === taskId)); + if (!task && this.taskManager && this.taskManager.getTask) { + try { task = await this.taskManager.getTask(taskId); } catch {} + } + + const isActive = task && (task.status === 'running' || task.status === 'queued' || task.status === 'pending'); + + const confirmed = await this._showDeleteTaskModal(task, isActive); + if (!confirmed) return; + + try { + if (isActive) { + // Ask the server to cancel. POST /cancel either flips status straight + // to `cancelled` (queued -> cancelled is synchronous) or drops a + // `.cancel` marker the bash processor picks up within ~1s + // (running -> cancelled). We then wait for the terminal status via + // SSE before issuing the delete. + try { await this.taskManager.cancelTask(taskId); } catch { + // If cancel itself failed (e.g. already terminal), fall through + // and try the delete anyway. + } + await this._waitForTaskTerminal(taskId, 15_000); + } + + // Try the polite delete first. If the task is still flagged as + // running/queued server-side (e.g. the bash processor is dead and + // never picked up the cancel marker), fall back to the force-delete + // override so the user isn't stuck with a permanently-undeletable row. + try { + await this.taskManager.deleteTask(taskId); + } catch (err) { + const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : ''); + if (!looks409) throw err; + await this.taskManager.deleteTask(taskId, { force: true }); + } + + // Remove from local array and re-render + this.tasks = this.tasks.filter(t => t.id !== taskId); + this.renderTasks(); + this.updateStats(); + this.updateSidebarCounts(); + this.generateAppCategories(); + + if (window.notificationSystem) { + // Type icon from whatever the task was, with a trash fallback + // because deletion is conceptually a 🗑️ action. + const typeIcon = (task && this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '') || '🗑️'; + const customIcon = `${typeIcon}`; + window.notificationSystem.show('Task deleted successfully', 'info', null, null, null, customIcon); + } + } catch (error) { + console.error('Error deleting task:', error); + if (window.notificationSystem) { + window.notificationSystem.error(`Failed to delete task: ${error.message}`); + } + } + } + + // Confirmation modal for deleteTask. Uses the shared openEoModal so it + // visually matches every other destructive confirmation on the app + // (Uninstall, Apply Configuration, etc). Resolves true if the user + // confirms Delete, false on Cancel / backdrop click / close. + _showDeleteTaskModal(task, isActive) { + return new Promise((resolve) => { + const escHtml = (s) => String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>'); + + const taskLabel = (task && (task.command || task.id)) || 'Unknown task'; + const taskStatus = (task && task.status) || 'unknown'; + + const warningTitle = isActive ? 'Active task' : 'This cannot be undone'; + const warningText = isActive + ? 'This task is still running or queued. It will be cancelled first, then deleted.' + : 'The task and its logs will be permanently removed.'; + + const bodyHtml = ` +
    +
    + + + + + +
    +
    +

    ${escHtml(warningTitle)}

    +

    ${escHtml(warningText)}

    +
    +
    + ${window.eoBadgeRow ? window.eoBadgeRow([ + { label: `Status: ${taskStatus}`, variant: isActive ? 'warning' : 'info' } + ]) : ''} + `; + + let decided = false; + const finish = (val, modal) => { + if (decided) return; + decided = true; + if (modal) modal.close(); + resolve(val); + }; + + window.openEoModal({ + id: 'delete-task-modal', + size: 'sm', + eyebrow: 'Delete Task', + title: taskLabel, + desc: 'Confirm to delete this task.', + body: bodyHtml, + actions: [ + { label: 'Delete Task', variant: 'danger', onClick: (m) => finish(true, m) }, + { label: 'Cancel', variant: 'secondary', onClick: (m) => finish(false, m) } + ], + onClose: () => finish(false, null) + }); + }); + } + + // Resolves once the task reaches completed/failed/cancelled, or after the + // timeout. Used by deleteTask so a cancel request has time to take effect + // before we issue the DELETE that would otherwise 409. + _waitForTaskTerminal(taskId, timeoutMs = 15_000) { + return new Promise((resolve) => { + // Already terminal in the bus cache? No need to wait. + const cached = (window.taskEventBus && window.taskEventBus.getTask) ? window.taskEventBus.getTask(taskId) : null; + if (cached && (cached.status === 'completed' || cached.status === 'failed' || cached.status === 'cancelled')) { + return resolve(cached); + } + + let timer; + const cleanup = () => { + window.removeEventListener('taskCompleted', onComplete); + window.removeEventListener('taskUpdated', onUpdate); + if (timer) clearTimeout(timer); + }; + const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled'; + const onComplete = (e) => { + const t = e.detail && (e.detail.task || (e.detail.taskId === taskId ? { id: taskId, status: e.detail.status } : null)); + const id = (t && t.id) || (e.detail && e.detail.taskId); + if (id !== taskId) return; + cleanup(); + resolve(t); + }; + const onUpdate = (e) => { + const t = e.detail && e.detail.task; + if (!t || t.id !== taskId) return; + if (isTerminal(t.status)) { cleanup(); resolve(t); } + }; + window.addEventListener('taskCompleted', onComplete); + window.addEventListener('taskUpdated', onUpdate); + timer = setTimeout(() => { cleanup(); resolve(null); }, timeoutMs); + }); + } + + async clearAllTasks() { + // Use the confirmation dialog system if available, otherwise fallback to confirm + return new Promise((resolve) => { + if (window.showConfirmation) { + window.showConfirmation( + 'Clear All Tasks', + 'Are you sure you want to clear all tasks? This will delete all task history and cannot be undone.', + () => { + this.performClearAll(); + resolve(true); + }, + 'Yes, Clear All', + 'Cancel', + 'clear', + false + ); + } else { + // Fallback to native confirm + const confirmed = confirm('Are you sure you want to clear all tasks? This will delete all task history.'); + if (confirmed) { + this.performClearAll(); + } + resolve(confirmed); + } + }); + } + + async performClearAll() { + // Show progress notification with the trash type icon in the left slot + // (this is conceptually a delete-all action, so 🗑️ matches the row icon). + const customIcon = '🗑️'; + const progressNotification = window.notificationSystem.show( + 'Clearing all tasks...', + 'info', + null, + null, + null, + customIcon + ); + + try { + // Delete all tasks using the task manager + const deletePromises = this.tasks.map(task => + this.taskManager.deleteTask(task.id) + ); + + await Promise.all(deletePromises); + + // Clear local array and re-render + this.tasks = []; + this.renderTasks(); + this.updateStats(); + this.updateSidebarCounts(); + this.generateAppCategories(); + + // Remove progress notification and show success + if (progressNotification && progressNotification.remove) { + progressNotification.remove(); + } + + if (window.notificationSystem) { + window.notificationSystem.show( + 'All tasks cleared successfully', + 'success', + null, + null, + null, + customIcon + ); + } + } catch (error) { + console.error('Error clearing tasks:', error); + + // Remove progress notification and show error + if (progressNotification && progressNotification.remove) { + progressNotification.remove(); + } + + if (window.notificationSystem) { + window.notificationSystem.show( + `Failed to clear tasks: ${error.message}`, + 'error', + null, + null, + null, + customIcon + ); + } + } + } + + getTimeAgo(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + showError(message) { + const container = document.getElementById('tasks-list'); + if (container) { + container.innerHTML = ` +
    +
    $ ${message}
    +
    _
    +
    + `; + } + } + + destroy() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + } +} + +// Export for use in other modules +window.TasksManager = TasksManager; diff --git a/containers/libreportal/frontend/js/components/topbar.js b/containers/libreportal/frontend/js/components/topbar.js new file mode 100755 index 0000000..5a0ce05 --- /dev/null +++ b/containers/libreportal/frontend/js/components/topbar.js @@ -0,0 +1,399 @@ +// Topbar component functionality +class TopbarComponent { + constructor() { + this.init(); + } + + getCurrentPage() { + const path = window.location.pathname; + + //// // console.log('🔍 Topbar: Detecting page from path:', path); + + // PRIMARY: Use path-based detection only (most reliable) + // This avoids confusion from query parameters like tab=, app=, etc. + if (path.startsWith('/app') || path === '/app') { + return 'app'; + } + if (path.startsWith('/apps') || path === '/apps') { + return 'apps'; + } + if (path.startsWith('/config') || path === '/config') { + return 'config'; + } + if (path.startsWith('/tasks') || path === '/tasks') { + return 'tasks'; + } + if (path.startsWith('/backup') || path === '/backup') { + return 'backup'; + } + if (path === '/' || path === '/dashboard') { + return 'dashboard'; + } + + // Fallback to filename extraction for backward compatibility + const filename = path.split('/').pop() || 'dashboard.html'; + const pageType = filename.replace('.html', ''); + return pageType; + } + + // Static method to load topbar (only once in SPA) + static async loadTopbar() { + const container = document.getElementById('topbar-container'); + if (!container) { + console.error('Topbar container not found'); + return; + } + + // Load fresh topbar HTML + try { + //// // console.log('Loading topbar HTML (SPA mode)'); + const response = await fetch('html/topbar.html'); + if (!response.ok) { + throw new Error(`Failed to load topbar: ${response.status}`); + } + + const html = await response.text(); + container.innerHTML = html; + + // Initialize component + new TopbarComponent(); + + } catch (error) { + console.error('Error loading topbar:', error); + } + } + + init() { + this.setupNavigation(); + this.setActiveNav(); + this.setupThemeManager(); + this.setupLogout(); + this.setupConfigUpdateLockout(); + this.setupSetupGate(); + } + + // Disable nav items entirely until the Setup Wizard has been completed. + // The wizard itself runs as a full-screen overlay that blocks interaction; + // this is a belt-and-braces guard for the brief window before the wizard + // mounts, and for any nav rendering that happens while it's open. + async setupSetupGate() { + try { + const res = await fetch('/api/setup/status'); + if (!res.ok) return; + const { complete } = await res.json(); + const nav = document.querySelector('.topbar-nav'); + if (!nav) return; + if (!complete) { + nav.classList.add('setup-needed'); + } else { + nav.classList.remove('setup-needed'); + } + } catch { /* leave nav enabled if status check fails */ } + } + + // Disable App Center / Config nav while a config_update task runs. + setupConfigUpdateLockout() { + const setLocked = (locked) => { + ['nav-app-center', 'nav-config'].forEach((id) => { + const el = document.getElementById(id); + if (!el) return; + if (locked) { + el.classList.add('nav-item-disabled'); + el.setAttribute('aria-disabled', 'true'); + el.title = 'Disabled while configuration is being applied'; + } else { + el.classList.remove('nav-item-disabled'); + el.removeAttribute('aria-disabled'); + el.title = ''; + } + }); + }; + + window.addEventListener('taskCreated', (event) => { + if (event.detail?.action === 'config_update') setLocked(true); + }); + window.addEventListener('taskCompleted', (event) => { + if (event.detail?.action === 'config_update') setLocked(false); + }); + } + + setupLogout() { + const btn = document.getElementById('logout-btn'); + if (btn) { + btn.addEventListener('click', () => window.authManager?.logout()); + } + } + + setupNavigation() { + // Add click handlers to navigation items + const navItems = document.querySelectorAll('.nav-item'); + navItems.forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + if (item.classList.contains('nav-item-disabled')) return; + const href = item.getAttribute('href'); + if (href) { + // Special handling for tasks navigation + if (item.id === 'nav-tasks' && window.tasksManager) { + // console.log('🔄 Tasks button clicked - forcing clean state and reloading...'); + + // Force clean state + window.tasksManager.highlightedTaskId = null; + window.tasksManager.currentCategory = 'all'; + window.tasksManager.tasks = []; // Clear the task list + + // Clear URL parameters + window.history.pushState({ category: 'all', taskId: null }, '', '/tasks?=all'); + + // Clear any localStorage filters + localStorage.removeItem('tasksDefaultFilter'); + + // Force reload all tasks + window.tasksManager.loadTasks().catch(error => { + console.warn('⚠️ Error refreshing tasks:', error); + }); + } + + // Use shared navigation utility + navigateToRoute(href); + } + }); + }); + + // Setup mobile menu + this.setupMobileMenu(); + } + + setupMobileMenu() { + const mobileMenuBtn = document.getElementById('mobile-menu-btn'); + const mobileOverlay = document.getElementById('mobile-overlay'); + const sidebar = document.getElementById('sidebar'); + + if (mobileMenuBtn && mobileOverlay) { + mobileMenuBtn.addEventListener('click', () => { + sidebar.classList.toggle('mobile-open'); + mobileOverlay.classList.toggle('active'); + document.body.style.overflow = sidebar.classList.contains('mobile-open') ? 'hidden' : ''; + }); + + mobileOverlay.addEventListener('click', () => { + sidebar.classList.remove('mobile-open'); + mobileOverlay.classList.remove('active'); + document.body.style.overflow = ''; + }); + } + } + + setupThemeManager() { + const themeSelector = document.getElementById('theme-selector'); + if (!themeSelector) return; + + const savedTheme = TopbarComponent.resolveSavedTheme(); + this.setTheme(savedTheme); + + // Populate the dropdown from ThemeRegistry. Called twice — once + // synchronously with the built-in fallback list, then again after + // the API discovery resolves with any custom themes. + const renderOptions = (themes) => { + const current = localStorage.getItem('theme') || savedTheme; + themeSelector.innerHTML = ''; + themes.forEach((t) => { + const opt = document.createElement('option'); + opt.value = t.name; + opt.textContent = t.displayName || t.name; + themeSelector.appendChild(opt); + }); + // If the saved theme is now in the list, select it; otherwise + // leave whatever the browser chose as the default selection. + if (themes.some((t) => t.name === current)) { + themeSelector.value = current; + } + }; + + if (window.ThemeRegistry && typeof window.ThemeRegistry.onChange === 'function') { + window.ThemeRegistry.onChange(renderOptions); + } else { + // ThemeRegistry didn't load — fall back to the built-in three. + renderOptions([ + { name: 'nebula', displayName: 'Nebula' }, + { name: 'dark-blue', displayName: 'Dark Blue' }, + { name: 'light', displayName: 'Light' }, + ]); + } + + themeSelector.addEventListener('change', (e) => { + this.setTheme(e.target.value); + }); + } + + // Reads localStorage, migrates legacy values, and returns the canonical + // theme name. Old "dark" / "blue" both map to the new "dark-blue". + // An earlier dead initializer wrote to "selectedTheme" — fold that in + // and drop it. We don't validate against a fixed list anymore because + // custom themes are discovered at runtime — any string is accepted. + static resolveSavedTheme() { + const legacy = localStorage.getItem('selectedTheme'); + if (legacy && !localStorage.getItem('theme')) { + localStorage.setItem('theme', legacy); + } + if (legacy) localStorage.removeItem('selectedTheme'); + + let theme = localStorage.getItem('theme'); + if (theme === 'dark' || theme === 'blue') { + theme = 'dark-blue'; + localStorage.setItem('theme', theme); + } + if (!theme) { + theme = 'nebula'; + localStorage.setItem('theme', theme); + } + return theme; + } + + setActiveNav() { + // Remove active class from all nav items + document.querySelectorAll('.nav-item').forEach(item => { + item.classList.remove('active'); + }); + + // Use path-based detection only for consistency + const path = window.location.pathname; + + let activeNavId; + + // PRIMARY: Use path-based detection only (most reliable) + if (path.startsWith('/app') || path.startsWith('/apps')) { + activeNavId = 'nav-app-center'; + } else if (path.startsWith('/config')) { + activeNavId = 'nav-config'; + } else if (path.startsWith('/tasks')) { + activeNavId = 'nav-tasks'; + } else if (path.startsWith('/backup')) { + activeNavId = 'nav-backup'; + } else if (path === '/' || path === '/dashboard') { + activeNavId = 'nav-dashboard'; + } else { + // Fallback to page detection + if (this.currentPage === 'dashboard') { + activeNavId = 'nav-dashboard'; + } else if (this.currentPage === 'index' || this.currentPage === 'app' || this.currentPage === 'apps') { + activeNavId = 'nav-app-center'; + } else if (this.currentPage === 'config') { + activeNavId = 'nav-config'; + } else if (this.currentPage === 'tasks') { + activeNavId = 'nav-tasks'; + } else if (this.currentPage === 'backup') { + activeNavId = 'nav-backup'; + } else { + activeNavId = 'nav-dashboard'; // default + } + } + + // Add active class to current page nav + if (activeNavId) { + const activeNav = document.getElementById(activeNavId); + if (activeNav) { + activeNav.classList.add('nav-active'); + } else { + console.warn(`❌ Nav element not found: ${activeNavId}`); + } + } else { + console.warn(`❌ Could not determine active nav for path: ${path}`); + } + } + + setTheme(theme) { + localStorage.setItem('theme', theme); + document.documentElement.setAttribute('data-theme', theme); + document.body.setAttribute('data-theme', theme); + } + + static clearAllNavigationHighlighting() { + //// // console.log('🧹 Aggressively clearing all navigation highlighting'); + + // Remove ALL active classes from ALL navigation items + document.querySelectorAll('.nav-item').forEach(item => { + //// // console.log('🧹 Clearing nav item:', item.id, item.textContent.trim()); + item.classList.remove('active'); + item.classList.remove('nav-active'); + // Also remove any other possible active states + item.removeAttribute('aria-current'); + item.blur(); // Remove focus + }); + + // Also clear other active classes that might interfere + document.querySelectorAll('.category.active').forEach(item => { + item.classList.remove('active'); + }); + document.querySelectorAll('.tab-button.active').forEach(item => { + item.classList.remove('active'); + }); + + //// // console.log('🧹 Cleared all navigation highlighting'); + } + + static createNavigationHighlighting() { + // Define the single function that handles navigation highlighting + window.topbarNavigationHighlighting = function() { + // Always clear all existing navigation first to prevent sticky highlighting + TopbarComponent.clearAllNavigationHighlighting(); + + // PRIMARY: Use path-based detection only (most reliable) + // This avoids confusion from query parameters like tab=, app=, etc. + const path = window.location.pathname; + let activeNavId; + + if (path.startsWith('/app') || path.startsWith('/apps')) { + activeNavId = 'nav-app-center'; + } else if (path.startsWith('/config')) { + activeNavId = 'nav-config'; + } else if (path.startsWith('/tasks')) { + activeNavId = 'nav-tasks'; + } else if (path.startsWith('/backup')) { + activeNavId = 'nav-backup'; + } else if (path === '/' || path === '/dashboard') { + activeNavId = 'nav-dashboard'; + } else { + // Fallback: use currentPage detection + const currentPage = new TopbarComponent().getCurrentPage(); + switch (currentPage) { + case 'index.html': + case '': + case 'index': + case 'app': + case 'apps': + activeNavId = 'nav-app-center'; + break; + case 'config': + activeNavId = 'nav-config'; + break; + case 'tasks': + activeNavId = 'nav-tasks'; + break; + case 'backup': + activeNavId = 'nav-backup'; + break; + case 'dashboard': + activeNavId = 'nav-dashboard'; + break; + default: + activeNavId = 'nav-dashboard'; + } + } + + // Add active class to current page nav + if (activeNavId) { + const activeNav = document.getElementById(activeNavId); + if (activeNav) { + activeNav.classList.add('nav-active'); + } else { + console.warn(`❌ Topbar: Nav element not found: ${activeNavId}`); + } + } else { + console.warn(`❌ Topbar: Could not determine active nav for path: ${path}`); + } + }; + + //// // console.log('🌐 Topbar navigation highlighting function created'); + } +} diff --git a/containers/libreportal/frontend/js/spa.js b/containers/libreportal/frontend/js/spa.js new file mode 100755 index 0000000..6d6d4b4 --- /dev/null +++ b/containers/libreportal/frontend/js/spa.js @@ -0,0 +1,550 @@ +// Clean SPA Router - Unified routing system for LibrePortal +class LibrePortalSPAClean { + constructor() { + this.routes = new Map(); + this.currentRoute = null; + this.isLoading = false; + this.dataLoaded = false; + this.apps = []; + this.categories = []; + this.init(); + } + + async init() { + //console.log('🚀 Clean SPA: Initializing...'); + + // Setup routes immediately + this.setupRoutes(); + + // Wait for DOM to be ready + if (document.readyState === 'loading') { + await new Promise(resolve => { + document.addEventListener('DOMContentLoaded', resolve); + }); + } + + // Wait for topbar to load first + await this.waitForTopbar(); + + // Load data first + await this.loadCoreData(); + + // Handle initial route + this.handleInitialRoute(); + + //console.log('✅ Clean SPA: Initialization complete'); + } + + async waitForTopbar() { + //console.log('⏳ Waiting for topbar to load...'); + + // Wait for topbar component to be available and loaded + let attempts = 0; + const maxAttempts = 20; // 2 seconds max (reduced from 5 seconds) + + while (attempts < maxAttempts) { + if (typeof TopbarComponent !== 'undefined' && TopbarComponent.loadTopbar) { + try { + await TopbarComponent.loadTopbar(); + //console.log('✅ Topbar loaded successfully'); + return; + } catch (error) { + console.warn('⚠️ Topbar loading failed, retrying...', error); + } + } + + await new Promise(resolve => setTimeout(resolve, 100)); + attempts++; + } + + console.warn('⚠️ Topbar failed to load after 2 seconds, continuing without topbar'); + // Don't block the entire app if topbar fails + } + + setupRoutes() { + // Clean route definitions with explicit handlers + this.routes.set('/', () => this.handleDashboard()); + this.routes.set('/dashboard', () => this.handleDashboard()); + this.routes.set('/apps', () => this.handleApps()); + this.routes.set('/app', () => this.handleAppDetail()); // Handle /app without query + this.routes.set('/app*', () => this.handleAppDetail()); // Handle /app with query + this.routes.set('/config', () => this.handleConfig()); // Handle /config without query + this.routes.set('/config*', () => this.handleConfig()); // Handle /config with query + this.routes.set('/tasks', () => this.handleTasks()); // Handle /tasks without query + this.routes.set('/tasks*', () => this.handleTasks()); // Handle /tasks with query + this.routes.set('/backup', () => this.handleBackup()); + this.routes.set('/backup*', () => this.handleBackup()); + + //console.log('📍 Routes registered:', Array.from(this.routes.keys())); + } + + async loadCoreData() { + //console.log('📊 Loading core data...'); + + try { + // Load apps + if (typeof DataLoader !== 'undefined' && DataLoader.loadApps) { + this.apps = await DataLoader.loadApps(); + window.apps = this.apps; + //console.log(`📱 Loaded ${this.apps.length} apps`); + } + + // Load categories + if (typeof DataLoader !== 'undefined' && DataLoader.loadCategories) { + this.categories = await DataLoader.loadCategories(); + window.categories = this.categories; + window.sidebarCategories = this.categories; // Ensure this is always available + //console.log(`📂 Loaded ${Object.keys(this.categories).length} categories`); + } + + this.dataLoaded = true; + //console.log('✅ Core data loaded successfully'); + + } catch (error) { + console.error('❌ Failed to load core data:', error); + this.showError('Failed to load application data'); + } + } + + handleInitialRoute() { + const path = window.location.pathname + window.location.search; + // console.log('🎯 SPA: Handling initial route:', path); + + // Handle root path - redirect to dashboard + if (path === '/' || path === '') { + // console.log('🏠 SPA: Redirecting to dashboard'); + this.navigate('/dashboard', false); + } else { + // console.log('🔀 SPA: Navigating to:', path); + this.navigate(path, false); // Don't add to history for initial load + } + } + + async navigate(path, addToHistory = true) { + // console.log('🚀 SPA: navigate called with:', path, 'addToHistory:', addToHistory); + + if (this.isLoading) { + // console.log('⏳ Navigation already in progress, ignoring:', path); + return; + } + + if (this.currentRoute === path && addToHistory) { + //console.log('🔄 Same route, skipping navigation:', path); + return; + } + + // Unsaved-config guard — an app's config panel registers + // window.__appConfigNavGuard while it has unsaved changes, so it can + // intercept navigation away (Apply / Discard / Stay). + if (typeof window.__appConfigNavGuard === 'function') { + try { + const decision = await window.__appConfigNavGuard(path); + if (decision === 'stay') return; + } catch (e) { + console.error('Nav guard error:', e); + } + } + + // Force data reload when navigating to dashboard, even if same route + if (path === '/dashboard' || path === '/') { + // console.log('🔄 Dashboard navigation detected, forcing data reload'); + // Trigger dashboard data reload after a short delay to ensure DOM is ready + setTimeout(() => { + if (typeof loadDashboardData === 'function') { + loadDashboardData(); + } + }, 100); + } + + this.isLoading = true; + //console.log('🧭 Navigating to:', path); + + try { + // Update browser history + if (addToHistory) { + history.pushState({ route: path }, '', path); + } + + this.currentRoute = path; + + // Find and execute route handler + const handler = this.findRouteHandler(path); + if (handler) { + await handler(); + } else { + console.warn('❌ No handler found for:', path); + this.showError('Page not found'); + } + + // Update navigation highlighting after content loads + if (typeof window.topbarNavigationHighlighting === 'function') { + //console.log('🔗 SPA: Updating navigation highlighting after navigation'); + window.topbarNavigationHighlighting(); + } + + } catch (error) { + console.error('❌ Navigation error:', error); + this.showError('Navigation failed'); + } finally { + this.isLoading = false; + } + } + + findRouteHandler(path) { + //console.log('🔍 Finding handler for path:', path); + + // Exact match first + if (this.routes.has(path)) { + //console.log('✅ Exact match found:', path); + return this.routes.get(path); + } + + // Handle query parameters - strip them for matching + let basePath = path; + if (path.includes('?')) { + basePath = path.split('?')[0]; + } + + //console.log('🔍 Checking base path:', basePath, 'for full path:', path); + + // Check base path against routes + if (this.routes.has(basePath)) { + //console.log('✅ Base path match found:', basePath); + return this.routes.get(basePath); + } + + // Wildcard matching for remaining cases + for (const [route, handler] of this.routes) { + if (route.includes('*')) { + const pattern = route.replace('*', ''); + if (basePath.startsWith(pattern)) { + //console.log('✅ Wildcard match:', pattern, 'for base path:', basePath); + return handler; + } + } + } + + //console.log('❌ No handler found for:', path); + //console.log('❌ Base path:', basePath); + //console.log('❌ Available routes:', Array.from(this.routes.keys())); + return null; + } + + async handleDashboard() { + // console.log('🏠 SPA: Loading dashboard...'); + + try { + // console.log('📄 SPA: Fetching dashboard content...'); + const html = await this.fetchContent('/html/dashboard-content.html'); + // console.log('📄 SPA: Dashboard content fetched, loading...'); + this.loadContent(html, 'Dashboard'); + // console.log('📄 SPA: Dashboard content loaded'); + + // Dashboard should already be initialized by SystemLoader + if (typeof loadInstalledApps === 'function') { + // console.log('📱 SPA: Loading installed apps...'); + loadInstalledApps(); + } + } catch (error) { + console.error('❌ Dashboard load error:', error); + this.showError('Failed to load dashboard'); + } + } + + async handleBackup() { + try { + const html = await this.fetchContent('/html/backup-content.html'); + this.loadContent(html, 'Backups'); + if (typeof BackupPage !== 'undefined') { + window.backupPage = new BackupPage(); + await window.backupPage.init(); + } else { + console.error('BackupPage class not loaded'); + } + } catch (error) { + console.error('❌ Backup page load error:', error); + this.showError('Failed to load backup page'); + } + } + + async handleApps() { + //console.log('📱 Loading apps...'); + + // Handle query parameters for apps + const path = window.location.pathname + window.location.search; + if (path.includes('?=')) { + const [basePath, query] = path.split('?='); + window.appsCategory = query || 'all'; + } else if (path.includes('?')) { + const url = new URL(path, window.location.origin); + const searchParams = url.searchParams; + window.appsCategory = searchParams.get('apps') || 'all'; + } else { + window.appsCategory = 'all'; + } + + try { + // Ensure unified layout is loaded (like the old SPA) + if (!document.querySelector('.apps-layout')) { + //console.log('📄 Loading apps layout HTML...'); + const html = await this.fetchContent('/html/apps-unified-layout.html'); + this.loadContent(html, 'Applications'); + } else { + //console.log('📄 Apps layout already exists, skipping HTML load'); + } + + // Apps manager should already be initialized by SystemLoader + if (window.appsManager) { + //console.log('✅ AppsManager already initialized by SystemLoader'); + await window.appsManager.initialize(); + + //console.log('✅ Apps loaded successfully'); + } else { + console.error('AppsManager not available - SystemLoader should have initialized it'); + throw new Error('AppsManager not initialized by SystemLoader'); + } + } catch (error) { + console.error('❌ Apps load error:', error); + this.showError('Failed to load applications: ' + error.message); + } + } + + async handleAppDetail() { + //console.log('🔍 Loading app detail...'); + + // Extract app name from URL + const url = new URL(window.location); + let appName = url.searchParams.get('app'); + + // Handle old format ?=appname&tab=tabname + if (!appName && url.search.includes('?=')) { + const queryPart = url.search.replace('?', ''); + const parts = queryPart.split('&'); + for (const part of parts) { + if (part.startsWith('=')) { + appName = part.substring(1); // Remove the '=' + break; + } + } + } + + //console.log('🔍 Parsed app name:', appName, 'from URL:', url.search); + + if (!appName) { + this.navigate('/apps', false); + return; + } + + try { + const html = await this.fetchContent('/html/apps-unified-layout.html'); + this.loadContent(html, appName); // Will be updated after app data loads + + // AppTabbedManager should already be initialized by SystemLoader + if (window.appTabbedManager) { + // console.log('✅ AppTabbedManager already initialized by SystemLoader'); + await window.appTabbedManager.initialize(); + } else { + console.error('AppTabbedManager not available - SystemLoader should have initialized it'); + throw new Error('AppTabbedManager not initialized by SystemLoader'); + } + + //console.log('✅ App detail loaded:', appName); + } catch (error) { + console.error('❌ App detail load error:', error); + this.showError('Failed to load application details'); + } + } + + async handleConfig() { + //console.log('⚙️ Loading config...'); + + // Handle query parameters for config + const path = window.location.pathname + window.location.search; + if (path.includes('?=')) { + const [basePath, query] = path.split('?='); + window.configCategory = query || 'general'; + } else if (path.includes('?')) { + const url = new URL(path, window.location.origin); + const searchParams = url.searchParams; + window.configCategory = searchParams.get('config') || 'general'; + } else { + window.configCategory = 'general'; + } + + try { + const html = await this.fetchContent('/html/config-content.html'); + this.loadContent(html, 'Configuration'); + + // Config manager should already be initialized by SystemLoader + if (window.configManager) { + // Render the actual configuration + if (typeof window.configManager.renderConfig === 'function') { + await window.configManager.renderConfig(window.configCategory || 'general'); + } + //console.log('✅ Config loaded'); + } else { + console.error('ConfigManager not available - SystemLoader should have initialized it'); + throw new Error('ConfigManager not initialized by SystemLoader'); + } + } catch (error) { + console.error('❌ Config load error:', error); + this.showError('Failed to load configuration'); + } + } + + async handleTasks() { + //console.log('📋 Loading tasks...'); + + try { + const html = await this.fetchContent('/html/tasks-content.html'); + this.loadContent(html, 'Tasks'); + + // Tasks manager should already be initialized by SystemLoader + if (window.tasksManager) { + //console.log('✅ TasksManager already initialized by SystemLoader'); + await window.tasksManager.init(); + } else { + console.warn('⚠️ TasksManager not available yet, task functionality will be limited'); + // Don't throw error - just show warning and continue + // The task system will be available when the user actually interacts with tasks + } + } catch (error) { + console.error('❌ Tasks load error:', error); + this.showError('Failed to load tasks'); + } + } + + async fetchContent(url) { + //console.log('📥 Fetching:', url); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.text(); + } + + async loadScript(src) { + const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_'); + if (document.getElementById(scriptId)) { + return; // Already loaded + } + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.id = scriptId; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + loadContent(html, title) { + const container = document.getElementById('main-content') || document.querySelector('.main'); + if (!container) { + throw new Error('Content container not found'); + } + + container.innerHTML = html; + document.title = `${title} - LibrePortal`; + + // Update navigation highlighting + this.updateNavigation(); + } + + updateNavigation() { + //console.log('🔗 SPA: Using fallback navigation logic'); + + // Remove ALL active classes + document.querySelectorAll('.nav-item').forEach(item => { + item.classList.remove('active'); + item.classList.remove('nav-active'); + }); + + // Use path-based detection only for consistency + const path = window.location.pathname; + + let activeId = 'nav-dashboard'; // default + + if (path.startsWith('/app') || path.startsWith('/apps')) { + activeId = 'nav-app-center'; + } else if (path.startsWith('/config')) { + activeId = 'nav-config'; + } else if (path.startsWith('/tasks')) { + activeId = 'nav-tasks'; + } else if (path.startsWith('/backup')) { + activeId = 'nav-backup'; + } else if (path === '/' || path === '/dashboard') { + activeId = 'nav-dashboard'; + } + + //console.log('🔗 SPA: Setting active nav:', activeId); + const activeElement = document.getElementById(activeId); + if (activeElement) { + activeElement.classList.add('nav-active'); + } + } + + showError(message) { + const container = document.getElementById('main-content') || document.querySelector('.main'); + if (container) { + container.innerHTML = ` +
    +

    Error

    +

    ${message}

    + +
    + `; + } + } +} + +// Global navigation function for click handlers +window.navigateToRoute = function(href) { + if (window.spaClean) { + //console.log('🔗 Converting href:', href); + + // Convert href to clean path - handle various formats + let route = href.replace('.html', '').replace('./', '').replace(/^\//, ''); + + // Handle special cases + if (route === '' || route === 'index') { + route = '/apps'; // index goes to apps (main app center) + } else if (route === 'dashboard') { + route = '/dashboard'; + } else if (route === 'apps') { + route = '/apps'; + } else if (route === 'config') { + route = '/config?=general'; + } else if (route === 'tasks') { + route = '/tasks'; + } else if (!route.startsWith('/')) { + route = '/' + route; + } + + //console.log('🎯 Final route:', route); + window.spaClean.navigate(route); + } +}; + +// Handle browser back/forward +window.addEventListener('popstate', (e) => { + if (e.state && e.state.route && window.spaClean) { + window.spaClean.navigate(e.state.route, false); + } +}); + +// Handle internal link clicks +document.addEventListener('click', (e) => { + const target = e.target.closest('a'); + if (target && target.href) { + const href = target.getAttribute('href'); + if (href && (href.startsWith('/') || href.startsWith('./'))) { + e.preventDefault(); + window.navigateToRoute(href); + } + } +}); + +// SPA initialization is now handled by SystemLoader +// LibrePortalSPAClean instance will be created centrally diff --git a/containers/libreportal/frontend/js/system/auth-manager.js b/containers/libreportal/frontend/js/system/auth-manager.js new file mode 100755 index 0000000..cbe21dd --- /dev/null +++ b/containers/libreportal/frontend/js/system/auth-manager.js @@ -0,0 +1,177 @@ +class AuthManager { + constructor() { + this.isAuthenticated = false; + this.username = null; + this._resolveLogin = null; + this._overlayEl = null; + } + + async initialize() { + const status = await this._checkStatus(); + if (status.authenticated) { + this.isAuthenticated = true; + this.username = status.username; + return; + } + return this._showLoginOverlay(); + } + + async _checkStatus() { + try { + const res = await fetch('/api/auth/status'); + return await res.json(); + } catch { + return { authenticated: false }; + } + } + + async login(username, password) { + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const data = await res.json(); + if (res.ok && data.success) { + this.isAuthenticated = true; + this.username = data.username; + this._hideLoginOverlay(); + if (this._resolveLogin) this._resolveLogin(); + return { success: true }; + } + return { success: false, error: data.error || 'Invalid credentials' }; + } catch { + return { success: false, error: 'Connection error' }; + } + } + + async logout() { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + } catch { /* ignore */ } + window.location.reload(); + } + + _showLoginOverlay() { + return new Promise(resolve => { + this._resolveLogin = resolve; + + const overlay = document.createElement('div'); + overlay.className = 'login-overlay aurora-bg aurora-static'; + overlay.innerHTML = ` + + + + + + `; + + // Add to page + document.body.appendChild(this.container); + this.isVisible = true; + + // Get references to elements + this.progressBar = document.getElementById('progress-fill'); + this.systemStatusContainer = document.getElementById('system-status-container'); + this.errorMessage = null; + this.retryButton = document.getElementById('retry-button'); + this.continueButton = document.getElementById('continue-button'); + + // Initialize system cards + this.initializeSystemCards(); + } + + // Initialize system status cards + initializeSystemCards() { + const systems = [ + { id: 'core', name: 'Core System', icon: '🪐' }, + { id: 'data', name: 'Data Loading', icon: '📊' }, + { id: 'components', name: 'UI Components', icon: '🎨' }, + { id: 'task', name: 'Task System', icon: '📋' }, + { id: 'managers', name: 'System Managers', icon: '🔧' }, + { id: 'backend', name: 'Backend Services', icon: '🌐' }, + { id: 'config-validation', name: 'Config Files', icon: '🔍' }, + { id: 'update-lock', name: 'Update Lock', icon: '🔄' }, + { id: 'pause-lock', name: 'Pause Lock', icon: '⏸️' } + ]; + + systems.forEach(system => { + const card = document.createElement('div'); + card.className = 'system-card'; + card.id = `system-card-${system.id}`; + card.innerHTML = ` +
    ${system.icon}
    +
    +
    ${system.name}
    +
    Waiting...
    +
    +
    +
    +
    + `; + + this.systemStatusContainer.appendChild(card); + this.systemCards.set(system.id, card); + }); + } + + // Update progress + updateProgress(progress, details = '') { + if (this.progressBar) { + this.progressBar.style.width = `${progress}%`; + } + + const percentageElement = document.getElementById('progress-percentage'); + if (percentageElement) { + percentageElement.textContent = `${Math.round(progress)}%`; + } + + const detailsElement = document.getElementById('progress-details'); + if (detailsElement) { + detailsElement.textContent = details || `${Math.round(progress)}% complete`; + } + + // Update loading tip + this.updateLoadingTip(progress); + } + + // Process message to convert backticks to styled code blocks + processMessage(message) { + if (!message) return message; + + // Convert backtick-wrapped text to styled code blocks + return message.replace(/`([^`]+)`/g, '$1'); + } + + // Update system check status + updateSystemCheck(systemId, checkName, status, error = null, message = null) { + const card = this.systemCards.get(systemId); + if (!card) return; + + const statusElement = card.querySelector('.system-status'); + const indicatorElement = card.querySelector('.status-icon'); + + if (!statusElement || !indicatorElement) return; + + switch (status) { + case 'checking': + statusElement.textContent = `Checking: ${checkName}`; + indicatorElement.textContent = '⏳'; + card.className = 'system-card checking'; + // Auto-scroll to this card when checking starts + this.scrollToCard(card); + break; + + case 'retrying': + statusElement.innerHTML = `Retrying: ${checkName}`; + indicatorElement.textContent = '🔄'; + card.className = 'system-card retrying'; + // Don't auto-scroll during retry to prevent jumping around + if (message) { + statusElement.title = this.processMessage(message); + } + break; + + case 'waiting': + statusElement.textContent = `Waiting: ${checkName}`; + indicatorElement.textContent = '⏰'; + card.className = 'system-card waiting'; + break; + + case 'passed': + statusElement.textContent = 'Operational'; + indicatorElement.textContent = '✅'; + card.className = 'system-card passed'; + break; + + case 'failed': + statusElement.textContent = `Failed: ${checkName}`; + indicatorElement.textContent = '❌'; + card.className = 'system-card failed'; + if (error) { + statusElement.title = error; + } + break; + + case 'skipped': + statusElement.textContent = 'Skipped'; + indicatorElement.textContent = '⏭️'; + card.className = 'system-card skipped'; + break; + } + } + + // Scroll to specific card smoothly + scrollToCard(card) { + if (!card || !this.systemStatusContainer) return; + + const containerHeight = this.systemStatusContainer.clientHeight; + const cardHeight = card.offsetHeight; + const cardOffsetTop = card.offsetTop; + const containerScrollHeight = this.systemStatusContainer.scrollHeight; + + // Calculate target scroll position to center the card + let targetScrollTop = cardOffsetTop - (containerHeight / 2) + (cardHeight / 2); + + // Ensure we don't scroll past the bottom + const maxScrollTop = containerScrollHeight - containerHeight; + if (targetScrollTop > maxScrollTop) { + targetScrollTop = maxScrollTop; + } + + // Ensure we don't scroll before the top + if (targetScrollTop < 0) { + targetScrollTop = 0; + } + + // Smooth scroll to the target position + this.systemStatusContainer.scrollTo({ + top: targetScrollTop, + behavior: 'smooth' + }); + } + + // Update loading tip based on progress + updateLoadingTip(progress) { + const tipElement = document.getElementById('loading-tip'); + if (!tipElement) return; + + const tips = [ + 'Loading essential components...', + 'Preparing your workspace...', + 'Checking system dependencies...', + 'Initializing data connections...', + 'Almost ready...', + 'Finalizing launch...' + ]; + + const tipIndex = Math.floor((progress / 100) * tips.length); + const tip = tips[Math.min(tipIndex, tips.length - 1)]; + + if (tipElement.textContent !== tip) { + tipElement.textContent = tip; + } + } + + // Show error message + showError(errors) { + // console.log('🔍 LoadingUI.showError called with errors:', errors.length); + // console.log('🔍 Existing error details elements:', document.querySelectorAll('.error-details').length); + // console.log('🔍 Call stack:', new Error().stack); + + const actionsContainer = document.getElementById('loading-actions'); + + // Check if this is a missing data files issue + const hasMissingDataFiles = errors.some(error => + error.error && (error.error.includes('Missing required data file') || + error.error.includes('Critical configuration files are missing or empty')) + ); + + // Create error details + const errorDetails = document.createElement('div'); + errorDetails.className = 'error-details'; + + if (hasMissingDataFiles) { + // Special handling for missing data files + errorDetails.innerHTML = ` +

    🔧 LibrePortal Setup Required

    +
    +

    ❌ Required data files are missing!

    +

    LibrePortal needs to generate JSON configuration files before it can run properly.

    +
    + libreportal webui generate all +
    +

    + Run this command in your terminal to fix the issue. +

    +
    +
    +
      + ${errors.map(error => ` +
    • + ${error.system || error.checkName || 'Unknown'}: +
      ${(error.error || 'Check failed').replace(/\n\n/g, '

      ').replace(/\n/g, '
      ')}
      +
    • + `).join('')} +
    +
    +

    After running the setup command, refresh this page to continue.

    + `; + } else { + // Regular error handling + errorDetails.innerHTML = ` +

    ⚠️ Loading Issues Detected

    +
    +
      + ${errors.map(error => ` +
    • + ${error.system || error.checkName || 'Unknown'}: +
      ${(error.error || 'Check failed').replace(/\n\n/g, '

      ').replace(/\n/g, '
      ')}
      + ${error.error && error.error.includes('Timeout') ? '
      Try checking your network connection and refresh.
      ' : ''} +
    • + `).join('')} +
    +
    +

    You can retry loading or continue with limited functionality

    + `; + } + + // Insert before actions + if (actionsContainer && actionsContainer.parentNode) { + actionsContainer.parentNode.insertBefore(errorDetails, actionsContainer); + actionsContainer.style.display = 'flex'; + this.retryButton.style.display = 'inline-flex'; + this.continueButton.style.display = 'inline-flex'; + } + } + + // Hide loading screen + hide() { + if (this.container && this.isVisible) { + // Show success message and trigger animations + const successMessage = document.getElementById('success-message'); + if (successMessage) { + successMessage.style.display = 'block'; + } + + // Add success class to trigger animations + this.container.classList.add('success'); + + // Wait for success animations to play, then hide + setTimeout(() => { + this.container.classList.add('hiding'); + setTimeout(() => { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + this.isVisible = false; + }, 500); + }, 200); + } + } + + // Show loading screen + show() { + if (!this.isVisible && this.container) { + if (!this.container.parentNode) { + document.body.appendChild(this.container); + } + this.container.classList.remove('hiding'); + this.isVisible = true; + } + } + + // Update status text + updateStatus(text) { + const statusElement = document.getElementById('loading-status-text'); + if (statusElement) { + statusElement.textContent = text; + } + } + + // Attach event listeners + attachEventListeners() { + if (this.retryButton) { + this.retryButton.addEventListener('click', () => { + // console.log('🔄 Retry button clicked - refreshing page'); + window.location.reload(); + }); + } + + if (this.continueButton) { + this.continueButton.addEventListener('click', () => { + this.hide(); + // Trigger continue event + window.dispatchEvent(new CustomEvent('loadingContinue')); + }); + } + } + + // Cleanup + destroy() { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + this.isVisible = false; + this.systemCards.clear(); + } +} diff --git a/containers/libreportal/frontend/js/system/setup-completion-watcher.js b/containers/libreportal/frontend/js/system/setup-completion-watcher.js new file mode 100644 index 0000000..d1375b0 --- /dev/null +++ b/containers/libreportal/frontend/js/system/setup-completion-watcher.js @@ -0,0 +1,234 @@ +// Setup Completion Watcher +// +// Listens for the `taskCompleted` event the TaskEventBus dispatches when any +// task hits a terminal state. If the completed task is the finalize task +// recorded by the Setup Wizard handoff (stashed in sessionStorage), trigger +// the celebratory exit: toast notification, re-enable the topbar nav, then +// navigate to the dashboard. + +(function setupCompletionWatcher() { + function readHandoff() { + try { + const raw = sessionStorage.getItem('libreportal_setup_handoff'); + return raw ? JSON.parse(raw) : null; + } catch { return null; } + } + + function clearHandoff() { + try { sessionStorage.removeItem('libreportal_setup_handoff'); } catch { /* noop */ } + } + + // Floating banner shown across the top of any page while setup tasks are + // running. Surfaces the X-of-Y completion count from the live TaskEventBus + // snapshot so the user always knows how far through they are. Vanishes + // automatically when the finalize event fires (the same handler that runs + // the toast + dashboard redirect down below). + let bannerEl = null; + + function isOnTasksPage() { + const p = window.location.pathname; + return p === '/tasks' || p.startsWith('/tasks') || p === '/tasks.html'; + } + + function ensureBanner() { + if (bannerEl && document.body.contains(bannerEl)) return bannerEl; + bannerEl = document.createElement('div'); + bannerEl.className = 'setup-progress-banner'; + bannerEl.innerHTML = ` +
    + +
    + Setting up your install + — starting… +
    +
    +
    + `; + document.body.appendChild(bannerEl); + return bannerEl; + } + + function removeBanner() { + if (bannerEl) { + bannerEl.classList.add('leaving'); + setTimeout(() => { bannerEl?.remove(); bannerEl = null; }, 350); + } + } + + // Local cache keyed by task id. SSE bus only registers tasks once they fire + // an event AFTER it connects, so tasks that already finished before the page + // loaded are invisible to it. We seed this from /api/tasks on start and + // refresh from each SSE event so the banner reflects the true count. + const groupTasks = new Map(); + + async function seedGroupTasks() { + const handoff = readHandoff(); + if (!handoff || !handoff.setupGroup) return; + try { + const res = await fetch('/api/tasks', { cache: 'no-store' }); + if (!res.ok) return; + const all = await res.json(); + for (const t of all) { + if (t && t.setupGroup === handoff.setupGroup) groupTasks.set(t.id, t); + } + refreshBanner(); + // Reload-mid-setup case: if the finalize task already terminated + // before the page came back up, no SSE event will fire for it. The + // /api/tasks fetch above is the only signal — kick off the completion + // handler from here too so the toast/redirect still runs. + checkAlreadyCompleted(); + } catch { /* network blip — SSE events will fill in the gaps */ } + } + + function ingestEventTask(detail) { + const t = detail && detail.task; + if (!t || !t.id) return; + const handoff = readHandoff(); + if (!handoff || !handoff.setupGroup) return; + if (t.setupGroup !== handoff.setupGroup) return; + groupTasks.set(t.id, t); + } + + function refreshBanner() { + if (!isOnTasksPage()) { removeBanner(); return; } + const handoff = readHandoff(); + if (!handoff || !handoff.setupGroup) { removeBanner(); return; } + + const tasksInGroup = Array.from(groupTasks.values()); + + // Trust the handoff for the denominator — see seedGroupTasks for why + // the SSE bus alone undercounts. + const total = handoff.totalTaskCount || tasksInGroup.length; + if (total === 0) return; + + const done = tasksInGroup.filter(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled').length; + const failed = tasksInGroup.some(t => t.status === 'failed'); + + const banner = ensureBanner(); + banner.classList.toggle('failed', failed); + banner.querySelector('.setup-progress-banner-count').textContent = `— ${done} of ${total} tasks complete`; + const pct = total ? Math.round((done / total) * 100) : 0; + banner.querySelector('.setup-progress-banner-fill').style.width = `${pct}%`; + } + + function showWelcomeToast(installName, success) { + const sys = (typeof window.ensureNotificationSystem === 'function') + ? window.ensureNotificationSystem() + : window.notificationSystem; + if (!sys || typeof sys.show !== 'function') return; + + if (success) { + sys.show( + `Setup complete — welcome aboard, ${installName || 'Quantum Traveler'}. Your install is ready.`, + 'success' + ); + } else { + sys.show( + `Setup ran into an issue. Check the failed task above for details.`, + 'error' + ); + } + } + + function reEnableNav() { + document.querySelectorAll('.topbar-nav.setup-needed').forEach((nav) => { + nav.classList.remove('setup-needed'); + }); + } + + function navigateToRecommendedApps() { + const route = '/apps?=recommended'; + if (window.spaClean && typeof window.spaClean.navigate === 'function') { + window.spaClean.navigate(route); + } else if (typeof window.navigateToRoute === 'function') { + window.navigateToRoute(route); + } else { + window.location.href = 'apps.html?=recommended'; + } + } + + // Refresh the banner on every task event so the count stays live. + const onTaskEvent = (e) => { ingestEventTask(e.detail || {}); refreshBanner(); }; + window.addEventListener('taskCreated', onTaskEvent); + window.addEventListener('taskUpdated', onTaskEvent); + window.addEventListener('taskCompleted', onTaskEvent); + + // Re-evaluate on navigation: covers back/forward (popstate) and SPA + // pushState/replaceState (monkey-patched once). Without this the banner + // would linger after leaving /tasks (or never appear when arriving there) + // because nothing else triggers refreshBanner. + window.addEventListener('popstate', refreshBanner); + ['pushState', 'replaceState'].forEach((m) => { + const orig = history[m]; + history[m] = function (...args) { + const r = orig.apply(this, args); + refreshBanner(); + return r; + }; + }); + + // Seed once now and again every 3s as a safety net — covers the window + // between the page loading and the SSE bus connecting, plus any FS-watch + // hiccups that swallow a task.upsert event. + seedGroupTasks(); + setInterval(() => { + if (readHandoff()) seedGroupTasks(); + }, 3000); + + window.addEventListener('taskCompleted', (event) => { + const detail = event.detail || {}; + const handoff = readHandoff(); + if (!handoff || !handoff.finalizeTaskId) return; + if (detail.taskId !== handoff.finalizeTaskId) return; + + const success = detail.status === 'completed'; + clearHandoff(); + + // Defer to the next frame so the tasks-manager listener (registered on the + // same `taskCompleted` event) gets to flip the row to "completed" first — + // otherwise the welcome toast appears while the finalize row still shows + // "running", which reads as out-of-order. + requestAnimationFrame(() => { + removeBanner(); + reEnableNav(); + showWelcomeToast(handoff.installName, success); + if (success) setTimeout(navigateToRecommendedApps, 1800); + }); + }); + + // Also handle the case where the user reloads the tasks page after the + // finalize task has already completed — we won't get a fresh `taskCompleted` + // event, so check the bus snapshot once it's ready. + function checkAlreadyCompleted() { + const handoff = readHandoff(); + if (!handoff || !handoff.finalizeTaskId) return; + // Prefer the /api/tasks-seeded cache (groupTasks) — the SSE bus only + // knows tasks that emitted events after it connected, so reload-mid- + // setup leaves it blind to anything that already finished. + const task = groupTasks.get(handoff.finalizeTaskId) + || (window.taskEventBus && window.taskEventBus.getTask(handoff.finalizeTaskId)); + if (!task) return; + if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { + const success = task.status === 'completed'; + clearHandoff(); + removeBanner(); + reEnableNav(); + showWelcomeToast(handoff.installName, success); + if (success) setTimeout(navigateToRecommendedApps, 1800); + } + } + + window.addEventListener('taskBusReady', () => { + checkAlreadyCompleted(); + refreshBanner(); + }); + if (window.taskEventBus && window.taskEventBus.connected) { + checkAlreadyCompleted(); + refreshBanner(); + } +})(); diff --git a/containers/libreportal/frontend/js/system/setup-detector.js b/containers/libreportal/frontend/js/system/setup-detector.js new file mode 100755 index 0000000..73274c5 --- /dev/null +++ b/containers/libreportal/frontend/js/system/setup-detector.js @@ -0,0 +1,440 @@ +// Setup Detection - First-time user setup and installer detection +class SetupDetector { + constructor() { + this.setupSteps = new Map(); + this.currentStep = 0; + this.isFirstTime = false; + this.setupComplete = false; + } + + // Initialize setup detection + async initialize() { + this.setupSteps = this.defineSetupSteps(); + this.isFirstTime = await this.detectFirstTime(); + this.setupComplete = !this.isFirstTime; + + return { + isFirstTime: this.isFirstTime, + setupComplete: this.setupComplete + }; + } + + // Define setup steps + defineSetupSteps() { + return new Map([ + ['welcome', { + title: 'Welcome to LibrePortal', + description: 'Let\'s get your Docker management system set up', + component: 'welcome-step' + }], + ['requirements', { + title: 'System Requirements', + description: 'Checking your system compatibility', + component: 'requirements-step', + check: () => this.checkSystemRequirements() + }], + ['directories', { + title: 'Directory Setup', + description: 'Creating necessary directories', + component: 'directories-step', + check: () => this.checkDirectories() + }], + ['permissions', { + title: 'Permissions Check', + description: 'Verifying file permissions', + component: 'permissions-step', + check: () => this.checkPermissions() + }], + ['configuration', { + title: 'Basic Configuration', + description: 'Setting up initial configuration', + component: 'config-step', + check: () => this.checkBasicConfig() + }], + ['complete', { + title: 'Setup Complete', + description: 'Your LibrePortal system is ready', + component: 'complete-step' + }] + ]); + } + + // Detect if this is first-time setup. Source of truth is the server-side + // lock file at /docker/containers/libreportal/frontend/data/.setup_complete + // — that's what the bash setupApply function creates after a successful + // wizard run. localStorage is no longer used because per-browser state + // gives wrong answers (different browsers would each see "first install"). + async detectFirstTime() { + try { + const res = await fetch('/api/setup/status'); + if (!res.ok) return true; + const data = await res.json(); + return !data.complete; + + } catch (error) { + console.error('Error detecting first-time setup:', error); + return true; // Assume first time on error + } + } + + // Check system requirements + async checkSystemRequirements() { + const checks = []; + + // Check browser compatibility + const browserCheck = this.checkBrowserCompatibility(); + checks.push({ + name: 'Browser Compatibility', + status: browserCheck.passed, + details: browserCheck.details + }); + + // Check JavaScript features + const jsCheck = this.checkJavaScriptFeatures(); + checks.push({ + name: 'JavaScript Features', + status: jsCheck.passed, + details: jsCheck.details + }); + + // Check storage availability + const storageCheck = this.checkStorageAvailability(); + checks.push({ + name: 'Local Storage', + status: storageCheck.passed, + details: storageCheck.details + }); + + // Check network connectivity + const networkCheck = await this.checkNetworkConnectivity(); + checks.push({ + name: 'Network Connectivity', + status: networkCheck.passed, + details: networkCheck.details + }); + + return { + passed: checks.every(check => check.status), + checks + }; + } + + // Check browser compatibility + checkBrowserCompatibility() { + const userAgent = navigator.userAgent; + const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor); + const isFirefox = /Firefox/.test(userAgent); + const isSafari = /Safari/.test(userAgent) && /Apple Computer/.test(navigator.vendor); + const isEdge = /Edg/.test(userAgent); + + const supported = isChrome || isFirefox || isSafari || isEdge; + + return { + passed: supported, + details: { + browser: this.getBrowserName(), + supported, + version: navigator.userAgent.match(/(?:Chrome|Firefox|Safari|Edge)\/(\d+)/)?.[1] || 'Unknown' + } + }; + } + + // Get browser name + getBrowserName() { + const userAgent = navigator.userAgent; + if (/Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor)) return 'Chrome'; + if (/Firefox/.test(userAgent)) return 'Firefox'; + if (/Safari/.test(userAgent) && /Apple Computer/.test(navigator.vendor)) return 'Safari'; + if (/Edg/.test(userAgent)) return 'Edge'; + return 'Unknown'; + } + + // Check JavaScript features + checkJavaScriptFeatures() { + const features = [ + { name: 'Fetch API', check: () => typeof fetch !== 'undefined' }, + { name: 'Promises', check: () => typeof Promise !== 'undefined' }, + { name: 'Arrow Functions', check: () => { try { eval('() => {}'); return true; } catch { return false; } } }, + { name: 'Async/Await', check: () => { try { eval('async () => {}'); return true; } catch { return false; } } }, + { name: 'Map/Set', check: () => typeof Map !== 'undefined' && typeof Set !== 'undefined' }, + { name: 'LocalStorage', check: () => typeof Storage !== 'undefined' } + ]; + + const results = features.map(feature => ({ + name: feature.name, + supported: feature.check() + })); + + const allSupported = results.every(result => result.supported); + + return { + passed: allSupported, + details: { + features: results, + totalSupported: results.filter(r => r.supported).length, + totalFeatures: results.length + } + }; + } + + // Check storage availability + checkStorageAvailability() { + try { + const testKey = 'libreportal_test'; + localStorage.setItem(testKey, 'test'); + localStorage.removeItem(testKey); + + return { + passed: true, + details: { + available: true, + quota: 'Available' + } + }; + } catch (error) { + return { + passed: false, + details: { + available: false, + error: error.message + } + }; + } + } + + // Check network connectivity + async checkNetworkConnectivity() { + try { + const response = await fetch('/', { method: 'HEAD', cache: 'no-cache' }); + + return { + passed: response.ok, + details: { + status: response.status, + statusText: response.statusText, + online: navigator.onLine + } + }; + } catch (error) { + return { + passed: false, + details: { + error: error.message, + online: navigator.onLine + } + }; + } + } + + // Check required directories + async checkDirectories() { + const requiredPaths = [ + '/data/apps/', + '/data/config/', + '/data/backup/', + '/containers/libreportal/' + ]; + + const checks = []; + + for (const path of requiredPaths) { + try { + // Try to access a file in the directory to check if it exists + const testFile = path.endsWith('/') ? path + '.gitkeep' : path; + const response = await fetch(testFile, { method: 'HEAD' }); + + checks.push({ + path, + exists: response.ok || response.status === 404 // 404 means directory exists but file doesn't + }); + } catch (error) { + checks.push({ + path, + exists: false, + error: error.message + }); + } + } + + const allExist = checks.every(check => check.exists); + + return { + passed: allExist, + details: { + directories: checks, + totalExists: checks.filter(c => c.exists).length, + totalDirectories: checks.length + } + }; + } + + // Check file permissions + async checkPermissions() { + const testFiles = [ + '/data/apps/generated/apps.json', + '/data/apps/apps-categories.json', + '/data/config/generated/configs.json' + ]; + + const checks = []; + + for (const file of testFiles) { + try { + const response = await fetch(file, { method: 'HEAD' }); + + checks.push({ + file, + readable: response.ok, + status: response.status + }); + } catch (error) { + checks.push({ + file, + readable: false, + error: error.message + }); + } + } + + const allReadable = checks.every(check => check.readable); + + return { + passed: allReadable, + details: { + files: checks, + totalReadable: checks.filter(c => c.readable).length, + totalFiles: checks.length + } + }; + } + + // Check basic configuration + async checkBasicConfig() { + try { + // Check for basic config files + const configFiles = [ + '/data/config/generated/configs.json' + ]; + + for (const file of configFiles) { + const response = await fetch(file, { method: 'HEAD' }); + if (!response.ok) { + return { + passed: false, + details: { + missing: file, + error: 'Configuration file not found' + } + }; + } + } + + // Try to load and validate config structure + const configResponse = await fetch('/data/config/generated/configs.json'); + if (configResponse.ok) { + const config = await configResponse.json(); + + return { + passed: true, + details: { + configLoaded: true, + configType: typeof config, + hasData: Object.keys(config).length > 0 + } + }; + } + + return { + passed: false, + details: { + error: 'Failed to load configuration' + } + }; + + } catch (error) { + return { + passed: false, + details: { + error: error.message + } + }; + } + } + + // Get setup step by ID + getStep(stepId) { + return this.setupSteps.get(stepId); + } + + // Get all setup steps + getAllSteps() { + return Array.from(this.setupSteps.entries()).map(([id, step]) => ({ + id, + ...step + })); + } + + // Get next step + getNextStep() { + const steps = Array.from(this.setupSteps.keys()); + const nextIndex = this.currentStep + 1; + + if (nextIndex < steps.length) { + return steps[nextIndex]; + } + + return null; + } + + // Get previous step + getPreviousStep() { + const steps = Array.from(this.setupSteps.keys()); + const prevIndex = this.currentStep - 1; + + if (prevIndex >= 0) { + return steps[prevIndex]; + } + + return null; + } + + // Move to specific step + goToStep(stepId) { + if (this.setupSteps.has(stepId)) { + this.currentStep = Array.from(this.setupSteps.keys()).indexOf(stepId); + return true; + } + return false; + } + + // Mark setup as complete + markSetupComplete() { + this.setupComplete = true; + this.isFirstTime = false; + localStorage.setItem('libreportal_setup_complete', 'true'); + localStorage.setItem('libreportal_setup_date', new Date().toISOString()); + } + + // Reset setup (for testing/re-setup) + resetSetup() { + this.setupComplete = false; + this.isFirstTime = true; + this.currentStep = 0; + localStorage.removeItem('libreportal_setup_complete'); + localStorage.removeItem('libreportal_setup_date'); + } + + // Get setup status + getSetupStatus() { + return { + isFirstTime: this.isFirstTime, + setupComplete: this.setupComplete, + currentStep: this.currentStep, + totalSteps: this.setupSteps.size, + setupDate: localStorage.getItem('libreportal_setup_date') + }; + } +} + +// Global instance +window.SetupDetector = SetupDetector; diff --git a/containers/libreportal/frontend/js/system/setup-wizard.js b/containers/libreportal/frontend/js/system/setup-wizard.js new file mode 100755 index 0000000..43150ff --- /dev/null +++ b/containers/libreportal/frontend/js/system/setup-wizard.js @@ -0,0 +1,758 @@ +// Setup Wizard - First-time install configuration UI. +// +// Multi-step slide-right form. Each step is a sibling div inside a track +// that translates horizontally as the user advances. Submission POSTs to +// /api/setup/save which fans out into separate tasks per app, then this UI +// hands off to the tasks page focused on the first task. + +class SetupWizard { + constructor() { + this.container = null; + this.suggestedName = ''; + this.dnsCheckTimer = null; + this.dnsCheckController = null; + this.onComplete = null; + this.currentStep = 0; + this.stepNames = ['Identity', 'Domains', 'Recommended', 'Metrics']; + this.stepIcons = ['🪐', '🛰️', '🛡️', '📊']; + this.totalSteps = this.stepNames.length; + this.domainCount = 0; // tracked dynamically as the user adds rows + } + + initialize(setupDetector, onComplete = null) { + this.onComplete = onComplete; + this.create(); + this.attach(); + this.suggestName(); + this.renderAppTiles(); + this.preselectTimezone(); + this.showStep(0); + } + + getWizardApps() { + return [ + { slug: 'traefik', recommended: true, defaultChecked: true, + fallback: { name: 'Traefik', description: 'Reverse proxy + automatic SSL via LetsEncrypt' } }, + { slug: 'crowdsec', recommended: true, defaultChecked: true, + fallback: { name: 'CrowdSec', description: 'Host-installed intrusion prevention' } } + ]; + } + + getMetricsApps() { + return [ + { slug: 'prometheus', defaultChecked: false, + fallback: { name: 'Prometheus', description: 'Scrapes and stores time-series metrics from your apps' } }, + { slug: 'grafana', defaultChecked: false, + fallback: { name: 'Grafana', description: 'Dashboards and visualisations on top of Prometheus metrics' } } + ]; + } + + create() { + this.container = document.createElement('div'); + this.container.className = 'setup-wizard aurora-bg aurora-static'; + this.container.innerHTML = ` + + +
    +
    + +

    Tuning your private universe before takeoff...

    +
    + +
    +
    +
    +
    +
    +
    + Step 1 of ${this.totalSteps} + 0% +
    +
    + +
    + +
    +
    + + +
    +
    + +
    + + + +
    +
    + +
    + +
    + + +
    +
    +
    + + +
    +
    + +
    + +

    No domain? Skip this step — apps will be reachable by IP and Port on your LAN.

    +
    +
    + + +
    +
    +
    Recommended Apps
    +

    Pre-selected to give you a working install out of the box.

    + + + + +
    +
    + + +
    +
    +
    Metrics Apps
    +

    Optional. Install these to enable per-app "Export metrics to Grafana" later.

    +
    +
    +
    + +
    +
    + + + +
    + + + +
    + +
    +
    +
    + `; + document.body.appendChild(this.container); + document.body.classList.add('setup-wizard-open'); + } + + attach() { + const $ = (id) => this.container.querySelector(id); + + $('#sw-reroll').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const btn = $('#sw-reroll'); + btn.classList.add('manifesting'); + setTimeout(() => btn.classList.remove('manifesting'), 700); + this.suggestName(true); + }); + + $('#sw-domain-add').addEventListener('click', () => this.addDomainRow()); + // Seed with one empty row so the user has somewhere to type. They can + // remove it if they truly want a local-only install. + this.addDomainRow(); + + this.attachLiveValidation(); + + $('#sw-back').addEventListener('click', () => this.prev()); + $('#sw-next').addEventListener('click', () => this.next()); + + $('#setup-form').addEventListener('submit', (e) => { + e.preventDefault(); + console.log('[setup] form submit fired'); + this.submit(); + }); + + $('#sw-submit').addEventListener('click', (e) => { + e.preventDefault(); + console.log('[setup] launch button clicked'); + this.submit(); + }); + + $('#setup-form').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA' && this.currentStep < this.totalSteps - 1) { + e.preventDefault(); + this.next(); + } + }); + } + + showStep(n) { + this.currentStep = Math.max(0, Math.min(this.totalSteps - 1, n)); + + this.container.querySelectorAll('.setup-step').forEach((el, idx) => { + el.classList.toggle('active', idx === this.currentStep); + }); + + const pct = Math.round(((this.currentStep + 1) / this.totalSteps) * 100); + const name = this.stepNames[this.currentStep]; + const icon = this.stepIcons[this.currentStep]; + this.container.querySelector('#sw-progress-fill').style.width = `${pct}%`; + this.container.querySelector('#sw-progress-step').innerHTML = + `Step ${this.currentStep + 1} of ${this.totalSteps} ${icon} ${name}`; + this.container.querySelector('#sw-progress-pct').textContent = `${pct}%`; + + this.container.querySelector('#sw-back').disabled = this.currentStep === 0; + const isLast = this.currentStep === this.totalSteps - 1; + this.container.querySelector('#sw-next').style.display = isLast ? 'none' : ''; + this.container.querySelector('#sw-submit').style.display = isLast ? '' : 'none'; + + setTimeout(() => { + const focusable = this.container.querySelector(`.setup-step.active input, .setup-step.active select`); + if (focusable) focusable.focus(); + }, 350); + } + + validateStep(idx) { + const $ = (id) => this.container.querySelector(id); + if (idx === 0) { + const name = $('#sw-name').value.trim(); + if (!/^[a-zA-Z0-9-]+$/.test(name)) return 'Install name must be letters, numbers, or hyphens only.'; + if (!$('#sw-timezone').value) return 'Please select a timezone.'; + } + if (idx === 1) { + // Domains are optional, but any non-empty input must be valid. + const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input')); + for (const input of inputs) { + const v = input.value.trim(); + if (v && !/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(v)) { + return 'One of your domains doesn\'t look valid.'; + } + } + } + if (idx === 2) { + const traefikBox = this.container.querySelector('input[data-app="traefik"]'); + if (traefikBox && traefikBox.checked) { + const tEmail = $('#sw-traefik-email').value.trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tEmail)) { + return 'Traefik needs a valid LetsEncrypt email.'; + } + } + } + return null; + } + + next() { + const err = this.validateStep(this.currentStep); + if (err) { this.showError(err); return; } + this.clearError(); + this.showStep(this.currentStep + 1); + } + + prev() { + this.clearError(); + this.showStep(this.currentStep - 1); + } + + async loadAppsManifest() { + if (this._appsManifest) return this._appsManifest; + try { + const res = await fetch('/data/apps/generated/apps.json'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const list = Array.isArray(data) ? data : (data.apps || []); + this._appsManifest = new Map(); + for (const app of list) { + const key = (app.slug || app.name || app.id || '').toLowerCase(); + if (key) this._appsManifest.set(key, app); + } + } catch (err) { + this._appsManifest = new Map(); + } + return this._appsManifest; + } + + async renderAppTiles() { + const manifest = await this.loadAppsManifest(); + const recBox = this.container.querySelector('#sw-apps-recommended'); + if (!recBox) return; + + const buildTile = ({ slug, defaultChecked, fallback, subOption }) => { + const app = manifest.get(slug) || {}; + const name = app.title || app.name || fallback.name; + const desc = app.description || fallback.description; + const icon = app.icon || `icons/apps/${slug}.svg`; + // Sub-option lives OUTSIDE the parent label (nested