mirror of
https://github.com/kossakovsky/n8n-install.git
synced 2026-03-07 22:33:11 +00:00
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:
@@ -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`
|
||||
|
||||
|
||||
10
.env.example
10
.env.example
@@ -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
3
.gitignore
vendored
@@ -11,4 +11,5 @@ supabase/
|
||||
dify/
|
||||
volumes/
|
||||
docker-compose.override.yml
|
||||
docker-compose.n8n-workers.yml
|
||||
docker-compose.n8n-workers.yml
|
||||
welcome/data.json
|
||||
10
Caddyfile
10
Caddyfile
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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
483
scripts/generate_welcome_page.sh
Executable 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
582
welcome/app.js
Normal 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
139
welcome/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user