feat: add welcome page dashboard for post-install credentials

replace terminal-based final report with web-based welcome page that
displays service credentials, hostnames, and quick start guide.

- add welcome/index.html with tailwind css and dark mode support
- add welcome/app.js with service metadata and password toggle/copy
- add scripts/generate_welcome_page.sh to generate data.json from env
- simplify 07_final_report.sh to show welcome page url and make commands
- add welcome page basic auth credentials to caddy and secret generation
- update add-new-service documentation with new welcome page steps
This commit is contained in:
Yury Kossakovsky
2025-12-11 17:09:42 -07:00
parent ab4ab149ad
commit 366865ad4c
10 changed files with 1344 additions and 432 deletions

View File

@@ -3,7 +3,7 @@ alwaysApply: false
---
# Guide: Adding a New Service to n8n-install
This document shows how to add a new optional service (behind Docker Compose profiles) and wire it into the installer, Caddy, and final report.
This document shows how to add a new optional service (behind Docker Compose profiles) and wire it into the installer, Caddy, and Welcome Page.
Use a short lowercase slug for your service, e.g., `myservice`.
@@ -110,24 +110,43 @@ _update_or_add_env_var "MYSERVICE_PASSWORD_HASH" "$FINAL_MYSERVICE_HASH"
"myservice" "MyService (Short description)"
```
## 6) scripts/07_final_report.sh
- Add a block that prints discovered URLs/credentials:
## 6) scripts/generate_welcome_page.sh
- Add a block that generates JSON data for the Welcome Page:
```bash
# MyService
if is_profile_active "myservice"; then
echo
echo "================================= MyService ==========================="
echo
echo "Host: ${MYSERVICE_HOSTNAME:-<hostname_not_set>}"
# Only print credentials if Caddy basic auth is enabled for this service
echo "User: ${MYSERVICE_USERNAME:-<not_set_in_env>}"
echo "Password: ${MYSERVICE_PASSWORD:-<not_set_in_env>}"
echo "API (external via Caddy): https://${MYSERVICE_HOSTNAME:-<hostname_not_set>}"
echo "API (internal): http://myservice:8080"
echo "Docs: <link_to_docs>"
SERVICES_JSON+='"myservice": {
"hostname": "'"$(json_escape "$MYSERVICE_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$MYSERVICE_USERNAME")"'",
"password": "'"$(json_escape "$MYSERVICE_PASSWORD")"'"
},
"extra": {
"internal_api": "http://myservice:8080",
"docs": "https://example.com/docs"
}
},'
fi
```
## 7) README.md
Notes:
- Use `"hostname": null` for internal-only services (no external access)
- Use `"credentials": { "note": "..." }` for services without username/password
- Add useful info to `extra` (internal_api, docs, dashboard URLs, etc.)
## 7) welcome/app.js
- Add service metadata to `SERVICE_METADATA` object:
```javascript
'myservice': {
name: 'MyService',
description: 'Short description of what it does',
icon: 'MS', // 2-letter abbreviation
color: 'bg-blue-500', // Tailwind color class
category: 'tools' // ai, database, monitoring, tools, infra, automation
},
```
## 8) README.md
- Add a short, one-line description under "What's Included", linking to your service docs/homepage.
```md
✅ [**MyService**](https://example.com) - One-line description of what it provides.
@@ -137,14 +156,14 @@ fi
- **MyService:** `myservice.yourdomain.com` (Brief description)
```
## 8) Ask about Basic Auth (important)
## 9) Ask about Basic Auth (important)
When adding any new public-facing service, explicitly ask the user whether they want to protect the service with Basic Auth via Caddy. If yes, add:
- Credentials section to `.env.example`
- Secret generation in `scripts/03_generate_secrets.sh`
- `basic_auth` in `Caddyfile`
- Pass the username/hash through `docker-compose.yml` `caddy.environment`
## 9) Verify and apply
## 10) Verify and apply
- Regenerate secrets to populate new variables:
```bash
bash scripts/03_generate_secrets.sh
@@ -161,12 +180,13 @@ docker compose -p localai logs -f --tail=200 myservice | cat
docker compose -p localai logs -f --tail=200 caddy | cat
```
## 10) Quick checklist
## 11) Quick checklist
- [ ] Service added to `docker-compose.yml` with a profile (no external ports exposed)
- [ ] Hostname and (optional) credentials added to `.env.example`
- [ ] Secret + hash generation added to `scripts/03_generate_secrets.sh`
- [ ] Exposed via `Caddyfile` with `reverse_proxy` (+ `basic_auth` if desired)
- [ ] Service selectable in `scripts/04_wizard.sh`
- [ ] Listed with URLs/credentials in `scripts/07_final_report.sh`
- [ ] Service data added to `scripts/generate_welcome_page.sh`
- [ ] Service metadata added to `welcome/app.js` (`SERVICE_METADATA`)
- [ ] One-line description added to `README.md`

View File

@@ -166,6 +166,16 @@ SUPABASE_HOSTNAME=supabase.yourdomain.com
WAHA_HOSTNAME=waha.yourdomain.com
WEAVIATE_HOSTNAME=weaviate.yourdomain.com
WEBUI_HOSTNAME=webui.yourdomain.com
WELCOME_HOSTNAME=welcome.yourdomain.com
############
# [required]
# Welcome Page credentials (for Caddy basic auth)
############
WELCOME_USERNAME=
WELCOME_PASSWORD=
WELCOME_PASSWORD_HASH=
# Everything below this point is optional.
# Default values will suffice unless you need more features/customization.

3
.gitignore vendored
View File

@@ -11,4 +11,5 @@ supabase/
dify/
volumes/
docker-compose.override.yml
docker-compose.n8n-workers.yml
docker-compose.n8n-workers.yml
welcome/data.json

View File

@@ -148,6 +148,16 @@ https://{$NEO4J_HOSTNAME}:7687 {
reverse_proxy docling:5001
}
# Welcome Page (Post-install dashboard)
{$WELCOME_HOSTNAME} {
basic_auth {
{$WELCOME_USERNAME} {$WELCOME_PASSWORD_HASH}
}
root * /srv/welcome
file_server
try_files {path} /index.html
}
import /etc/caddy/addons/*.conf
# # SearXNG

View File

@@ -264,6 +264,7 @@ services:
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-addon:/etc/caddy/addons:ro
- ./welcome:/srv/welcome:ro
- caddy-data:/data:rw
- caddy-config:/config:rw
environment:
@@ -306,6 +307,9 @@ services:
- SUPABASE_HOSTNAME=${SUPABASE_HOSTNAME}
- WEAVIATE_HOSTNAME=${WEAVIATE_HOSTNAME}
- WEBUI_HOSTNAME=${WEBUI_HOSTNAME}
- WELCOME_HOSTNAME=${WELCOME_HOSTNAME}
- WELCOME_PASSWORD_HASH=${WELCOME_PASSWORD_HASH}
- WELCOME_USERNAME=${WELCOME_USERNAME}
cap_drop:
- ALL
cap_add:

View File

@@ -62,6 +62,7 @@ declare -A VARS_TO_GENERATE=(
["VAULT_ENC_KEY"]="alphanum:32"
["WAHA_DASHBOARD_PASSWORD"]="password:32"
["WEAVIATE_API_KEY"]="secret:48" # API Key for Weaviate service (36 bytes -> 48 chars base64)
["WELCOME_PASSWORD"]="password:32" # Welcome page basic auth password
["WHATSAPP_SWAGGER_PASSWORD"]="password:32"
)
@@ -272,6 +273,7 @@ generated_values["LIGHTRAG_USERNAME"]="$USER_EMAIL" # Set LightRAG username for
generated_values["WAHA_DASHBOARD_USERNAME"]="$USER_EMAIL" # WAHA dashboard username default
generated_values["WHATSAPP_SWAGGER_USERNAME"]="$USER_EMAIL" # WAHA swagger username default
generated_values["DOCLING_USERNAME"]="$USER_EMAIL" # Set Docling username for Caddy
generated_values["WELCOME_USERNAME"]="$USER_EMAIL" # Set Welcome page username for Caddy
# Create a temporary file for processing
@@ -299,6 +301,7 @@ found_vars["DOCLING_USERNAME"]=0
found_vars["LT_USERNAME"]=0
found_vars["LIGHTRAG_USERNAME"]=0
found_vars["WAHA_DASHBOARD_USERNAME"]=0
found_vars["WELCOME_USERNAME"]=0
found_vars["WHATSAPP_SWAGGER_USERNAME"]=0
# Read template, substitute domain, generate initial values
@@ -346,7 +349,7 @@ while IFS= read -r line || [[ -n "$line" ]]; do
# This 'else' block is for lines from template not covered by existing values or VARS_TO_GENERATE.
# Check if it is one of the user input vars - these are handled by found_vars later if not in template.
is_user_input_var=0 # Reset for each line
user_input_vars=("FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "OPENAI_API_KEY" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WHATSAPP_SWAGGER_USERNAME")
user_input_vars=("FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "OPENAI_API_KEY" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WELCOME_USERNAME" "WHATSAPP_SWAGGER_USERNAME")
for uivar in "${user_input_vars[@]}"; do
if [[ "$varName" == "$uivar" ]]; then
is_user_input_var=1
@@ -428,7 +431,7 @@ if [[ -z "${generated_values[SERVICE_ROLE_KEY]}" ]]; then
fi
# Add any custom variables that weren't found in the template
for var in "FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "OPENAI_API_KEY" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WHATSAPP_SWAGGER_USERNAME" "DOCLING_USERNAME"; do
for var in "FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "OPENAI_API_KEY" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WELCOME_USERNAME" "WHATSAPP_SWAGGER_USERNAME" "DOCLING_USERNAME"; do
if [[ ${found_vars["$var"]} -eq 0 && -v generated_values["$var"] ]]; then
# Before appending, check if it's already in TMP_ENV_FILE to avoid duplicates
if ! grep -q -E "^${var}=" "$TMP_ENV_FILE"; then
@@ -618,6 +621,18 @@ if [[ -z "$FINAL_DOCLING_HASH" && -n "$DOCLING_PLAIN_PASS" ]]; then
fi
_update_or_add_env_var "DOCLING_PASSWORD_HASH" "$FINAL_DOCLING_HASH"
# --- WELCOME PAGE ---
WELCOME_PLAIN_PASS="${generated_values["WELCOME_PASSWORD"]}"
FINAL_WELCOME_HASH="${generated_values[WELCOME_PASSWORD_HASH]}"
if [[ -z "$FINAL_WELCOME_HASH" && -n "$WELCOME_PLAIN_PASS" ]]; then
NEW_HASH=$(_generate_and_get_hash "$WELCOME_PLAIN_PASS")
if [[ -n "$NEW_HASH" ]]; then
FINAL_WELCOME_HASH="$NEW_HASH"
generated_values["WELCOME_PASSWORD_HASH"]="$NEW_HASH"
fi
fi
_update_or_add_env_var "WELCOME_PASSWORD_HASH" "$FINAL_WELCOME_HASH"
if [ $? -eq 0 ]; then # This $? reflects the status of the last mv command from the last _update_or_add_env_var call.
# For now, assuming if we reached here and mv was fine, primary operations were okay.
echo ".env file generated successfully in the project root ($OUTPUT_FILE)."

View File

@@ -17,439 +17,87 @@ if [ ! -f "$ENV_FILE" ]; then
fi
# Load environment variables from .env file
# Use set -a to export all variables read from the file
set -a
source "$ENV_FILE"
set +a
# Generate welcome page data
if [ -f "$SCRIPT_DIR/generate_welcome_page.sh" ]; then
log_info "Generating welcome page..."
bash "$SCRIPT_DIR/generate_welcome_page.sh" || log_warning "Failed to generate welcome page"
fi
# Function to check if a profile is active
is_profile_active() {
local profile_to_check="$1"
# COMPOSE_PROFILES is sourced from .env and will be available here
if [ -z "$COMPOSE_PROFILES" ]; then
return 1 # Not active if COMPOSE_PROFILES is empty or not set
return 1
fi
# Check if the profile_to_check is in the comma-separated list
# Adding commas at the beginning and end of both strings handles edge cases
# (e.g., single profile, profile being a substring of another)
if [[ ",$COMPOSE_PROFILES," == *",$profile_to_check,"* ]]; then
return 0 # Active
return 0
else
return 1 # Not active
return 1
fi
}
# --- Service Access Credentials ---
# Display credentials, checking if variables exist
echo
log_info "Service Access Credentials. Save this information securely!"
# Display credentials, checking if variables exist
if is_profile_active "n8n"; then
echo
echo "================================= n8n ================================="
echo
echo "Host: ${N8N_HOSTNAME:-<hostname_not_set>}"
N8N_WORKER_COUNT_VAL="${N8N_WORKER_COUNT:-1}"
echo "Workers: $N8N_WORKER_COUNT_VAL (each with dedicated task runner sidecar)"
fi
if is_profile_active "open-webui"; then
echo
echo "================================= WebUI ==============================="
echo
echo "Host: ${WEBUI_HOSTNAME:-<hostname_not_set>}"
fi
if is_profile_active "flowise"; then
echo
echo "================================= Flowise ============================="
echo
echo "Host: ${FLOWISE_HOSTNAME:-<hostname_not_set>}"
echo "User: ${FLOWISE_USERNAME:-<not_set_in_env>}"
echo "Password: ${FLOWISE_PASSWORD:-<not_set_in_env>}"
fi
if is_profile_active "dify"; then
echo
echo "================================= Dify ================================="
echo
echo "Host: ${DIFY_HOSTNAME:-<hostname_not_set>}"
echo "Description: AI Application Development Platform with LLMOps"
echo
echo "API Access:"
echo " - Web Interface: https://${DIFY_HOSTNAME:-<hostname_not_set>}"
echo " - API Endpoint: https://${DIFY_HOSTNAME:-<hostname_not_set>}/v1"
echo " - Internal API: http://dify-api:5001"
fi
if is_profile_active "supabase"; then
echo
echo "================================= Supabase ============================"
echo
echo "External Host (via Caddy): ${SUPABASE_HOSTNAME:-<hostname_not_set>}"
echo "Studio User: ${DASHBOARD_USERNAME:-<not_set_in_env>}"
echo "Studio Password: ${DASHBOARD_PASSWORD:-<not_set_in_env>}"
echo
echo "Internal API Gateway: http://kong:8000"
echo "Service Role Secret: ${SERVICE_ROLE_KEY:-<not_set_in_env>}"
fi
if is_profile_active "langfuse"; then
echo
echo "================================= Langfuse ============================"
echo
echo "Host: ${LANGFUSE_HOSTNAME:-<hostname_not_set>}"
echo "User: ${LANGFUSE_INIT_USER_EMAIL:-<not_set_in_env>}"
echo "Password: ${LANGFUSE_INIT_USER_PASSWORD:-<not_set_in_env>}"
fi
if is_profile_active "monitoring"; then
echo
echo "================================= Grafana ============================="
echo
echo "Host: ${GRAFANA_HOSTNAME:-<hostname_not_set>}"
echo "User: admin"
echo "Password: ${GRAFANA_ADMIN_PASSWORD:-<not_set_in_env>}"
echo
echo "================================= Prometheus =========================="
echo
echo "Host: ${PROMETHEUS_HOSTNAME:-<hostname_not_set>}"
echo "User: ${PROMETHEUS_USERNAME:-<not_set_in_env>}"
echo "Password: ${PROMETHEUS_PASSWORD:-<not_set_in_env>}"
fi
if is_profile_active "searxng"; then
echo
echo "================================= Searxng ============================="
echo
echo "Host: ${SEARXNG_HOSTNAME:-<hostname_not_set>}"
echo "User: ${SEARXNG_USERNAME:-<not_set_in_env>}"
echo "Password: ${SEARXNG_PASSWORD:-<not_set_in_env>}"
fi
if is_profile_active "portainer"; then
echo
echo "================================= Portainer ==========================="
echo
echo "Host: ${PORTAINER_HOSTNAME:-<hostname_not_set>}"
echo "(Note: On first login, Portainer will prompt to set up an admin user.)"
fi
if is_profile_active "postiz"; then
echo
echo "================================= Postiz =============================="
echo
echo "Host: ${POSTIZ_HOSTNAME:-<hostname_not_set>}"
echo "Internal Access (e.g., from n8n): http://postiz:5000"
fi
if is_profile_active "postgresus"; then
echo
echo "================================= Postgresus =========================="
echo
echo "Host: ${POSTGRESUS_HOSTNAME:-<hostname_not_set>}"
echo "UI (external via Caddy): https://${POSTGRESUS_HOSTNAME:-<hostname_not_set>}"
echo "UI (internal): http://postgresus:4005"
echo "------ Backup Target (internal PostgreSQL) ------"
echo "PG version: ${POSTGRES_VERSION:-17}"
echo "Host: postgres"
echo "Port: ${POSTGRES_PORT:-5432}"
echo "Username: ${POSTGRES_USER:-postgres}"
echo "Password: ${POSTGRES_PASSWORD:-<not_set_in_env>}"
echo "DB name: ${POSTGRES_DB:-postgres}"
echo "Use HTTPS: false"
fi
if is_profile_active "ragapp"; then
echo
echo "================================= RAGApp =============================="
echo
echo "Host: ${RAGAPP_HOSTNAME:-<hostname_not_set>}"
echo "Internal Access (e.g., from n8n): http://ragapp:8000"
echo "User: ${RAGAPP_USERNAME:-<not_set_in_env>}"
echo "Password: ${RAGAPP_PASSWORD:-<not_set_in_env>}"
echo "Admin: https://${RAGAPP_HOSTNAME:-<hostname_not_set>}/admin"
echo "API Docs: https://${RAGAPP_HOSTNAME:-<hostname_not_set>}/docs"
fi
if is_profile_active "ragflow"; then
echo
echo "================================= RAGFlow ============================="
echo
echo "Host: ${RAGFLOW_HOSTNAME:-<hostname_not_set>}"
echo "API (external via Caddy): https://${RAGFLOW_HOSTNAME:-<hostname_not_set>}"
echo "API (internal): http://ragflow:80"
echo "Note: Uses built-in authentication (login/registration available in web UI)"
fi
if is_profile_active "comfyui"; then
echo
echo "================================= ComfyUI ============================="
echo
echo "Host: ${COMFYUI_HOSTNAME:-<hostname_not_set>}"
echo "User: ${COMFYUI_USERNAME:-<not_set_in_env>}"
echo "Password: ${COMFYUI_PASSWORD:-<not_set_in_env>}"
fi
if is_profile_active "libretranslate"; then
echo
echo "================================= LibreTranslate ==========================="
echo
echo "Host: ${LT_HOSTNAME:-<hostname_not_set>}"
echo "User: ${LT_USERNAME:-<not_set_in_env>}"
echo "Password: ${LT_PASSWORD:-<not_set_in_env>}"
echo "API (external via Caddy): https://${LT_HOSTNAME:-<hostname_not_set>}"
echo "API (internal): http://libretranslate:5000"
echo "Docs: https://github.com/LibreTranslate/LibreTranslate"
fi
if is_profile_active "qdrant"; then
echo
echo "================================= Qdrant =============================="
echo
echo "Dashboard: https://${QDRANT_HOSTNAME:-<hostname_not_set>}/dashboard"
echo "Host: https://${QDRANT_HOSTNAME:-<hostname_not_set>}"
echo "API Key: ${QDRANT_API_KEY:-<not_set_in_env>}"
echo "Internal REST API Access (e.g., from backend): http://qdrant:6333"
fi
if is_profile_active "crawl4ai"; then
echo
echo "================================= Crawl4AI ============================"
echo
echo "Internal Access (e.g., from n8n): http://crawl4ai:11235"
echo "(Note: Not exposed externally via Caddy by default)"
fi
if is_profile_active "docling"; then
echo
echo "================================= Docling ============================="
echo
echo "Web UI: https://${DOCLING_HOSTNAME:-<hostname_not_set>}/ui"
echo "API Docs: https://${DOCLING_HOSTNAME:-<hostname_not_set>}/docs"
echo
echo "Credentials (Caddy Basic Auth):"
echo "User: ${DOCLING_USERNAME:-<not_set_in_env>}"
echo "Password: ${DOCLING_PASSWORD:-<not_set_in_env>}"
echo
echo "API Endpoints:"
echo "External (via Caddy): https://${DOCLING_HOSTNAME:-<hostname_not_set>}"
echo "Internal (from n8n): http://docling:5001"
echo
echo "VLM Pipeline (Vision Language Model):"
echo " 1. Load VLM model in Ollama via Open WebUI -> Settings -> Models"
echo " Example: granite3.2-vision:2b"
echo
echo " 2. API request with VLM pipeline:"
echo ' curl -X POST "https://'"${DOCLING_HOSTNAME:-<hostname_not_set>}"'/v1/convert/source" \'
echo ' -H "Content-Type: application/json" \'
echo ' -u "'"${DOCLING_USERNAME:-<not_set_in_env>}"':'"${DOCLING_PASSWORD:-<not_set_in_env>}"'" \'
echo " -d '{"
echo ' "source": "https://arxiv.org/pdf/2501.17887",'
echo ' "options": {'
echo ' "pipeline": "vlm",'
echo ' "vlm_pipeline_model_api": {'
echo ' "url": "http://ollama:11434/v1/chat/completions",'
echo ' "params": {"model": "granite3.2-vision:2b"},'
echo ' "prompt": "Convert this page to docling.",'
echo ' "timeout": 300'
echo " }"
echo " }"
echo " }'"
fi
if is_profile_active "gotenberg"; then
echo
echo "================================= Gotenberg ============================"
echo
echo "Internal Access (e.g., from n8n): http://gotenberg:3000"
echo "API Documentation: https://gotenberg.dev/docs"
echo
echo "Common API Endpoints:"
echo " HTML to PDF: POST /forms/chromium/convert/html"
echo " URL to PDF: POST /forms/chromium/convert/url"
echo " Markdown to PDF: POST /forms/chromium/convert/markdown"
echo " Office to PDF: POST /forms/libreoffice/convert"
fi
if is_profile_active "waha"; then
echo
echo "============================== WAHA (WhatsApp HTTP API) =============================="
echo
echo "Dashboard: https://${WAHA_HOSTNAME:-<hostname_not_set>}/dashboard"
echo "Swagger: https://${WAHA_HOSTNAME:-<hostname_not_set>}"
echo "Internal: http://waha:3000"
echo
echo "Dashboard User: ${WAHA_DASHBOARD_USERNAME:-<not_set_in_env>}"
echo "Dashboard Pass: ${WAHA_DASHBOARD_PASSWORD:-<not_set_in_env>}"
echo "Swagger User: ${WHATSAPP_SWAGGER_USERNAME:-<not_set_in_env>}"
echo "Swagger Pass: ${WHATSAPP_SWAGGER_PASSWORD:-<not_set_in_env>}"
echo "API key (plain): ${WAHA_API_KEY_PLAIN:-<not_set_in_env>}"
fi
if is_profile_active "paddleocr"; then
echo
echo "================================= PaddleOCR ==========================="
echo
echo "Host: ${PADDLEOCR_HOSTNAME:-<hostname_not_set>}"
echo "User: ${PADDLEOCR_USERNAME:-<not_set_in_env>}"
echo "Password: ${PADDLEOCR_PASSWORD:-<not_set_in_env>}"
echo "API (external via Caddy): https://${PADDLEOCR_HOSTNAME:-<hostname_not_set>}"
echo "API (internal): http://paddleocr:8080"
echo "Docs: https://paddleocr.a2.fyi/docs"
echo "Notes: PaddleX Basic Serving (CPU), pipeline=OCR"
fi
if is_profile_active "python-runner"; then
echo
echo "================================= Python Runner ========================"
echo
echo "Internal Container DNS: python-runner"
echo "Mounted Code Directory: ./python-runner (host) -> /app (container)"
echo "Entry File: /app/main.py"
echo "(Note: Internal-only service with no exposed ports; view output via logs)"
echo "Logs: docker compose -p localai logs -f python-runner"
fi
if is_profile_active "n8n" || is_profile_active "langfuse"; then
echo
echo "================================= Redis (Valkey) ======================"
echo
echo "Internal Host: ${REDIS_HOST:-redis}"
echo "Internal Port: ${REDIS_PORT:-6379}"
echo "Password: ${REDIS_AUTH:-}"
echo "(Note: Primarily for internal service communication, not exposed externally by default)"
fi
if is_profile_active "letta"; then
echo
echo "================================= Letta ================================"
echo
echo "Host: ${LETTA_HOSTNAME:-<hostname_not_set>}"
echo "Authorization: Bearer ${LETTA_SERVER_PASSWORD}"
fi
if is_profile_active "lightrag"; then
echo
echo "================================= LightRAG ============================="
echo
echo "Host: ${LIGHTRAG_HOSTNAME:-<hostname_not_set>}"
echo "Web UI: https://${LIGHTRAG_HOSTNAME:-<hostname_not_set>}"
echo "Internal Access (e.g., from n8n): http://lightrag:9621"
echo ""
echo "Authentication (Web UI):"
echo " User: ${LIGHTRAG_USERNAME:-<not_set_in_env>}"
echo " Password: ${LIGHTRAG_PASSWORD:-<not_set_in_env>}"
echo ""
echo "API Access:"
echo " API Key: ${LIGHTRAG_API_KEY:-<not_set_in_env>}"
echo " API Docs: https://${LIGHTRAG_HOSTNAME:-<hostname_not_set>}/docs"
echo " Ollama-compatible: https://${LIGHTRAG_HOSTNAME:-<hostname_not_set>}/v1/chat/completions"
echo ""
echo "Configuration:"
echo " LLM: Ollama (qwen2.5:32b) at http://ollama:11434"
echo " Embeddings: Ollama (bge-m3:latest) at http://ollama:11434"
echo " Storage: Flexible (JSON/PostgreSQL/Neo4j based on installed services)"
echo ""
echo "Note: Requires Ollama to be installed and running for LLM and embeddings."
echo " Upload documents via /app/data/inputs volume or Web UI."
fi
if is_profile_active "cpu" || is_profile_active "gpu-nvidia" || is_profile_active "gpu-amd"; then
echo
echo "================================= Ollama =============================="
echo
echo "Internal Access (e.g., from n8n, Open WebUI): http://ollama:11434"
echo "(Note: Ollama runs with the selected profile: cpu, gpu-nvidia, or gpu-amd)"
fi
if is_profile_active "weaviate"; then
echo
echo "================================= Weaviate ============================"
echo
echo "Host: ${WEAVIATE_HOSTNAME:-<hostname_not_set>}"
echo "Admin User (for Weaviate RBAC): ${WEAVIATE_USERNAME:-<not_set_in_env>}"
echo "Weaviate API Key: ${WEAVIATE_API_KEY:-<not_set_in_env>}"
fi
if is_profile_active "neo4j"; then
echo
echo "================================= Neo4j =================================="
echo
echo "Web UI Host: https://${NEO4J_HOSTNAME:-<hostname_not_set>}"
echo "Bolt Port (for drivers): 7687 (e.g., neo4j://\\${NEO4J_HOSTNAME:-<hostname_not_set>}:7687)"
echo "User (for Web UI & API): ${NEO4J_AUTH_USERNAME:-<not_set_in_env>}"
echo "Password (for Web UI & API): ${NEO4J_AUTH_PASSWORD:-<not_set_in_env>}"
echo
echo "HTTP API Access (e.g., for N8N):"
echo " Authentication: Basic (use User/Password above)"
echo " Cypher API Endpoint (POST): https://\\${NEO4J_HOSTNAME:-<hostname_not_set>}/db/neo4j/tx/commit"
echo " Authorization Header Value (for 'Authorization: Basic <value>'): \$(echo -n \"${NEO4J_AUTH_USERNAME:-neo4j}:${NEO4J_AUTH_PASSWORD}\" | base64)"
fi
# Standalone PostgreSQL (used by n8n, Langfuse, etc.)
# Check if n8n or langfuse is active, as they use this PostgreSQL instance.
# The Supabase section already details its own internal Postgres.
if is_profile_active "n8n" || is_profile_active "langfuse"; then
# Check if Supabase is NOT active, to avoid confusion with Supabase's Postgres if both are present
# However, the main POSTGRES_PASSWORD is used by this standalone instance.
# Supabase has its own environment variables for its internal Postgres if configured differently,
# but the current docker-compose.yml uses the main POSTGRES_PASSWORD for langfuse's postgres dependency too.
# For clarity, we will label this distinctly.
echo
echo "==================== Standalone PostgreSQL (for n8n, Langfuse, etc.) ====================="
echo
echo "Host: postgres"
echo "Port: ${POSTGRES_PORT:-5432}"
echo "Database: ${POSTGRES_DB:-postgres}" # This is typically 'postgres' or 'n8n' for n8n, and 'langfuse' for langfuse, but refers to the service.
echo "User: ${POSTGRES_USER:-postgres}"
echo "Password: ${POSTGRES_PASSWORD:-<not_set_in_env>}"
echo "(Note: This is the PostgreSQL instance used by services like n8n and Langfuse.)"
echo "(It is separate from Supabase's internal PostgreSQL if Supabase is also enabled.)"
fi
echo
echo "======================================================================="
echo " Installation Complete!"
echo "======================================================================="
echo
# --- Update Script Info (Placeholder) ---
log_info "To update the services, run the 'update.sh' script: bash ./scripts/update.sh"
# --- Make Commands ---
echo "================================= Make Commands ========================="
echo
echo " make logs View logs (all services)"
echo " make logs s=<service> View logs for specific service"
echo " make status Show container status"
echo " make monitor Live CPU/memory monitoring"
echo " make restarts Show restart count per container"
echo
echo " make update Update system and services"
echo " make update-preview Preview available updates (dry-run)"
echo " make doctor Run system diagnostics"
echo " make clean Remove unused Docker resources"
echo
echo " make switch-beta Switch to beta (develop branch)"
echo " make switch-stable Switch to stable (main branch)"
echo
# ============================================
# Cloudflare Tunnel Security Notice
# ============================================
if is_profile_active "cloudflare-tunnel"; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🔒 CLOUDFLARE TUNNEL SECURITY"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "✅ Cloudflare Tunnel is configured and running!"
echo ""
echo "Your services are accessible through Cloudflare's secure network."
echo "All traffic is encrypted and routed through the tunnel."
echo ""
echo "🛡️ RECOMMENDED SECURITY ENHANCEMENT:"
echo " For maximum security, close the following ports in your VPS firewall:"
echo " • Port 80 (HTTP)"
echo " • Port 443 (HTTPS)"
echo " • Port 7687 (Neo4j Bolt)"
echo ""
echo " ⚠️ Only close ports AFTER confirming tunnel connectivity!"
echo ""
fi
# --- Welcome Page ---
echo "================================= Welcome Page =========================="
echo
echo "All your service credentials are available on the Welcome Page:"
echo
echo " URL: https://${WELCOME_HOSTNAME:-welcome.${USER_DOMAIN_NAME}}"
echo " Username: ${WELCOME_USERNAME:-<not_set>}"
echo " Password: ${WELCOME_PASSWORD:-<not_set>}"
echo
echo "The Welcome Page displays:"
echo " - All installed services with their hostnames"
echo " - Login credentials (username/password/API keys)"
echo " - Internal URLs for service-to-service communication"
echo
# --- Next Steps ---
echo "======================================================================="
echo " Next Steps"
echo "======================================================================="
echo
echo "======================================================================"
echo "1. Visit your Welcome Page to view all service credentials"
echo " https://${WELCOME_HOSTNAME:-welcome.${USER_DOMAIN_NAME}}"
echo
echo "Next Steps:"
echo "1. Review the credentials above and store them safely."
echo "2. Access the services via their respective URLs (check \`docker compose ps\` if needed)."
echo "3. Configure services as needed (e.g., first-run setup for n8n)."
echo "2. Store the Welcome Page credentials securely"
echo " (Username: ${WELCOME_USERNAME:-<not_set>})"
echo
echo "======================================================================"
echo "3. Configure services as needed:"
echo " - n8n: Complete first-run setup with your email"
echo " - Portainer: Create admin account on first login"
echo " - Open WebUI: Register your account"
echo
log_info "Thank you for using this repository!"
echo "4. Run 'make doctor' if you experience any issues"
echo
echo "======================================================================="
echo
log_info "Thank you for using n8n-install!"
echo

483
scripts/generate_welcome_page.sh Executable file
View File

@@ -0,0 +1,483 @@
#!/bin/bash
# Generate data.json for the welcome page with active services and credentials
set -e
# Source the utilities file
source "$(dirname "$0")/utils.sh"
# Get the directory where the script resides
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )"
ENV_FILE="$PROJECT_ROOT/.env"
OUTPUT_FILE="$PROJECT_ROOT/welcome/data.json"
# Check if .env file exists
if [ ! -f "$ENV_FILE" ]; then
log_error "The .env file ('$ENV_FILE') was not found."
exit 1
fi
# Ensure welcome directory exists
mkdir -p "$PROJECT_ROOT/welcome"
# Remove existing data.json if it exists (always regenerate)
if [ -f "$OUTPUT_FILE" ]; then
rm -f "$OUTPUT_FILE"
fi
# Load environment variables from .env file
set -a
source "$ENV_FILE"
set +a
# Function to check if a profile is active
is_profile_active() {
local profile_to_check="$1"
if [ -z "$COMPOSE_PROFILES" ]; then
return 1
fi
if [[ ",$COMPOSE_PROFILES," == *",$profile_to_check,"* ]]; then
return 0
else
return 1
fi
}
# Function to escape JSON strings
json_escape() {
local str="$1"
# Escape backslashes, double quotes, and control characters
printf '%s' "$str" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr -d '\n\r'
}
# Start building JSON
GENERATED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Build services object (key-based)
SERVICES_JSON=""
# n8n
if is_profile_active "n8n"; then
N8N_WORKER_COUNT_VAL="${N8N_WORKER_COUNT:-1}"
SERVICES_JSON+='"n8n": {
"hostname": "'"$(json_escape "$N8N_HOSTNAME")"'",
"credentials": {
"note": "Use the email you provided during installation"
},
"extra": {
"workers": "'"$N8N_WORKER_COUNT_VAL"'"
}
},'
fi
# Flowise
if is_profile_active "flowise"; then
SERVICES_JSON+='"flowise": {
"hostname": "'"$(json_escape "$FLOWISE_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$FLOWISE_USERNAME")"'",
"password": "'"$(json_escape "$FLOWISE_PASSWORD")"'"
}
},'
fi
# Open WebUI
if is_profile_active "open-webui"; then
SERVICES_JSON+='"open-webui": {
"hostname": "'"$(json_escape "$WEBUI_HOSTNAME")"'",
"credentials": {
"note": "Create account on first login"
}
},'
fi
# Grafana (monitoring)
if is_profile_active "monitoring"; then
SERVICES_JSON+='"grafana": {
"hostname": "'"$(json_escape "$GRAFANA_HOSTNAME")"'",
"credentials": {
"username": "admin",
"password": "'"$(json_escape "$GRAFANA_ADMIN_PASSWORD")"'"
}
},'
SERVICES_JSON+='"prometheus": {
"hostname": "'"$(json_escape "$PROMETHEUS_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$PROMETHEUS_USERNAME")"'",
"password": "'"$(json_escape "$PROMETHEUS_PASSWORD")"'"
}
},'
fi
# Portainer
if is_profile_active "portainer"; then
SERVICES_JSON+='"portainer": {
"hostname": "'"$(json_escape "$PORTAINER_HOSTNAME")"'",
"credentials": {
"note": "Create admin account on first login"
}
},'
fi
# Postgresus
if is_profile_active "postgresus"; then
SERVICES_JSON+='"postgresus": {
"hostname": "'"$(json_escape "$POSTGRESUS_HOSTNAME")"'",
"credentials": {
"note": "Uses PostgreSQL credentials from .env"
},
"extra": {
"pg_host": "postgres",
"pg_port": "'"${POSTGRES_PORT:-5432}"'",
"pg_user": "'"$(json_escape "${POSTGRES_USER:-postgres}")"'",
"pg_password": "'"$(json_escape "$POSTGRES_PASSWORD")"'",
"pg_db": "'"$(json_escape "${POSTGRES_DB:-postgres}")"'"
}
},'
fi
# Langfuse
if is_profile_active "langfuse"; then
SERVICES_JSON+='"langfuse": {
"hostname": "'"$(json_escape "$LANGFUSE_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$LANGFUSE_INIT_USER_EMAIL")"'",
"password": "'"$(json_escape "$LANGFUSE_INIT_USER_PASSWORD")"'"
}
},'
fi
# Supabase
if is_profile_active "supabase"; then
SERVICES_JSON+='"supabase": {
"hostname": "'"$(json_escape "$SUPABASE_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$DASHBOARD_USERNAME")"'",
"password": "'"$(json_escape "$DASHBOARD_PASSWORD")"'"
},
"extra": {
"internal_api": "http://kong:8000",
"service_role_key": "'"$(json_escape "$SERVICE_ROLE_KEY")"'"
}
},'
fi
# Dify
if is_profile_active "dify"; then
SERVICES_JSON+='"dify": {
"hostname": "'"$(json_escape "$DIFY_HOSTNAME")"'",
"credentials": {
"note": "Create account on first login"
},
"extra": {
"api_endpoint": "https://'"$(json_escape "$DIFY_HOSTNAME")"'/v1",
"internal_api": "http://dify-api:5001"
}
},'
fi
# Qdrant
if is_profile_active "qdrant"; then
SERVICES_JSON+='"qdrant": {
"hostname": "'"$(json_escape "$QDRANT_HOSTNAME")"'",
"credentials": {
"api_key": "'"$(json_escape "$QDRANT_API_KEY")"'"
},
"extra": {
"dashboard": "https://'"$(json_escape "$QDRANT_HOSTNAME")"'/dashboard",
"internal_api": "http://qdrant:6333"
}
},'
fi
# Weaviate
if is_profile_active "weaviate"; then
SERVICES_JSON+='"weaviate": {
"hostname": "'"$(json_escape "$WEAVIATE_HOSTNAME")"'",
"credentials": {
"api_key": "'"$(json_escape "$WEAVIATE_API_KEY")"'",
"username": "'"$(json_escape "$WEAVIATE_USERNAME")"'"
}
},'
fi
# Neo4j
if is_profile_active "neo4j"; then
SERVICES_JSON+='"neo4j": {
"hostname": "'"$(json_escape "$NEO4J_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$NEO4J_AUTH_USERNAME")"'",
"password": "'"$(json_escape "$NEO4J_AUTH_PASSWORD")"'"
},
"extra": {
"bolt_port": "7687"
}
},'
fi
# SearXNG
if is_profile_active "searxng"; then
SERVICES_JSON+='"searxng": {
"hostname": "'"$(json_escape "$SEARXNG_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$SEARXNG_USERNAME")"'",
"password": "'"$(json_escape "$SEARXNG_PASSWORD")"'"
}
},'
fi
# RAGApp
if is_profile_active "ragapp"; then
SERVICES_JSON+='"ragapp": {
"hostname": "'"$(json_escape "$RAGAPP_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$RAGAPP_USERNAME")"'",
"password": "'"$(json_escape "$RAGAPP_PASSWORD")"'"
},
"extra": {
"admin": "https://'"$(json_escape "$RAGAPP_HOSTNAME")"'/admin",
"docs": "https://'"$(json_escape "$RAGAPP_HOSTNAME")"'/docs",
"internal_api": "http://ragapp:8000"
}
},'
fi
# RAGFlow
if is_profile_active "ragflow"; then
SERVICES_JSON+='"ragflow": {
"hostname": "'"$(json_escape "$RAGFLOW_HOSTNAME")"'",
"credentials": {
"note": "Create account on first login"
},
"extra": {
"internal_api": "http://ragflow:80"
}
},'
fi
# LightRAG
if is_profile_active "lightrag"; then
SERVICES_JSON+='"lightrag": {
"hostname": "'"$(json_escape "$LIGHTRAG_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$LIGHTRAG_USERNAME")"'",
"password": "'"$(json_escape "$LIGHTRAG_PASSWORD")"'",
"api_key": "'"$(json_escape "$LIGHTRAG_API_KEY")"'"
},
"extra": {
"docs": "https://'"$(json_escape "$LIGHTRAG_HOSTNAME")"'/docs",
"internal_api": "http://lightrag:9621"
}
},'
fi
# Letta
if is_profile_active "letta"; then
SERVICES_JSON+='"letta": {
"hostname": "'"$(json_escape "$LETTA_HOSTNAME")"'",
"credentials": {
"api_key": "'"$(json_escape "$LETTA_SERVER_PASSWORD")"'"
}
},'
fi
# ComfyUI
if is_profile_active "comfyui"; then
SERVICES_JSON+='"comfyui": {
"hostname": "'"$(json_escape "$COMFYUI_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$COMFYUI_USERNAME")"'",
"password": "'"$(json_escape "$COMFYUI_PASSWORD")"'"
}
},'
fi
# LibreTranslate
if is_profile_active "libretranslate"; then
SERVICES_JSON+='"libretranslate": {
"hostname": "'"$(json_escape "$LT_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$LT_USERNAME")"'",
"password": "'"$(json_escape "$LT_PASSWORD")"'"
},
"extra": {
"internal_api": "http://libretranslate:5000"
}
},'
fi
# Docling
if is_profile_active "docling"; then
SERVICES_JSON+='"docling": {
"hostname": "'"$(json_escape "$DOCLING_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$DOCLING_USERNAME")"'",
"password": "'"$(json_escape "$DOCLING_PASSWORD")"'"
},
"extra": {
"ui": "https://'"$(json_escape "$DOCLING_HOSTNAME")"'/ui",
"docs": "https://'"$(json_escape "$DOCLING_HOSTNAME")"'/docs",
"internal_api": "http://docling:5001"
}
},'
fi
# PaddleOCR
if is_profile_active "paddleocr"; then
SERVICES_JSON+='"paddleocr": {
"hostname": "'"$(json_escape "$PADDLEOCR_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$PADDLEOCR_USERNAME")"'",
"password": "'"$(json_escape "$PADDLEOCR_PASSWORD")"'"
},
"extra": {
"internal_api": "http://paddleocr:8080"
}
},'
fi
# Postiz
if is_profile_active "postiz"; then
SERVICES_JSON+='"postiz": {
"hostname": "'"$(json_escape "$POSTIZ_HOSTNAME")"'",
"credentials": {
"note": "Create account on first login"
},
"extra": {
"internal_api": "http://postiz:5000"
}
},'
fi
# WAHA
if is_profile_active "waha"; then
SERVICES_JSON+='"waha": {
"hostname": "'"$(json_escape "$WAHA_HOSTNAME")"'",
"credentials": {
"username": "'"$(json_escape "$WAHA_DASHBOARD_USERNAME")"'",
"password": "'"$(json_escape "$WAHA_DASHBOARD_PASSWORD")"'",
"api_key": "'"$(json_escape "$WAHA_API_KEY_PLAIN")"'"
},
"extra": {
"dashboard": "https://'"$(json_escape "$WAHA_HOSTNAME")"'/dashboard",
"swagger_user": "'"$(json_escape "$WHATSAPP_SWAGGER_USERNAME")"'",
"swagger_pass": "'"$(json_escape "$WHATSAPP_SWAGGER_PASSWORD")"'",
"internal_api": "http://waha:3000"
}
},'
fi
# Crawl4AI (internal only)
if is_profile_active "crawl4ai"; then
SERVICES_JSON+='"crawl4ai": {
"hostname": null,
"credentials": {
"note": "Internal service only"
},
"extra": {
"internal_api": "http://crawl4ai:11235"
}
},'
fi
# Gotenberg (internal only)
if is_profile_active "gotenberg"; then
SERVICES_JSON+='"gotenberg": {
"hostname": null,
"credentials": {
"note": "Internal service only"
},
"extra": {
"internal_api": "http://gotenberg:3000",
"docs": "https://gotenberg.dev/docs"
}
},'
fi
# Ollama (internal only)
if is_profile_active "cpu" || is_profile_active "gpu-nvidia" || is_profile_active "gpu-amd"; then
SERVICES_JSON+='"ollama": {
"hostname": null,
"credentials": {
"note": "Internal service only"
},
"extra": {
"internal_api": "http://ollama:11434"
}
},'
fi
# Redis/Valkey (internal only, shown if n8n or langfuse active)
if is_profile_active "n8n" || is_profile_active "langfuse"; then
SERVICES_JSON+='"redis": {
"hostname": null,
"credentials": {
"password": "'"$(json_escape "$REDIS_AUTH")"'"
},
"extra": {
"internal_host": "'"${REDIS_HOST:-redis}"'",
"internal_port": "'"${REDIS_PORT:-6379}"'"
}
},'
fi
# PostgreSQL (internal only, shown if n8n or langfuse active)
if is_profile_active "n8n" || is_profile_active "langfuse"; then
SERVICES_JSON+='"postgres": {
"hostname": null,
"credentials": {
"username": "'"$(json_escape "${POSTGRES_USER:-postgres}")"'",
"password": "'"$(json_escape "$POSTGRES_PASSWORD")"'"
},
"extra": {
"internal_host": "postgres",
"internal_port": "'"${POSTGRES_PORT:-5432}"'",
"database": "'"$(json_escape "${POSTGRES_DB:-postgres}")"'"
}
},'
fi
# Python Runner (internal only)
if is_profile_active "python-runner"; then
SERVICES_JSON+='"python-runner": {
"hostname": null,
"credentials": {
"note": "Internal service only"
},
"extra": {
"logs_command": "docker compose -p localai logs -f python-runner"
}
},'
fi
# Cloudflare Tunnel
if is_profile_active "cloudflare-tunnel"; then
SERVICES_JSON+='"cloudflare-tunnel": {
"hostname": null,
"credentials": {
"note": "Zero-trust access via Cloudflare network"
},
"extra": {
"recommendation": "Close ports 80, 443, 7687 in your VPS firewall after confirming tunnel connectivity"
}
},'
fi
# Remove trailing comma from services JSON
SERVICES_JSON=$(echo "$SERVICES_JSON" | sed 's/,$//')
# Write final JSON
cat > "$OUTPUT_FILE" << EOF
{
"domain": "$(json_escape "$USER_DOMAIN_NAME")",
"generated_at": "$GENERATED_AT",
"services": {
$SERVICES_JSON
}
}
EOF
log_success "Welcome page data generated at: $OUTPUT_FILE"
log_info "Access it at: https://${WELCOME_HOSTNAME:-welcome.${USER_DOMAIN_NAME}}"

582
welcome/app.js Normal file
View File

@@ -0,0 +1,582 @@
/**
* n8n-install Welcome Page
* Dynamic rendering of services and credentials from data.json
*/
(function() {
'use strict';
// Service metadata - hardcoded info about each service
const SERVICE_METADATA = {
'n8n': {
name: 'n8n',
description: 'Workflow Automation',
icon: 'n8n',
color: 'bg-orange-500',
category: 'automation'
},
'flowise': {
name: 'Flowise',
description: 'AI Agent Builder',
icon: 'FL',
color: 'bg-blue-500',
category: 'ai'
},
'open-webui': {
name: 'Open WebUI',
description: 'ChatGPT-like Interface',
icon: 'AI',
color: 'bg-green-500',
category: 'ai'
},
'grafana': {
name: 'Grafana',
description: 'Monitoring Dashboard',
icon: 'GF',
color: 'bg-orange-600',
category: 'monitoring'
},
'prometheus': {
name: 'Prometheus',
description: 'Metrics Collection',
icon: 'PM',
color: 'bg-red-500',
category: 'monitoring'
},
'portainer': {
name: 'Portainer',
description: 'Docker Management UI',
icon: 'PT',
color: 'bg-cyan-500',
category: 'infra'
},
'postgresus': {
name: 'Postgresus',
description: 'PostgreSQL Backups & Monitoring',
icon: 'PG',
color: 'bg-blue-600',
category: 'database'
},
'langfuse': {
name: 'Langfuse',
description: 'AI Observability',
icon: 'LF',
color: 'bg-violet-500',
category: 'ai'
},
'supabase': {
name: 'Supabase',
description: 'Backend as a Service',
icon: 'SB',
color: 'bg-emerald-500',
category: 'database'
},
'dify': {
name: 'Dify',
description: 'AI Application Platform',
icon: 'DF',
color: 'bg-indigo-500',
category: 'ai'
},
'qdrant': {
name: 'Qdrant',
description: 'Vector Database',
icon: 'QD',
color: 'bg-purple-500',
category: 'database'
},
'weaviate': {
name: 'Weaviate',
description: 'Vector Database',
icon: 'WV',
color: 'bg-green-600',
category: 'database'
},
'neo4j': {
name: 'Neo4j',
description: 'Graph Database',
icon: 'N4',
color: 'bg-blue-700',
category: 'database'
},
'searxng': {
name: 'SearXNG',
description: 'Private Metasearch Engine',
icon: 'SX',
color: 'bg-teal-500',
category: 'tools'
},
'ragapp': {
name: 'RAGApp',
description: 'RAG UI & API',
icon: 'RA',
color: 'bg-amber-500',
category: 'ai'
},
'ragflow': {
name: 'RAGFlow',
description: 'Document Understanding RAG',
icon: 'RF',
color: 'bg-rose-500',
category: 'ai'
},
'lightrag': {
name: 'LightRAG',
description: 'Graph-based RAG',
icon: 'LR',
color: 'bg-lime-600',
category: 'ai'
},
'letta': {
name: 'Letta',
description: 'Agent Server & SDK',
icon: 'LT',
color: 'bg-fuchsia-500',
category: 'ai'
},
'comfyui': {
name: 'ComfyUI',
description: 'Stable Diffusion UI',
icon: 'CU',
color: 'bg-pink-500',
category: 'ai'
},
'libretranslate': {
name: 'LibreTranslate',
description: 'Translation API',
icon: 'TR',
color: 'bg-sky-500',
category: 'tools'
},
'docling': {
name: 'Docling',
description: 'Document Converter',
icon: 'DL',
color: 'bg-stone-500',
category: 'tools'
},
'paddleocr': {
name: 'PaddleOCR',
description: 'OCR API Server',
icon: 'OC',
color: 'bg-yellow-600',
category: 'tools'
},
'postiz': {
name: 'Postiz',
description: 'Social Publishing Platform',
icon: 'PZ',
color: 'bg-violet-600',
category: 'tools'
},
'waha': {
name: 'WAHA',
description: 'WhatsApp HTTP API',
icon: 'WA',
color: 'bg-green-700',
category: 'tools'
},
'crawl4ai': {
name: 'Crawl4AI',
description: 'Web Crawler for AI',
icon: 'C4',
color: 'bg-gray-600',
category: 'tools'
},
'gotenberg': {
name: 'Gotenberg',
description: 'PDF Generator API',
icon: 'GT',
color: 'bg-red-600',
category: 'tools'
},
'ollama': {
name: 'Ollama',
description: 'Local LLM Runner',
icon: 'OL',
color: 'bg-gray-700',
category: 'ai'
},
'redis': {
name: 'Redis (Valkey)',
description: 'In-Memory Data Store',
icon: 'RD',
color: 'bg-red-700',
category: 'infra'
},
'postgres': {
name: 'PostgreSQL',
description: 'Relational Database',
icon: 'PG',
color: 'bg-blue-800',
category: 'infra'
},
'python-runner': {
name: 'Python Runner',
description: 'Custom Python Scripts',
icon: 'PY',
color: 'bg-yellow-500',
category: 'tools'
},
'cloudflare-tunnel': {
name: 'Cloudflare Tunnel',
description: 'Zero-Trust Network Access',
icon: 'CF',
color: 'bg-orange-500',
category: 'infra'
}
};
// DOM Elements
const servicesContainer = document.getElementById('services-container');
const quickstartContainer = document.getElementById('quickstart-container');
const domainInfo = document.getElementById('domain-info');
const errorToast = document.getElementById('error-toast');
const errorMessage = document.getElementById('error-message');
/**
* Show error toast
*/
function showError(message) {
errorMessage.textContent = message;
errorToast.classList.remove('hidden');
setTimeout(() => {
errorToast.classList.remove('translate-y-20', 'opacity-0');
}, 10);
setTimeout(() => {
errorToast.classList.add('translate-y-20', 'opacity-0');
setTimeout(() => errorToast.classList.add('hidden'), 300);
}, 5000);
}
/**
* Create password field with toggle and copy buttons
*/
function createPasswordField(password) {
const container = document.createElement('div');
container.className = 'flex items-center gap-1';
const passwordSpan = document.createElement('span');
passwordSpan.className = 'font-mono text-sm select-all';
passwordSpan.textContent = '*'.repeat(Math.min(password.length, 12));
passwordSpan.dataset.password = password;
passwordSpan.dataset.hidden = 'true';
// Toggle visibility button (eye icon)
const toggleBtn = document.createElement('button');
toggleBtn.className = 'p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500';
toggleBtn.innerHTML = `
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
`;
toggleBtn.title = 'Hold to reveal';
// Show password on mouse down, hide on mouse up/leave
const showPassword = () => {
passwordSpan.textContent = passwordSpan.dataset.password;
passwordSpan.dataset.hidden = 'false';
};
const hidePassword = () => {
passwordSpan.textContent = '*'.repeat(Math.min(password.length, 12));
passwordSpan.dataset.hidden = 'true';
};
toggleBtn.addEventListener('mousedown', showPassword);
toggleBtn.addEventListener('mouseup', hidePassword);
toggleBtn.addEventListener('mouseleave', hidePassword);
toggleBtn.addEventListener('touchstart', showPassword);
toggleBtn.addEventListener('touchend', hidePassword);
// Copy button
const copyBtn = document.createElement('button');
copyBtn.className = 'p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500';
copyBtn.innerHTML = `
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg class="w-4 h-4 text-green-500 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
`;
copyBtn.title = 'Copy to clipboard';
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(password);
// Show checkmark
const copyIcon = copyBtn.querySelector('.copy-icon');
const checkIcon = copyBtn.querySelector('.check-icon');
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
// Revert after 2 seconds
setTimeout(() => {
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
});
container.appendChild(passwordSpan);
container.appendChild(toggleBtn);
container.appendChild(copyBtn);
return container;
}
/**
* Render a single service card
*/
function renderServiceCard(key, serviceData) {
const metadata = SERVICE_METADATA[key] || {
name: key,
description: '',
icon: key.substring(0, 2).toUpperCase(),
color: 'bg-slate-500'
};
const card = document.createElement('div');
card.className = 'bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-5 hover:shadow-lg transition-shadow';
// Build credentials section
let credentialsHtml = '';
if (serviceData.credentials) {
const creds = serviceData.credentials;
if (creds.note) {
credentialsHtml = `
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-slate-700">
<p class="text-sm text-gray-500 dark:text-gray-400 italic">${escapeHtml(creds.note)}</p>
</div>
`;
} else {
let fields = [];
if (creds.username) {
fields.push(`
<div class="flex justify-between items-center">
<span class="text-gray-500 dark:text-gray-400 text-sm">Username:</span>
<span class="font-mono text-sm select-all">${escapeHtml(creds.username)}</span>
</div>
`);
}
if (creds.password) {
fields.push(`
<div class="flex justify-between items-center" id="pwd-${key}">
<span class="text-gray-500 dark:text-gray-400 text-sm">Password:</span>
</div>
`);
}
if (creds.api_key) {
fields.push(`
<div class="flex justify-between items-center" id="api-${key}">
<span class="text-gray-500 dark:text-gray-400 text-sm">API Key:</span>
</div>
`);
}
if (fields.length > 0) {
credentialsHtml = `
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-slate-700 space-y-2">
${fields.join('')}
</div>
`;
}
}
}
// Build extra info section (internal URLs, etc.)
let extraHtml = '';
if (serviceData.extra) {
const extraItems = [];
const extra = serviceData.extra;
if (extra.internal_api) {
extraItems.push(`<span class="text-xs text-gray-400 dark:text-gray-500">Internal: ${escapeHtml(extra.internal_api)}</span>`);
}
if (extra.workers) {
extraItems.push(`<span class="text-xs text-gray-400 dark:text-gray-500">Workers: ${escapeHtml(extra.workers)}</span>`);
}
if (extra.recommendation) {
extraItems.push(`<span class="text-xs text-amber-600 dark:text-amber-400">${escapeHtml(extra.recommendation)}</span>`);
}
if (extraItems.length > 0) {
extraHtml = `<div class="mt-2 flex flex-wrap gap-2">${extraItems.join('')}</div>`;
}
}
card.innerHTML = `
<div class="flex items-start gap-4">
<div class="${metadata.color} w-12 h-12 rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
${metadata.icon}
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-lg">${escapeHtml(metadata.name)}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">${escapeHtml(metadata.description)}</p>
${serviceData.hostname ? `
<a href="https://${escapeHtml(serviceData.hostname)}" target="_blank" rel="noopener"
class="text-blue-500 hover:text-blue-600 text-sm font-medium inline-flex items-center gap-1 group">
${escapeHtml(serviceData.hostname)}
<svg class="w-3 h-3 group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
` : '<span class="text-sm text-gray-400 dark:text-gray-500 italic">Internal service</span>'}
${extraHtml}
</div>
</div>
${credentialsHtml}
`;
// Add password fields after card is created
if (serviceData.credentials) {
const creds = serviceData.credentials;
setTimeout(() => {
if (creds.password) {
const pwdContainer = card.querySelector(`#pwd-${key}`);
if (pwdContainer) {
pwdContainer.appendChild(createPasswordField(creds.password));
}
}
if (creds.api_key) {
const apiContainer = card.querySelector(`#api-${key}`);
if (apiContainer) {
apiContainer.appendChild(createPasswordField(creds.api_key));
}
}
}, 0);
}
return card;
}
/**
* Render all services
*/
function renderServices(services) {
servicesContainer.innerHTML = '';
if (!services || Object.keys(services).length === 0) {
servicesContainer.innerHTML = `
<div class="col-span-full text-center py-8 text-gray-500 dark:text-gray-400">
<p>No services configured. Run the installer to set up services.</p>
</div>
`;
return;
}
// Sort services: external first (with hostname), then internal
const sortedKeys = Object.keys(services).sort((a, b) => {
const aHasHostname = services[a].hostname ? 1 : 0;
const bHasHostname = services[b].hostname ? 1 : 0;
return bHasHostname - aHasHostname;
});
sortedKeys.forEach(key => {
servicesContainer.appendChild(renderServiceCard(key, services[key]));
});
}
/**
* Render quick start steps
*/
function renderQuickStart(steps) {
quickstartContainer.innerHTML = '';
if (!steps || steps.length === 0) {
// Default steps if none provided
steps = [
{ step: 1, title: 'Log into n8n', description: 'Use the email you provided during installation' },
{ step: 2, title: 'Create your first workflow', description: 'Start with a Manual Trigger + HTTP Request nodes' },
{ step: 3, title: 'Explore community workflows', description: 'Check imported workflows for 300+ examples' },
{ step: 4, title: 'Monitor your system', description: 'Use Grafana to track performance' }
];
}
steps.forEach(item => {
const stepEl = document.createElement('div');
stepEl.className = 'flex items-start gap-4 p-4 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700';
stepEl.innerHTML = `
<div class="w-8 h-8 rounded-full bg-green-500 text-white flex items-center justify-center font-bold text-sm flex-shrink-0">
${item.step}
</div>
<div>
<h4 class="font-semibold">${escapeHtml(item.title)}</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">${escapeHtml(item.description)}</p>
</div>
`;
quickstartContainer.appendChild(stepEl);
});
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Load data and render page
*/
async function init() {
try {
const response = await fetch('data.json');
if (!response.ok) {
throw new Error(`Failed to load data (${response.status})`);
}
const data = await response.json();
// Update domain info
if (data.domain) {
domainInfo.textContent = `Domain: ${data.domain}`;
}
if (data.generated_at) {
const date = new Date(data.generated_at);
domainInfo.textContent += ` | Generated: ${date.toLocaleString()}`;
}
// Render services
renderServices(data.services);
// Render quick start
renderQuickStart(data.quick_start);
} catch (error) {
console.error('Error loading data:', error);
// Show error in UI
servicesContainer.innerHTML = `
<div class="col-span-full bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6 text-center">
<svg class="w-12 h-12 mx-auto text-red-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<h3 class="font-semibold text-red-700 dark:text-red-400 mb-2">Unable to load service data</h3>
<p class="text-sm text-red-600 dark:text-red-300">Make sure the installation completed successfully and data.json was generated.</p>
</div>
`;
// Still render default quick start
renderQuickStart(null);
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

139
welcome/index.html Normal file
View File

@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to n8n-install</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
// Auto dark mode detection
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
document.documentElement.classList.toggle('dark', e.matches);
});
// Tailwind config for dark mode
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8'
}
}
}
}
}
</script>
<style>
/* Custom scrollbar for dark mode */
.dark ::-webkit-scrollbar { width: 8px; }
.dark ::-webkit-scrollbar-track { background: #1e293b; }
.dark ::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
</style>
</head>
<body class="bg-gray-50 dark:bg-slate-900 text-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200">
<div class="max-w-5xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
<!-- Header -->
<header class="text-center mb-12">
<h1 class="text-4xl sm:text-5xl font-bold bg-gradient-to-r from-blue-500 to-purple-600 bg-clip-text text-transparent mb-3">
Welcome to n8n-install
</h1>
<p class="text-lg text-gray-600 dark:text-gray-400">
Your self-hosted automation platform is ready
</p>
<p id="domain-info" class="text-sm text-gray-500 dark:text-gray-500 mt-2"></p>
</header>
<!-- Services Section -->
<section class="mb-12">
<h2 class="text-2xl font-semibold mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
Your Services
</h2>
<div id="services-container" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Services will be injected here by JavaScript -->
<div class="animate-pulse bg-gray-200 dark:bg-slate-800 rounded-xl h-48"></div>
<div class="animate-pulse bg-gray-200 dark:bg-slate-800 rounded-xl h-48"></div>
<div class="animate-pulse bg-gray-200 dark:bg-slate-800 rounded-xl h-48"></div>
</div>
</section>
<!-- Quick Start Section -->
<section class="mb-12">
<h2 class="text-2xl font-semibold mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
Quick Start
</h2>
<div id="quickstart-container" class="space-y-4">
<!-- Quick start steps will be injected here -->
<div class="animate-pulse bg-gray-200 dark:bg-slate-800 rounded-xl h-20"></div>
<div class="animate-pulse bg-gray-200 dark:bg-slate-800 rounded-xl h-20"></div>
</div>
</section>
<!-- Documentation Section -->
<section class="mb-12">
<h2 class="text-2xl font-semibold mb-6 flex items-center gap-2">
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
Documentation
</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<a href="https://docs.n8n.io/" target="_blank" rel="noopener"
class="flex items-center gap-3 p-4 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 hover:border-blue-500 dark:hover:border-blue-500 transition-colors group">
<span class="text-gray-700 dark:text-gray-300 group-hover:text-blue-500 transition-colors">n8n Docs</span>
<svg class="w-4 h-4 ml-auto text-gray-400 group-hover:text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
<a href="https://docs.n8n.io/advanced-ai/intro-tutorial/" target="_blank" rel="noopener"
class="flex items-center gap-3 p-4 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 hover:border-blue-500 dark:hover:border-blue-500 transition-colors group">
<span class="text-gray-700 dark:text-gray-300 group-hover:text-blue-500 transition-colors">AI Tutorial</span>
<svg class="w-4 h-4 ml-auto text-gray-400 group-hover:text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
<a href="https://n8n.io/workflows/" target="_blank" rel="noopener"
class="flex items-center gap-3 p-4 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 hover:border-blue-500 dark:hover:border-blue-500 transition-colors group">
<span class="text-gray-700 dark:text-gray-300 group-hover:text-blue-500 transition-colors">Templates</span>
<svg class="w-4 h-4 ml-auto text-gray-400 group-hover:text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
<a href="https://github.com/kossakovsky/n8n-install" target="_blank" rel="noopener"
class="flex items-center gap-3 p-4 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 hover:border-blue-500 dark:hover:border-blue-500 transition-colors group">
<span class="text-gray-700 dark:text-gray-300 group-hover:text-blue-500 transition-colors">GitHub</span>
<svg class="w-4 h-4 ml-auto text-gray-400 group-hover:text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
</div>
</section>
<!-- Footer -->
<footer class="text-center text-sm text-gray-500 dark:text-gray-500 pt-8 border-t border-gray-200 dark:border-slate-800">
<p>Powered by <a href="https://github.com/kossakovsky/n8n-install" target="_blank" rel="noopener" class="text-blue-500 hover:underline">n8n-install</a></p>
</footer>
</div>
<!-- Error Toast (hidden by default) -->
<div id="error-toast" class="fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg transform translate-y-20 opacity-0 transition-all duration-300 hidden">
<p id="error-message">Error loading data</p>
</div>
<script src="app.js"></script>
</body>
</html>