Merge branch 'develop_ntfy_brin_advisor'
- install NTFY for notification for mobile (WIP) - install script to validate brin psql and do advisor - can deploy ssh remote with TODO - TODO with command erase database
This commit is contained in:
commit
54730243d5
8 changed files with 1399 additions and 0 deletions
1
Makefile
1
Makefile
|
|
@ -47,6 +47,7 @@ endif
|
||||||
-include ./conf/make.installation.Makefile
|
-include ./conf/make.installation.Makefile
|
||||||
-include ./conf/make.installation.poetry.Makefile
|
-include ./conf/make.installation.poetry.Makefile
|
||||||
-include ./conf/make.test.Makefile
|
-include ./conf/make.test.Makefile
|
||||||
|
-include ./conf/make.ssh.Makefile
|
||||||
-include ./conf/make.todo.Makefile
|
-include ./conf/make.todo.Makefile
|
||||||
|
|
||||||
# Include private Makefile
|
# Include private Makefile
|
||||||
|
|
|
||||||
130
conf/make.ssh.Makefile
Normal file
130
conf/make.ssh.Makefile
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
#######
|
||||||
|
# SSH #
|
||||||
|
#######
|
||||||
|
|
||||||
|
# Connection variables (override on command line or via environment)
|
||||||
|
SSH_HOST ?=
|
||||||
|
SSH_USER ?= erplibre
|
||||||
|
SSH_PORT ?= 22
|
||||||
|
SSH_KEY ?=
|
||||||
|
SSH_PATH ?= ~/erplibre_deploy_2
|
||||||
|
|
||||||
|
# Target to run remotely via ssh_make
|
||||||
|
SSH_TARGET ?= run
|
||||||
|
|
||||||
|
# Build SSH/rsync options from variables
|
||||||
|
_SSH_KEY_OPT = $(if $(SSH_KEY),-i $(SSH_KEY),)
|
||||||
|
_SSH_CMD = ssh -p $(SSH_PORT) -o StrictHostKeyChecking=accept-new $(_SSH_KEY_OPT)
|
||||||
|
_RSYNC_SSH = ssh -p $(SSH_PORT) -o StrictHostKeyChecking=accept-new $(_SSH_KEY_OPT)
|
||||||
|
|
||||||
|
define _require_host
|
||||||
|
@test -n "$(SSH_HOST)" || \
|
||||||
|
(echo "Error: SSH_HOST is required. Usage: make $@ SSH_HOST=hostname [SSH_USER=erplibre] [SSH_PORT=22] [SSH_KEY=~/.ssh/id_rsa]" && exit 1)
|
||||||
|
endef
|
||||||
|
|
||||||
|
# Test SSH connectivity
|
||||||
|
.PHONY: ssh_check
|
||||||
|
ssh_check:
|
||||||
|
$(call _require_host)
|
||||||
|
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "echo 'SSH connection to $(SSH_HOST) OK && uname -a'"
|
||||||
|
|
||||||
|
# Sync project files to remote (excludes venvs, addons, odoo sources, git)
|
||||||
|
.PHONY: ssh_push
|
||||||
|
ssh_push:
|
||||||
|
$(call _require_host)
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='.venv.*/' \
|
||||||
|
--exclude='addons/' \
|
||||||
|
--exclude='odoo12.0/' \
|
||||||
|
--exclude='odoo13.0/' \
|
||||||
|
--exclude='odoo14.0/' \
|
||||||
|
--exclude='odoo15.0/' \
|
||||||
|
--exclude='odoo16.0/' \
|
||||||
|
--exclude='odoo17.0/' \
|
||||||
|
--exclude='odoo18.0/' \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='private/' \
|
||||||
|
--exclude='*.pyc' \
|
||||||
|
--exclude='__pycache__/' \
|
||||||
|
-e "$(_RSYNC_SSH)" \
|
||||||
|
./ $(SSH_USER)@$(SSH_HOST):$(SSH_PATH)/
|
||||||
|
|
||||||
|
# Install ERPLibre on remote server (Odoo 18 default)
|
||||||
|
.PHONY: ssh_install
|
||||||
|
ssh_install:
|
||||||
|
$(call _require_host)
|
||||||
|
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "cd $(SSH_PATH) && make install_odoo_18"
|
||||||
|
|
||||||
|
# Start Odoo on remote server
|
||||||
|
.PHONY: ssh_run
|
||||||
|
ssh_run:
|
||||||
|
$(call _require_host)
|
||||||
|
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "cd $(SSH_PATH) && make run"
|
||||||
|
|
||||||
|
# Stop Odoo on remote server (via systemd if available, else kill process)
|
||||||
|
.PHONY: ssh_stop
|
||||||
|
ssh_stop:
|
||||||
|
$(call _require_host)
|
||||||
|
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||||
|
"systemctl --user stop erplibre 2>/dev/null || \
|
||||||
|
sudo systemctl stop erplibre 2>/dev/null || \
|
||||||
|
cd $(SSH_PATH) && make process_kill_odoo"
|
||||||
|
|
||||||
|
# Restart Odoo on remote server
|
||||||
|
.PHONY: ssh_restart
|
||||||
|
ssh_restart:
|
||||||
|
$(call _require_host)
|
||||||
|
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||||
|
"systemctl --user restart erplibre 2>/dev/null || \
|
||||||
|
sudo systemctl restart erplibre 2>/dev/null || \
|
||||||
|
(cd $(SSH_PATH) && make process_kill_odoo && make run)"
|
||||||
|
|
||||||
|
# Show service status on remote server
|
||||||
|
.PHONY: ssh_status
|
||||||
|
ssh_status:
|
||||||
|
$(call _require_host)
|
||||||
|
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||||
|
"systemctl --user status erplibre 2>/dev/null || \
|
||||||
|
sudo systemctl status erplibre 2>/dev/null || \
|
||||||
|
ps aux | grep odoo | grep -v grep"
|
||||||
|
|
||||||
|
# Stream logs from remote server
|
||||||
|
.PHONY: ssh_logs
|
||||||
|
ssh_logs:
|
||||||
|
$(call _require_host)
|
||||||
|
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||||
|
"journalctl --user -u erplibre -f 2>/dev/null || \
|
||||||
|
sudo journalctl -u erplibre -f"
|
||||||
|
|
||||||
|
# Execute any make target on remote server (e.g.: make ssh_make SSH_HOST=host SSH_TARGET=db_create_db_test)
|
||||||
|
.PHONY: ssh_make
|
||||||
|
ssh_make:
|
||||||
|
$(call _require_host)
|
||||||
|
@test -n "$(SSH_TARGET)" || \
|
||||||
|
(echo "Error: SSH_TARGET is required. Usage: make ssh_make SSH_HOST=hostname SSH_TARGET=make_target" && exit 1)
|
||||||
|
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "cd $(SSH_PATH) && make $(SSH_TARGET)"
|
||||||
|
|
||||||
|
# Install systemd service on remote server
|
||||||
|
.PHONY: ssh_install_systemd
|
||||||
|
ssh_install_systemd:
|
||||||
|
$(call _require_host)
|
||||||
|
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||||
|
"cd $(SSH_PATH) && python3 script/systemd/install_daemon.py \
|
||||||
|
--user $(SSH_USER) \
|
||||||
|
--home-erplibre $(SSH_PATH) \
|
||||||
|
--port 8069"
|
||||||
|
|
||||||
|
# Configure nginx + SSL on remote server (requires SSH_DOMAIN)
|
||||||
|
SSH_DOMAIN ?=
|
||||||
|
SSH_ADMIN_EMAIL ?=
|
||||||
|
.PHONY: ssh_install_nginx
|
||||||
|
ssh_install_nginx:
|
||||||
|
$(call _require_host)
|
||||||
|
@test -n "$(SSH_DOMAIN)" || \
|
||||||
|
(echo "Error: SSH_DOMAIN is required. Usage: make ssh_install_nginx SSH_HOST=hostname SSH_DOMAIN=example.com [SSH_ADMIN_EMAIL=admin@example.com]" && exit 1)
|
||||||
|
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||||
|
"cd $(SSH_PATH) && sudo python3 script/nginx/deploy_nginx_and_certbot.py \
|
||||||
|
--generate_nginx \
|
||||||
|
--run_certbot \
|
||||||
|
--domain $(SSH_DOMAIN) \
|
||||||
|
$(if $(SSH_ADMIN_EMAIL),--admin_email $(SSH_ADMIN_EMAIL),)"
|
||||||
303
script/install/install_ntfy.sh
Executable file
303
script/install/install_ntfy.sh
Executable file
|
|
@ -0,0 +1,303 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install NTFY self-hosted push notification server
|
||||||
|
# https://github.com/binwiederhier/ntfy
|
||||||
|
# Supports: Ubuntu 20.04+, Debian 11+, Arch Linux (and derivatives)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo bash install_ntfy.sh
|
||||||
|
# sudo NTFY_PORT=8080 NTFY_BASE_URL=http://192.168.1.100:8080 bash install_ntfy.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
NTFY_PORT="${NTFY_PORT:-8080}"
|
||||||
|
NTFY_BASE_URL="${NTFY_BASE_URL:-http://localhost:${NTFY_PORT}}"
|
||||||
|
INSTALL_DIR="/usr/local/bin"
|
||||||
|
CONFIG_DIR="/etc/ntfy"
|
||||||
|
CACHE_DIR="/var/cache/ntfy"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
log() { echo "[NTFY] $*"; }
|
||||||
|
die() { echo "[NTFY] ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
check_root() {
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
die "This script must be run as root. Use: sudo bash $0"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_os() {
|
||||||
|
if [ -f /etc/os-release ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. /etc/os-release
|
||||||
|
OS="${ID}"
|
||||||
|
OS_LIKE="${ID_LIKE:-}"
|
||||||
|
else
|
||||||
|
die "Cannot detect OS (no /etc/os-release)"
|
||||||
|
fi
|
||||||
|
log "Detected OS: ${OS}"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_ubuntu_like() {
|
||||||
|
case "$OS" in
|
||||||
|
ubuntu|debian|linuxmint|pop|elementary|raspbian) return 0 ;;
|
||||||
|
*) echo "$OS_LIKE" | grep -qE "debian|ubuntu" && return 0 || return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
is_arch_like() {
|
||||||
|
case "$OS" in
|
||||||
|
arch|manjaro|endeavouros|artix|garuda) return 0 ;;
|
||||||
|
*) echo "$OS_LIKE" | grep -q "arch" && return 0 || return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Get latest ntfy version from GitHub
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
get_latest_version() {
|
||||||
|
local ver
|
||||||
|
ver=$(curl -fsSL \
|
||||||
|
"https://api.github.com/repos/binwiederhier/ntfy/releases/latest" \
|
||||||
|
2>/dev/null | grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/' | head -1)
|
||||||
|
if [ -z "$ver" ]; then
|
||||||
|
# Fallback to a known stable version if GitHub API is unreachable
|
||||||
|
ver="2.11.0"
|
||||||
|
log "Warning: could not reach GitHub API, falling back to v${ver}"
|
||||||
|
fi
|
||||||
|
echo "$ver"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Installation methods
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
install_deb() {
|
||||||
|
local version="$1"
|
||||||
|
local arch
|
||||||
|
arch=$(dpkg --print-architecture) # amd64, arm64, armhf, ...
|
||||||
|
|
||||||
|
local deb_file="ntfy_${version}_linux_${arch}.deb"
|
||||||
|
local url="https://github.com/binwiederhier/ntfy/releases/download/v${version}/${deb_file}"
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
|
||||||
|
log "Downloading ${deb_file}..."
|
||||||
|
if ! curl -fsSL "$url" -o "${tmp}/${deb_file}"; then
|
||||||
|
log "Warning: .deb not found for arch=${arch}, trying binary install..."
|
||||||
|
rm -rf "$tmp"
|
||||||
|
install_binary "$version"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Installing .deb package..."
|
||||||
|
dpkg -i "${tmp}/${deb_file}" || apt-get install -f -y
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_binary() {
|
||||||
|
local version="$1"
|
||||||
|
local machine
|
||||||
|
machine=$(uname -m)
|
||||||
|
local arch_str="amd64"
|
||||||
|
case "$machine" in
|
||||||
|
aarch64|arm64) arch_str="arm64" ;;
|
||||||
|
armv7l|armhf) arch_str="armv7" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local tarball="ntfy_${version}_linux_${arch_str}.tar.gz"
|
||||||
|
local url="https://github.com/binwiederhier/ntfy/releases/download/v${version}/${tarball}"
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
|
||||||
|
log "Downloading ${tarball}..."
|
||||||
|
curl -fsSL "$url" -o "${tmp}/${tarball}" || \
|
||||||
|
die "Failed to download ntfy from GitHub. Check your internet connection."
|
||||||
|
tar -xzf "${tmp}/${tarball}" -C "$tmp"
|
||||||
|
|
||||||
|
local binary
|
||||||
|
binary=$(find "$tmp" -name "ntfy" -type f | head -1)
|
||||||
|
[ -n "$binary" ] || die "Binary not found in archive"
|
||||||
|
|
||||||
|
install -m 755 "$binary" "${INSTALL_DIR}/ntfy"
|
||||||
|
log "ntfy binary installed to ${INSTALL_DIR}/ntfy"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
# Install systemd service manually
|
||||||
|
create_systemd_service
|
||||||
|
}
|
||||||
|
|
||||||
|
install_arch_aur() {
|
||||||
|
local aur_pkg="ntfy"
|
||||||
|
|
||||||
|
if command -v yay &>/dev/null; then
|
||||||
|
log "Installing via yay (AUR)..."
|
||||||
|
sudo -u "${SUDO_USER:-$USER}" yay -S --noconfirm "$aur_pkg"
|
||||||
|
elif command -v paru &>/dev/null; then
|
||||||
|
log "Installing via paru (AUR)..."
|
||||||
|
sudo -u "${SUDO_USER:-$USER}" paru -S --noconfirm "$aur_pkg"
|
||||||
|
else
|
||||||
|
log "No AUR helper found (yay/paru). Falling back to binary install..."
|
||||||
|
local version
|
||||||
|
version=$(get_latest_version)
|
||||||
|
install_binary "$version"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
create_systemd_service() {
|
||||||
|
cat > /etc/systemd/system/ntfy.service <<'SYSTEMD'
|
||||||
|
[Unit]
|
||||||
|
Description=ntfy push notification server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/ntfy serve
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
User=ntfy
|
||||||
|
Group=ntfy
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/var/cache/ntfy /etc/ntfy
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SYSTEMD
|
||||||
|
|
||||||
|
# Create dedicated system user if it doesn't exist
|
||||||
|
if ! id ntfy &>/dev/null; then
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin ntfy
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Systemd service created."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
configure_ntfy() {
|
||||||
|
log "Configuring NTFY..."
|
||||||
|
mkdir -p "$CONFIG_DIR" "$CACHE_DIR"
|
||||||
|
|
||||||
|
# Set ownership for cache dir if ntfy user exists
|
||||||
|
if id ntfy &>/dev/null; then
|
||||||
|
chown ntfy:ntfy "$CACHE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${CONFIG_DIR}/server.yml" ]; then
|
||||||
|
log "Config already exists at ${CONFIG_DIR}/server.yml — skipping."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "${CONFIG_DIR}/server.yml" <<YAML
|
||||||
|
# NTFY Server configuration
|
||||||
|
# Documentation: https://ntfy.sh/docs/config/
|
||||||
|
|
||||||
|
# Public URL of this server (used in notification links)
|
||||||
|
base-url: "${NTFY_BASE_URL}"
|
||||||
|
|
||||||
|
# Listening address — change to ":443" + TLS for production
|
||||||
|
listen-http: ":${NTFY_PORT}"
|
||||||
|
|
||||||
|
# Message cache (enables message history for reconnecting subscribers)
|
||||||
|
cache-file: "${CACHE_DIR}/cache.db"
|
||||||
|
cache-duration: "12h"
|
||||||
|
|
||||||
|
log-level: info
|
||||||
|
|
||||||
|
# Attachment support
|
||||||
|
attachment-cache-dir: "${CACHE_DIR}/attachments"
|
||||||
|
attachment-total-size-limit: "5G"
|
||||||
|
attachment-file-size-limit: "15M"
|
||||||
|
attachment-expiry-duration: "3h"
|
||||||
|
|
||||||
|
# ─── Authentication (optional) ───────────────────────────────────────────────
|
||||||
|
# Uncomment and run "ntfy user add <user>" to require login.
|
||||||
|
# auth-file: "${CONFIG_DIR}/user.db"
|
||||||
|
# auth-default-access: "deny-all"
|
||||||
|
YAML
|
||||||
|
log "Configuration written to ${CONFIG_DIR}/server.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Enable & start service
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enable_service() {
|
||||||
|
log "Enabling and starting ntfy service..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable ntfy
|
||||||
|
systemctl restart ntfy
|
||||||
|
sleep 1
|
||||||
|
systemctl is-active ntfy --quiet && log "ntfy is running." \
|
||||||
|
|| log "Warning: ntfy may not have started. Run: systemctl status ntfy"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
main() {
|
||||||
|
check_root
|
||||||
|
detect_os
|
||||||
|
|
||||||
|
if command -v ntfy &>/dev/null && ntfy version &>/dev/null 2>&1; then
|
||||||
|
log "ntfy is already installed: $(ntfy version 2>/dev/null | head -1)"
|
||||||
|
log "Reconfiguring and restarting..."
|
||||||
|
configure_ntfy
|
||||||
|
enable_service
|
||||||
|
elif is_ubuntu_like; then
|
||||||
|
log "Using Ubuntu/Debian install path..."
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends curl ca-certificates
|
||||||
|
local version
|
||||||
|
version=$(get_latest_version)
|
||||||
|
log "Installing ntfy v${version}..."
|
||||||
|
install_deb "$version"
|
||||||
|
configure_ntfy
|
||||||
|
enable_service
|
||||||
|
elif is_arch_like; then
|
||||||
|
log "Using Arch Linux install path..."
|
||||||
|
install_arch_aur
|
||||||
|
configure_ntfy
|
||||||
|
enable_service
|
||||||
|
else
|
||||||
|
log "Unknown OS '${OS}', attempting generic binary install..."
|
||||||
|
apt-get install -y --no-install-recommends curl ca-certificates 2>/dev/null \
|
||||||
|
|| pacman -S --noconfirm curl ca-certificates 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
local version
|
||||||
|
version=$(get_latest_version)
|
||||||
|
install_binary "$version"
|
||||||
|
configure_ntfy
|
||||||
|
enable_service
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ip
|
||||||
|
ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "127.0.0.1")
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "======================================================="
|
||||||
|
echo " NTFY push notification server installed successfully!"
|
||||||
|
echo "======================================================="
|
||||||
|
echo " Local URL : http://localhost:${NTFY_PORT}"
|
||||||
|
echo " Network URL: http://${ip}:${NTFY_PORT}"
|
||||||
|
echo " Config : ${CONFIG_DIR}/server.yml"
|
||||||
|
echo "-------------------------------------------------------"
|
||||||
|
echo " Test (send a notification):"
|
||||||
|
echo " curl -d 'Hello World' http://localhost:${NTFY_PORT}/my-topic"
|
||||||
|
echo ""
|
||||||
|
echo " Subscribe on mobile (ntfy app):"
|
||||||
|
echo " 1. Install 'ntfy' from Google Play / F-Droid / App Store"
|
||||||
|
echo " 2. Add server: http://${ip}:${NTFY_PORT}"
|
||||||
|
echo " 3. Subscribe to topic: my-topic"
|
||||||
|
echo "======================================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
191
script/shell_script_odoo/brin_advisor.py
Normal file
191
script/shell_script_odoo/brin_advisor.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
brin_advisor.py — Recommends the optimal index type for an Odoo 18 model
|
||||||
|
|
||||||
|
Usage (via odoo-bin shell):
|
||||||
|
source .venv/bin/activate
|
||||||
|
echo "MODEL='account.move.line'" | cat - brin_advisor.py \
|
||||||
|
| odoo-bin shell -d my_database --no-http
|
||||||
|
|
||||||
|
# Or with export:
|
||||||
|
export MODEL=mail.message
|
||||||
|
cat brin_advisor.py | odoo-bin shell -d my_database --no-http
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DATE_COLUMNS = [
|
||||||
|
"date",
|
||||||
|
"create_date",
|
||||||
|
"write_date",
|
||||||
|
"invoice_date",
|
||||||
|
"scheduled_date",
|
||||||
|
"date_done",
|
||||||
|
"in_date",
|
||||||
|
"expiration_date",
|
||||||
|
]
|
||||||
|
|
||||||
|
THRESHOLDS = [
|
||||||
|
(0.9, "✅ BRIN ideal", "\033[0;32m"),
|
||||||
|
(0.5, "⚠️ BRIN acceptable", "\033[1;33m"),
|
||||||
|
(0.1, "❌ Prefer B-tree", "\033[0;31m"),
|
||||||
|
(-1.0, "❌ B-tree mandatory", "\033[0;31m"),
|
||||||
|
]
|
||||||
|
|
||||||
|
RESET = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
CYAN = "\033[0;36m"
|
||||||
|
GREEN = "\033[0;32m"
|
||||||
|
YELLOW = "\033[1;33m"
|
||||||
|
RED = "\033[0;31m"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def recommend(correlation):
|
||||||
|
for threshold, label, color in THRESHOLDS:
|
||||||
|
if correlation >= threshold:
|
||||||
|
return label, color
|
||||||
|
return THRESHOLDS[-1][1], THRESHOLDS[-1][2]
|
||||||
|
|
||||||
|
|
||||||
|
def model_to_table(model_name):
|
||||||
|
"""
|
||||||
|
Resolve model name to table via the Odoo ORM — more reliable than a plain
|
||||||
|
replace('.', '_') since some models override _table explicitly.
|
||||||
|
"""
|
||||||
|
if model_name not in env:
|
||||||
|
# Fallback: standard Odoo naming convention
|
||||||
|
return model_name.replace(".", "_")
|
||||||
|
return env[model_name]._table
|
||||||
|
|
||||||
|
|
||||||
|
# ── Model resolution ──────────────────────────────────────────────────────────
|
||||||
|
# Priority: MODEL variable defined before the pipe, then environment variable
|
||||||
|
model_name = (
|
||||||
|
globals().get("MODEL") or os.environ.get("MODEL") or "account.move.line"
|
||||||
|
)
|
||||||
|
table = model_to_table(model_name)
|
||||||
|
cr = env.cr # PostgreSQL cursor provided by odoo-bin shell
|
||||||
|
|
||||||
|
print(f"\n{BOLD}{CYAN}=== BRIN Advisor for: {model_name} ==={RESET}\n")
|
||||||
|
print(f" Model : {model_name}")
|
||||||
|
print(f" Table : {table}\n")
|
||||||
|
|
||||||
|
# ── Check table exists ────────────────────────────────────────────────────────
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = %s
|
||||||
|
""",
|
||||||
|
(table,),
|
||||||
|
)
|
||||||
|
if cr.fetchone()[0] == 0:
|
||||||
|
print(f"{RED}❌ Table '{table}' not found in the database.{RESET}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
# ── Automatic ANALYZE ─────────────────────────────────────────────────────────
|
||||||
|
# odoo-bin shell runs inside an open transaction — ANALYZE is not allowed
|
||||||
|
# inside a transaction, so we temporarily switch to AUTOCOMMIT.
|
||||||
|
print(
|
||||||
|
f" {YELLOW}⟳ Running ANALYZE on {table}...{RESET}", end=" ", flush=True
|
||||||
|
)
|
||||||
|
with env.cr._cnx.cursor() as auto_cr:
|
||||||
|
old_isolation = env.cr._cnx.isolation_level
|
||||||
|
env.cr._cnx.set_isolation_level(0) # AUTOCOMMIT
|
||||||
|
auto_cr.execute(
|
||||||
|
f"ANALYZE {table}"
|
||||||
|
) # table name validated via ORM, no injection risk
|
||||||
|
env.cr._cnx.set_isolation_level(old_isolation)
|
||||||
|
print(f"{GREEN}OK{RESET}\n")
|
||||||
|
|
||||||
|
# ── Table size ────────────────────────────────────────────────────────────────
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
pg_size_pretty(pg_total_relation_size(%s::regclass)),
|
||||||
|
reltuples::bigint
|
||||||
|
FROM pg_class WHERE relname = %s
|
||||||
|
""",
|
||||||
|
(table, table),
|
||||||
|
)
|
||||||
|
row = cr.fetchone()
|
||||||
|
if row:
|
||||||
|
size_pretty, row_count = row
|
||||||
|
print(
|
||||||
|
f" Size : {BOLD}{size_pretty}{RESET} (~{row_count:,} estimated rows)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Correlation analysis ──────────────────────────────────────────────────────
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
SELECT attname, correlation
|
||||||
|
FROM pg_stats
|
||||||
|
WHERE tablename = %s
|
||||||
|
AND attname = ANY(%s)
|
||||||
|
ORDER BY ABS(correlation) DESC NULLS LAST
|
||||||
|
""",
|
||||||
|
(table, DATE_COLUMNS),
|
||||||
|
)
|
||||||
|
stats = cr.fetchall()
|
||||||
|
|
||||||
|
print(f"{BOLD}Date columns found:{RESET}")
|
||||||
|
print("─" * 70)
|
||||||
|
print(f" {'COLUMN':<25} {'CORRELATION':<14} {'IDX SIZE':<12} RECOMMENDATION")
|
||||||
|
print("─" * 70)
|
||||||
|
|
||||||
|
if not stats:
|
||||||
|
print(f" {YELLOW}No standard date columns found on this table.{RESET}")
|
||||||
|
else:
|
||||||
|
for col, corr in stats:
|
||||||
|
if corr is None:
|
||||||
|
continue
|
||||||
|
# Size of existing BRIN index on this column, if any
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
SELECT pg_size_pretty(pg_relation_size(idx.indexrelid))
|
||||||
|
FROM pg_index idx
|
||||||
|
JOIN pg_class i ON i.oid = idx.indexrelid
|
||||||
|
JOIN pg_attribute a
|
||||||
|
ON a.attrelid = %s::regclass
|
||||||
|
AND a.attnum = ANY(idx.indkey)
|
||||||
|
AND a.attname = %s
|
||||||
|
JOIN pg_am am ON am.oid = i.relam AND am.amname = 'brin'
|
||||||
|
WHERE idx.indrelid = %s::regclass
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(table, col, table),
|
||||||
|
)
|
||||||
|
idx_row = cr.fetchone()
|
||||||
|
idx_size = idx_row[0] if idx_row else "none"
|
||||||
|
|
||||||
|
label, color = recommend(corr)
|
||||||
|
print(
|
||||||
|
f" {col:<25} {corr:<14.4f} {idx_size:<12} {color}{label}{RESET}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("─" * 70)
|
||||||
|
|
||||||
|
# ── Existing BRIN indexes ─────────────────────────────────────────────────────
|
||||||
|
print(f"\n{BOLD}Existing BRIN indexes:{RESET}")
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
SELECT indexname, indexdef
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE tablename = %s
|
||||||
|
AND indexdef LIKE '%%USING brin%%'
|
||||||
|
""",
|
||||||
|
(table,),
|
||||||
|
)
|
||||||
|
brin_indexes = cr.fetchall()
|
||||||
|
|
||||||
|
if not brin_indexes:
|
||||||
|
print(f" {YELLOW}No BRIN indexes found on this table.{RESET}")
|
||||||
|
else:
|
||||||
|
for iname, _ in brin_indexes:
|
||||||
|
print(f" {GREEN}✔{RESET} {iname}")
|
||||||
|
|
||||||
|
print()
|
||||||
286
script/shell_script_odoo/brin_cluster.py
Normal file
286
script/shell_script_odoo/brin_cluster.py
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
brin_cluster.py — Reorders table rows physically to optimize BRIN indexes.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Check correlation of candidate columns via pg_stats
|
||||||
|
2. Pick the best column (highest absolute correlation, ideally create_date)
|
||||||
|
3. Create a temporary B-tree index on that column
|
||||||
|
4. CLUSTER the table on that index (rewrites rows in physical order)
|
||||||
|
5. ANALYZE to refresh statistics
|
||||||
|
6. Drop the temporary B-tree index
|
||||||
|
7. Report the new correlation
|
||||||
|
|
||||||
|
WARNING:
|
||||||
|
- CLUSTER acquires an ACCESS EXCLUSIVE lock — the table is fully locked.
|
||||||
|
- On large tables, this can take minutes to hours.
|
||||||
|
- Run during a maintenance window or off-peak hours.
|
||||||
|
- Always pg_dump before running on production.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
export MODEL=account.move.line
|
||||||
|
cat brin_cluster.py | odoo-bin shell -d my_database --no-http
|
||||||
|
|
||||||
|
# Dry run (no changes, just shows what would be done):
|
||||||
|
export MODEL=account.move.line DRY_RUN=1
|
||||||
|
cat brin_cluster.py | odoo-bin shell -d my_database --no-http
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Columns considered as clustering candidates, in priority order.
|
||||||
|
# create_date is first: it's never updated, so correlation is always perfect.
|
||||||
|
CANDIDATE_COLUMNS = [
|
||||||
|
"create_date",
|
||||||
|
"date",
|
||||||
|
"invoice_date",
|
||||||
|
"write_date",
|
||||||
|
"scheduled_date",
|
||||||
|
"date_done",
|
||||||
|
"in_date",
|
||||||
|
]
|
||||||
|
|
||||||
|
BRIN_IDEAL_THRESHOLD = 0.9
|
||||||
|
|
||||||
|
RESET = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
CYAN = "\033[0;36m"
|
||||||
|
GREEN = "\033[0;32m"
|
||||||
|
YELLOW = "\033[1;33m"
|
||||||
|
RED = "\033[0;31m"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model_name = (
|
||||||
|
globals().get("MODEL") or os.environ.get("MODEL") or "account.move.line"
|
||||||
|
)
|
||||||
|
dry_run = bool(globals().get("DRY_RUN") or os.environ.get("DRY_RUN"))
|
||||||
|
|
||||||
|
if model_name not in env:
|
||||||
|
table = model_name.replace(".", "_")
|
||||||
|
else:
|
||||||
|
table = env[model_name]._table
|
||||||
|
|
||||||
|
cr = env.cr
|
||||||
|
cnx = env.cr._cnx
|
||||||
|
|
||||||
|
print(f"\n{BOLD}{CYAN}=== BRIN Cluster for: {model_name} ==={RESET}")
|
||||||
|
print(f" Table : {table}")
|
||||||
|
print(f" Dry run : {dry_run}\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def autocommit(sql, params=None):
|
||||||
|
"""Execute a DDL statement outside the current transaction (AUTOCOMMIT)."""
|
||||||
|
old = cnx.isolation_level
|
||||||
|
cnx.set_isolation_level(0)
|
||||||
|
with cnx.cursor() as c:
|
||||||
|
c.execute(sql, params)
|
||||||
|
cnx.set_isolation_level(old)
|
||||||
|
|
||||||
|
|
||||||
|
def get_correlations():
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
SELECT attname, correlation
|
||||||
|
FROM pg_stats
|
||||||
|
WHERE tablename = %s
|
||||||
|
AND attname = ANY(%s)
|
||||||
|
AND correlation IS NOT NULL
|
||||||
|
ORDER BY ABS(correlation) DESC
|
||||||
|
""",
|
||||||
|
(table, CANDIDATE_COLUMNS),
|
||||||
|
)
|
||||||
|
return {row[0]: row[1] for row in cr.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def table_size():
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
pg_size_pretty(pg_total_relation_size(%s::regclass)),
|
||||||
|
reltuples::bigint
|
||||||
|
FROM pg_class WHERE relname = %s
|
||||||
|
""",
|
||||||
|
(table, table),
|
||||||
|
)
|
||||||
|
return cr.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(index_name):
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM pg_indexes
|
||||||
|
WHERE tablename = %s AND indexname = %s
|
||||||
|
""",
|
||||||
|
(table, index_name),
|
||||||
|
)
|
||||||
|
return cr.fetchone()[0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Step 1: ANALYZE to get fresh stats ───────────────────────────────────────
|
||||||
|
|
||||||
|
print(f" {YELLOW}⟳ ANALYZE {table}...{RESET}", end=" ", flush=True)
|
||||||
|
autocommit(f"ANALYZE {table}")
|
||||||
|
print(f"{GREEN}OK{RESET}\n")
|
||||||
|
|
||||||
|
size_pretty, row_count = table_size()
|
||||||
|
print(
|
||||||
|
f" Size : {BOLD}{size_pretty}{RESET} (~{row_count:,} estimated rows)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Step 2: Check current correlations ───────────────────────────────────────
|
||||||
|
|
||||||
|
correlations = get_correlations()
|
||||||
|
|
||||||
|
if not correlations:
|
||||||
|
print(f"{RED}❌ No date columns found on {table}. Aborting.{RESET}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print(f"{BOLD}Current correlations:{RESET}")
|
||||||
|
for col, corr in sorted(correlations.items(), key=lambda x: -abs(x[1])):
|
||||||
|
if abs(corr) >= BRIN_IDEAL_THRESHOLD:
|
||||||
|
icon = f"{GREEN}✅ BRIN ideal{RESET}"
|
||||||
|
else:
|
||||||
|
icon = f"{YELLOW}⚠️ needs clustering{RESET}"
|
||||||
|
print(f" {col:<25} {corr:+.4f} {icon}")
|
||||||
|
|
||||||
|
# ── Step 3: Pick the best clustering column ───────────────────────────────────
|
||||||
|
|
||||||
|
# Only cluster if at least one *candidate* column is suboptimal.
|
||||||
|
# Note: a column like `date` (user-editable) may never reach ideal correlation
|
||||||
|
# regardless of clustering — that's expected and not a reason to cluster.
|
||||||
|
suboptimal = {
|
||||||
|
col: corr
|
||||||
|
for col, corr in correlations.items()
|
||||||
|
if abs(corr) < BRIN_IDEAL_THRESHOLD
|
||||||
|
}
|
||||||
|
ideal = {
|
||||||
|
col: corr
|
||||||
|
for col, corr in correlations.items()
|
||||||
|
if abs(corr) >= BRIN_IDEAL_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
if suboptimal:
|
||||||
|
print(f"\n {YELLOW}⚠️ Suboptimal columns: {', '.join(suboptimal)}{RESET}")
|
||||||
|
print(
|
||||||
|
f" {CYAN}Note: columns like 'date' or 'invoice_date' may stay suboptimal"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" after clustering if users enter backdated records — that's expected.{RESET}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"\n{GREEN}✅ All columns already have ideal correlation. No clustering needed.{RESET}\n"
|
||||||
|
)
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
# Pick the best clustering column: highest absolute correlation among candidates.
|
||||||
|
# Prefer create_date first (never updated → guaranteed monotonic order),
|
||||||
|
# then fall back to the column with highest abs(correlation).
|
||||||
|
cluster_col = None
|
||||||
|
for preferred in CANDIDATE_COLUMNS:
|
||||||
|
if preferred in ideal: # already ideal → good anchor for physical order
|
||||||
|
cluster_col = preferred
|
||||||
|
break
|
||||||
|
if not cluster_col:
|
||||||
|
# All candidates are suboptimal — pick the least bad one
|
||||||
|
cluster_col = max(suboptimal, key=lambda c: abs(suboptimal[c]))
|
||||||
|
|
||||||
|
if not cluster_col:
|
||||||
|
print(f"{RED}❌ No suitable clustering column found. Aborting.{RESET}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\n {BOLD}Clustering column chosen:{RESET} {cluster_col} "
|
||||||
|
f"(current correlation: {correlations[cluster_col]:+.4f})\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Step 4: Create temporary B-tree index ────────────────────────────────────
|
||||||
|
|
||||||
|
tmp_index = f"_brin_cluster_tmp_{table}_{cluster_col}"
|
||||||
|
|
||||||
|
if index_exists(tmp_index):
|
||||||
|
print(
|
||||||
|
f" {YELLOW}⚠️ Temporary index already exists, dropping it first...{RESET}"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
autocommit(f'DROP INDEX IF EXISTS "{tmp_index}"')
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" {YELLOW}⟳ Creating temporary B-tree index on {cluster_col}...{RESET}",
|
||||||
|
end=" ",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
autocommit(f'CREATE INDEX "{tmp_index}" ON "{table}" ("{cluster_col}")')
|
||||||
|
print(f"{GREEN}OK{RESET}")
|
||||||
|
else:
|
||||||
|
print(f"{CYAN}[DRY RUN]{RESET}")
|
||||||
|
|
||||||
|
# ── Step 5: CLUSTER ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print(f"\n {YELLOW}⟳ CLUSTER {table} on {cluster_col}...{RESET}")
|
||||||
|
print(
|
||||||
|
f" {YELLOW} (ACCESS EXCLUSIVE lock — table unavailable during this operation){RESET}",
|
||||||
|
end=" ",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
t0 = time.time()
|
||||||
|
autocommit(f'CLUSTER "{table}" USING "{tmp_index}"')
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
print(f"{GREEN}OK{RESET} ({elapsed:.1f}s)")
|
||||||
|
else:
|
||||||
|
print(f"{CYAN}[DRY RUN]{RESET}")
|
||||||
|
|
||||||
|
# ── Step 6: Drop temporary index ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\n {YELLOW}⟳ Dropping temporary index...{RESET}", end=" ", flush=True
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
autocommit(f'DROP INDEX IF EXISTS "{tmp_index}"')
|
||||||
|
print(f"{GREEN}OK{RESET}")
|
||||||
|
else:
|
||||||
|
print(f"{CYAN}[DRY RUN]{RESET}")
|
||||||
|
|
||||||
|
# ── Step 7: ANALYZE again + report new correlations ──────────────────────────
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\n {YELLOW}⟳ ANALYZE after clustering...{RESET}", end=" ", flush=True
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
autocommit(f"ANALYZE {table}")
|
||||||
|
print(f"{GREEN}OK{RESET}\n")
|
||||||
|
|
||||||
|
new_correlations = get_correlations()
|
||||||
|
|
||||||
|
print(f"{BOLD}Correlations after clustering:{RESET}")
|
||||||
|
print("─" * 55)
|
||||||
|
print(f" {'COLUMN':<25} {'BEFORE':>8} {'AFTER':>8} RESULT")
|
||||||
|
print("─" * 55)
|
||||||
|
for col in sorted(correlations.keys()):
|
||||||
|
before = correlations.get(col, 0.0)
|
||||||
|
after = new_correlations.get(col, 0.0)
|
||||||
|
delta = after - before
|
||||||
|
if abs(after) >= BRIN_IDEAL_THRESHOLD:
|
||||||
|
result = f"{GREEN}✅ BRIN ideal{RESET}"
|
||||||
|
else:
|
||||||
|
result = f"{YELLOW}⚠️ still suboptimal{RESET}"
|
||||||
|
arrow = "↑" if delta > 0.01 else ("↓" if delta < -0.01 else "→")
|
||||||
|
print(
|
||||||
|
f" {col:<25} {before:>+8.4f} {after:>+8.4f} {arrow} {result}"
|
||||||
|
)
|
||||||
|
print("─" * 55)
|
||||||
|
else:
|
||||||
|
print(f"{CYAN}[DRY RUN]{RESET}")
|
||||||
|
|
||||||
|
print(f"\n{GREEN}✅ Done.{RESET}\n")
|
||||||
|
|
@ -54,6 +54,70 @@ class DatabaseManager:
|
||||||
else:
|
else:
|
||||||
print(t("cmd_not_found"))
|
print(t("cmd_not_found"))
|
||||||
|
|
||||||
|
def _confirm_drop(self, message: str) -> bool:
|
||||||
|
"""Ask for an explicit 'oui'/'yes' confirmation, default is no."""
|
||||||
|
print(f"⚠️ {message}")
|
||||||
|
answer = (
|
||||||
|
input(t("Type 'oui' to confirm (default: no): ")).strip().lower()
|
||||||
|
)
|
||||||
|
return answer in ("oui", "yes")
|
||||||
|
|
||||||
|
def drop_database(self) -> None:
|
||||||
|
print(f"⚠️ {t('Erase a database — irreversible operation!')}")
|
||||||
|
choices = [
|
||||||
|
{
|
||||||
|
"prompt_description": t(
|
||||||
|
"Erase ALL databases (make db_drop_all)"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{"prompt_description": t("Erase a single database")},
|
||||||
|
]
|
||||||
|
help_info = self._fill_help_info(choices)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
status = click.prompt(help_info)
|
||||||
|
print()
|
||||||
|
if status == "0":
|
||||||
|
return
|
||||||
|
elif status == "1":
|
||||||
|
self._drop_all_databases()
|
||||||
|
return
|
||||||
|
elif status == "2":
|
||||||
|
self._drop_single_database()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print(t("Command not found !"))
|
||||||
|
|
||||||
|
def _drop_all_databases(self) -> None:
|
||||||
|
if not self._confirm_drop(
|
||||||
|
t("You are about to erase ALL databases. This cannot be undone.")
|
||||||
|
):
|
||||||
|
print(t("Database deletion cancelled."))
|
||||||
|
return
|
||||||
|
self._execute.exec_command_live(
|
||||||
|
"make db_drop_all",
|
||||||
|
source_erplibre=False,
|
||||||
|
single_source_erplibre=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _drop_single_database(self) -> None:
|
||||||
|
database_name = self.select_database()
|
||||||
|
if not database_name:
|
||||||
|
print(t("No database selected."))
|
||||||
|
return
|
||||||
|
message = t(
|
||||||
|
"You are about to erase the database '{database}'."
|
||||||
|
" This cannot be undone."
|
||||||
|
).format(database=database_name)
|
||||||
|
if not self._confirm_drop(message):
|
||||||
|
print(t("Database deletion cancelled."))
|
||||||
|
return
|
||||||
|
self._execute.exec_command_live(
|
||||||
|
f"./odoo_bin.sh db --drop --database {database_name}",
|
||||||
|
source_erplibre=False,
|
||||||
|
single_source_erplibre=True,
|
||||||
|
)
|
||||||
|
|
||||||
def restore_from_database(self, show_remote_list: bool = True) -> None:
|
def restore_from_database(self, show_remote_list: bool = True) -> None:
|
||||||
path_image_db = os.path.join(os.getcwd(), "image_db")
|
path_image_db = os.path.join(os.getcwd(), "image_db")
|
||||||
print("[1] By filename from image_db")
|
print("[1] By filename from image_db")
|
||||||
|
|
|
||||||
|
|
@ -651,6 +651,22 @@ class TODO:
|
||||||
choices = [
|
choices = [
|
||||||
{"prompt_description": t("Clone ERPLibre locally (git clone)")},
|
{"prompt_description": t("Clone ERPLibre locally (git clone)")},
|
||||||
{"prompt_description": t("Configure sshfs")},
|
{"prompt_description": t("Configure sshfs")},
|
||||||
|
{"prompt_description": t("SSH - Check connection")},
|
||||||
|
{"prompt_description": t("SSH - Sync files (rsync)")},
|
||||||
|
{"prompt_description": t("SSH - Install ERPLibre")},
|
||||||
|
{"prompt_description": t("SSH - Start Odoo")},
|
||||||
|
{"prompt_description": t("SSH - Stop Odoo")},
|
||||||
|
{"prompt_description": t("SSH - Restart Odoo")},
|
||||||
|
{"prompt_description": t("SSH - Service status")},
|
||||||
|
{"prompt_description": t("SSH - View logs")},
|
||||||
|
{"prompt_description": t("SSH - Run make target")},
|
||||||
|
{"prompt_description": t("SSH - Install systemd service")},
|
||||||
|
{"prompt_description": t("SSH - Configure nginx + SSL")},
|
||||||
|
{
|
||||||
|
"prompt_description": t(
|
||||||
|
"Deploy - Install NTFY notification server"
|
||||||
|
)
|
||||||
|
},
|
||||||
]
|
]
|
||||||
help_info = self.fill_help_info(choices)
|
help_info = self.fill_help_info(choices)
|
||||||
|
|
||||||
|
|
@ -663,6 +679,30 @@ class TODO:
|
||||||
self._deploy_clone_erplibre()
|
self._deploy_clone_erplibre()
|
||||||
elif status == "2":
|
elif status == "2":
|
||||||
self._configure_sshfs()
|
self._configure_sshfs()
|
||||||
|
elif status == "3":
|
||||||
|
self._deploy_ssh_check()
|
||||||
|
elif status == "4":
|
||||||
|
self._deploy_ssh_push()
|
||||||
|
elif status == "5":
|
||||||
|
self._deploy_ssh_install()
|
||||||
|
elif status == "6":
|
||||||
|
self._deploy_ssh_run()
|
||||||
|
elif status == "7":
|
||||||
|
self._deploy_ssh_stop()
|
||||||
|
elif status == "8":
|
||||||
|
self._deploy_ssh_restart()
|
||||||
|
elif status == "9":
|
||||||
|
self._deploy_ssh_status()
|
||||||
|
elif status == "10":
|
||||||
|
self._deploy_ssh_logs()
|
||||||
|
elif status == "11":
|
||||||
|
self._deploy_ssh_make()
|
||||||
|
elif status == "12":
|
||||||
|
self._deploy_ssh_install_systemd()
|
||||||
|
elif status == "13":
|
||||||
|
self._deploy_ssh_install_nginx()
|
||||||
|
elif status == "14":
|
||||||
|
self._deploy_ntfy_server()
|
||||||
else:
|
else:
|
||||||
print(t("Command not found !"))
|
print(t("Command not found !"))
|
||||||
|
|
||||||
|
|
@ -689,6 +729,54 @@ class TODO:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"{t('Error cloning ERPLibre: ')}{e}")
|
print(f"{t('Error cloning ERPLibre: ')}{e}")
|
||||||
|
|
||||||
|
def _deploy_ntfy_server(self):
|
||||||
|
print(
|
||||||
|
f"\n{t('Deploy a local NTFY push notification server (Ubuntu/Arch)')}"
|
||||||
|
)
|
||||||
|
port = (
|
||||||
|
input(t("NTFY server port (default: 8080): ")).strip() or "8080"
|
||||||
|
)
|
||||||
|
import socket
|
||||||
|
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
try:
|
||||||
|
local_ip = socket.gethostbyname(hostname)
|
||||||
|
except Exception:
|
||||||
|
local_ip = "127.0.0.1"
|
||||||
|
|
||||||
|
default_url = f"http://{local_ip}:{port}"
|
||||||
|
base_url = (
|
||||||
|
input(
|
||||||
|
f"{t('NTFY base URL')} (default: {default_url}): "
|
||||||
|
).strip()
|
||||||
|
or default_url
|
||||||
|
)
|
||||||
|
|
||||||
|
script_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"..",
|
||||||
|
"install",
|
||||||
|
"install_ntfy.sh",
|
||||||
|
)
|
||||||
|
script_path = os.path.realpath(script_path)
|
||||||
|
|
||||||
|
if not os.path.isfile(script_path):
|
||||||
|
print(f"{t('NTFY install script not found: ')}{script_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{t('Installing NTFY server (requires sudo)...')}")
|
||||||
|
cmd = (
|
||||||
|
f"sudo NTFY_PORT={port}"
|
||||||
|
f" NTFY_BASE_URL={base_url}"
|
||||||
|
f" bash {script_path}"
|
||||||
|
)
|
||||||
|
print(f"{t('Will execute:')} {cmd}\n")
|
||||||
|
try:
|
||||||
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||||
|
print(f"\n{t('NTFY server installed and started successfully!')}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{t('Error installing NTFY server: ')}{e}")
|
||||||
|
|
||||||
def _configure_sshfs(self):
|
def _configure_sshfs(self):
|
||||||
import getpass
|
import getpass
|
||||||
import re
|
import re
|
||||||
|
|
@ -793,6 +881,171 @@ class TODO:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"{t('Error mounting sshfs: ')}{e}")
|
print(f"{t('Error mounting sshfs: ')}{e}")
|
||||||
|
|
||||||
|
def _get_ssh_params(self):
|
||||||
|
"""Prompt for SSH connection parameters. Returns dict or None on cancel."""
|
||||||
|
host = click.prompt(t("Remote host (user@hostname or hostname): ")).strip()
|
||||||
|
if not host:
|
||||||
|
print(t("SSH host is required!"))
|
||||||
|
return None
|
||||||
|
user = (
|
||||||
|
click.prompt(t("SSH user (default: erplibre): ")).strip()
|
||||||
|
or "erplibre"
|
||||||
|
)
|
||||||
|
port = click.prompt(t("SSH port (default: 22): ")).strip() or "22"
|
||||||
|
key = (
|
||||||
|
click.prompt(
|
||||||
|
t("SSH key path (default: ~/.ssh/id_rsa, empty for none): ")
|
||||||
|
).strip()
|
||||||
|
)
|
||||||
|
path = (
|
||||||
|
click.prompt(t("Remote path (default: ~/erplibre_deploy_2): ")).strip()
|
||||||
|
or "~/erplibre_deploy_2"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"SSH_HOST": host,
|
||||||
|
"SSH_USER": user,
|
||||||
|
"SSH_PORT": port,
|
||||||
|
"SSH_KEY": key,
|
||||||
|
"SSH_PATH": path,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_ssh_make_cmd(self, target, params, extra=None):
|
||||||
|
"""Build a make SSH command string from params dict."""
|
||||||
|
parts = [f"make {target}"]
|
||||||
|
for k, v in params.items():
|
||||||
|
if v:
|
||||||
|
parts.append(f'{k}="{v}"')
|
||||||
|
if extra:
|
||||||
|
for k, v in extra.items():
|
||||||
|
if v:
|
||||||
|
parts.append(f'{k}="{v}"')
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
def _deploy_ssh_check(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
cmd = self._build_ssh_make_cmd("ssh_check", params)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deploy_ssh_push(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
cmd = self._build_ssh_make_cmd("ssh_push", params)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deploy_ssh_install(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
cmd = self._build_ssh_make_cmd("ssh_install", params)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deploy_ssh_run(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
cmd = self._build_ssh_make_cmd("ssh_run", params)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deploy_ssh_stop(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
cmd = self._build_ssh_make_cmd("ssh_stop", params)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deploy_ssh_restart(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
cmd = self._build_ssh_make_cmd("ssh_restart", params)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deploy_ssh_status(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
cmd = self._build_ssh_make_cmd("ssh_status", params)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deploy_ssh_logs(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
cmd = self._build_ssh_make_cmd("ssh_logs", params)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deploy_ssh_make(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
target = click.prompt(t("Make target to run remotely: ")).strip()
|
||||||
|
if not target:
|
||||||
|
print(t("SSH host is required!"))
|
||||||
|
return
|
||||||
|
cmd = self._build_ssh_make_cmd(
|
||||||
|
"ssh_make", params, extra={"SSH_TARGET": target}
|
||||||
|
)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deploy_ssh_install_systemd(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
cmd = self._build_ssh_make_cmd("ssh_install_systemd", params)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deploy_ssh_install_nginx(self):
|
||||||
|
params = self._get_ssh_params()
|
||||||
|
if not params:
|
||||||
|
return
|
||||||
|
domain = click.prompt(t("Domain name (e.g.: example.com): ")).strip()
|
||||||
|
if not domain:
|
||||||
|
print(t("SSH host is required!"))
|
||||||
|
return
|
||||||
|
email = click.prompt(t("Admin email for SSL certificate: ")).strip()
|
||||||
|
cmd = self._build_ssh_make_cmd(
|
||||||
|
"ssh_install_nginx",
|
||||||
|
params,
|
||||||
|
extra={"SSH_DOMAIN": domain, "SSH_ADMIN_EMAIL": email},
|
||||||
|
)
|
||||||
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, single_source_erplibre=True
|
||||||
|
)
|
||||||
|
|
||||||
def prompt_execute_code(self):
|
def prompt_execute_code(self):
|
||||||
print(f"🤖 {t('What do you need for development?')}")
|
print(f"🤖 {t('What do you need for development?')}")
|
||||||
# help_info = """Commande :
|
# help_info = """Commande :
|
||||||
|
|
@ -1220,6 +1473,7 @@ class TODO:
|
||||||
},
|
},
|
||||||
{"prompt_description": t("Restore from backup (.zip)")},
|
{"prompt_description": t("Restore from backup (.zip)")},
|
||||||
{"prompt_description": t("Create backup (.zip)")},
|
{"prompt_description": t("Create backup (.zip)")},
|
||||||
|
{"prompt_description": t("Erase a database")},
|
||||||
]
|
]
|
||||||
help_info = self.fill_help_info(choices)
|
help_info = self.fill_help_info(choices)
|
||||||
|
|
||||||
|
|
@ -1234,6 +1488,8 @@ class TODO:
|
||||||
self.db_manager.restore_from_database()
|
self.db_manager.restore_from_database()
|
||||||
elif status == "3":
|
elif status == "3":
|
||||||
self.db_manager.create_backup_from_database()
|
self.db_manager.create_backup_from_database()
|
||||||
|
elif status == "4":
|
||||||
|
self.db_manager.drop_database()
|
||||||
else:
|
else:
|
||||||
print(t("Command not found !"))
|
print(t("Command not found !"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,52 @@ TRANSLATIONS = {
|
||||||
"fr": "Faites des modifications sur les bases de données!",
|
"fr": "Faites des modifications sur les bases de données!",
|
||||||
"en": "Make changes to databases!",
|
"en": "Make changes to databases!",
|
||||||
},
|
},
|
||||||
|
# Database drop / erase
|
||||||
|
"Erase a database": {
|
||||||
|
"fr": "Effacer une base de données",
|
||||||
|
"en": "Erase a database",
|
||||||
|
},
|
||||||
|
"Erase a database — irreversible operation!": {
|
||||||
|
"fr": "Effacer une base de données — opération irréversible !",
|
||||||
|
"en": "Erase a database — irreversible operation!",
|
||||||
|
},
|
||||||
|
"Erase ALL databases (make db_drop_all)": {
|
||||||
|
"fr": "Effacer TOUTES les bases de données (make db_drop_all)",
|
||||||
|
"en": "Erase ALL databases (make db_drop_all)",
|
||||||
|
},
|
||||||
|
"Erase a single database": {
|
||||||
|
"fr": "Effacer une seule base de données",
|
||||||
|
"en": "Erase a single database",
|
||||||
|
},
|
||||||
|
"You are about to erase ALL databases. This cannot be undone.": {
|
||||||
|
"fr": (
|
||||||
|
"Tu es sur le point d'effacer TOUTES les bases de données."
|
||||||
|
" C'est irréversible."
|
||||||
|
),
|
||||||
|
"en": "You are about to erase ALL databases. This cannot be undone.",
|
||||||
|
},
|
||||||
|
"You are about to erase the database '{database}'. This cannot be undone.": {
|
||||||
|
"fr": (
|
||||||
|
"Tu es sur le point d'effacer la base de données « {database} »."
|
||||||
|
" C'est irréversible."
|
||||||
|
),
|
||||||
|
"en": (
|
||||||
|
"You are about to erase the database '{database}'."
|
||||||
|
" This cannot be undone."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"Type 'oui' to confirm (default: no): ": {
|
||||||
|
"fr": "Tape « oui » pour confirmer (défaut : non) : ",
|
||||||
|
"en": "Type 'oui'/'yes' to confirm (default: no): ",
|
||||||
|
},
|
||||||
|
"Database deletion cancelled.": {
|
||||||
|
"fr": "Effacement de la base de données annulé.",
|
||||||
|
"en": "Database deletion cancelled.",
|
||||||
|
},
|
||||||
|
"No database selected.": {
|
||||||
|
"fr": "Aucune base de données sélectionnée.",
|
||||||
|
"en": "No database selected.",
|
||||||
|
},
|
||||||
"Manage execution processes!": {
|
"Manage execution processes!": {
|
||||||
"fr": "Manipuler les processus d'exécution!",
|
"fr": "Manipuler les processus d'exécution!",
|
||||||
"en": "Manage execution processes!",
|
"en": "Manage execution processes!",
|
||||||
|
|
@ -722,6 +768,95 @@ TRANSLATIONS = {
|
||||||
"fr": "Erreur lors de l'ajout de l'automatisation : ",
|
"fr": "Erreur lors de l'ajout de l'automatisation : ",
|
||||||
"en": "Error adding automation: ",
|
"en": "Error adding automation: ",
|
||||||
},
|
},
|
||||||
|
# SSH Deploy section
|
||||||
|
"Deploy - Deploy ERPLibre via SSH": {
|
||||||
|
"fr": "Déploiement - Déployer ERPLibre via SSH",
|
||||||
|
"en": "Deploy - Deploy ERPLibre via SSH",
|
||||||
|
},
|
||||||
|
"SSH deployment tools!": {
|
||||||
|
"fr": "Outils de déploiement SSH!",
|
||||||
|
"en": "SSH deployment tools!",
|
||||||
|
},
|
||||||
|
"SSH - Check connection": {
|
||||||
|
"fr": "SSH - Vérifier la connexion",
|
||||||
|
"en": "SSH - Check connection",
|
||||||
|
},
|
||||||
|
"SSH - Sync files (rsync)": {
|
||||||
|
"fr": "SSH - Synchroniser les fichiers (rsync)",
|
||||||
|
"en": "SSH - Sync files (rsync)",
|
||||||
|
},
|
||||||
|
"SSH - Install ERPLibre": {
|
||||||
|
"fr": "SSH - Installer ERPLibre",
|
||||||
|
"en": "SSH - Install ERPLibre",
|
||||||
|
},
|
||||||
|
"SSH - Start Odoo": {
|
||||||
|
"fr": "SSH - Démarrer Odoo",
|
||||||
|
"en": "SSH - Start Odoo",
|
||||||
|
},
|
||||||
|
"SSH - Stop Odoo": {
|
||||||
|
"fr": "SSH - Arrêter Odoo",
|
||||||
|
"en": "SSH - Stop Odoo",
|
||||||
|
},
|
||||||
|
"SSH - Restart Odoo": {
|
||||||
|
"fr": "SSH - Redémarrer Odoo",
|
||||||
|
"en": "SSH - Restart Odoo",
|
||||||
|
},
|
||||||
|
"SSH - Service status": {
|
||||||
|
"fr": "SSH - Statut du service",
|
||||||
|
"en": "SSH - Service status",
|
||||||
|
},
|
||||||
|
"SSH - View logs": {
|
||||||
|
"fr": "SSH - Voir les logs",
|
||||||
|
"en": "SSH - View logs",
|
||||||
|
},
|
||||||
|
"SSH - Run make target": {
|
||||||
|
"fr": "SSH - Exécuter une cible make",
|
||||||
|
"en": "SSH - Run make target",
|
||||||
|
},
|
||||||
|
"SSH - Install systemd service": {
|
||||||
|
"fr": "SSH - Installer le service systemd",
|
||||||
|
"en": "SSH - Install systemd service",
|
||||||
|
},
|
||||||
|
"SSH - Configure nginx + SSL": {
|
||||||
|
"fr": "SSH - Configurer nginx + SSL",
|
||||||
|
"en": "SSH - Configure nginx + SSL",
|
||||||
|
},
|
||||||
|
"Remote host (user@hostname or hostname): ": {
|
||||||
|
"fr": "Hôte distant (user@hostname ou hostname) : ",
|
||||||
|
"en": "Remote host (user@hostname or hostname): ",
|
||||||
|
},
|
||||||
|
"SSH user (default: erplibre): ": {
|
||||||
|
"fr": "Utilisateur SSH (défaut: erplibre) : ",
|
||||||
|
"en": "SSH user (default: erplibre): ",
|
||||||
|
},
|
||||||
|
"SSH port (default: 22): ": {
|
||||||
|
"fr": "Port SSH (défaut: 22) : ",
|
||||||
|
"en": "SSH port (default: 22): ",
|
||||||
|
},
|
||||||
|
"SSH key path (default: ~/.ssh/id_rsa, empty for none): ": {
|
||||||
|
"fr": "Chemin de la clé SSH (défaut: ~/.ssh/id_rsa, vide pour aucune) : ",
|
||||||
|
"en": "SSH key path (default: ~/.ssh/id_rsa, empty for none): ",
|
||||||
|
},
|
||||||
|
"Remote path (default: ~/erplibre_deploy_2): ": {
|
||||||
|
"fr": "Chemin distant (défaut: ~/erplibre_deploy_2) : ",
|
||||||
|
"en": "Remote path (default: ~/erplibre_deploy_2): ",
|
||||||
|
},
|
||||||
|
"Make target to run remotely: ": {
|
||||||
|
"fr": "Cible make à exécuter à distance : ",
|
||||||
|
"en": "Make target to run remotely: ",
|
||||||
|
},
|
||||||
|
"Domain name (e.g.: example.com): ": {
|
||||||
|
"fr": "Nom de domaine (ex: example.com) : ",
|
||||||
|
"en": "Domain name (e.g.: example.com): ",
|
||||||
|
},
|
||||||
|
"Admin email for SSL certificate: ": {
|
||||||
|
"fr": "Email administrateur pour le certificat SSL : ",
|
||||||
|
"en": "Admin email for SSL certificate: ",
|
||||||
|
},
|
||||||
|
"SSH host is required!": {
|
||||||
|
"fr": "L'hôte SSH est requis!",
|
||||||
|
"en": "SSH host is required!",
|
||||||
|
},
|
||||||
# Language selection
|
# Language selection
|
||||||
"Choose language / Choisir la langue": {
|
"Choose language / Choisir la langue": {
|
||||||
"fr": "Choisir la langue / Choose language",
|
"fr": "Choisir la langue / Choose language",
|
||||||
|
|
@ -816,6 +951,39 @@ TRANSLATIONS = {
|
||||||
"fr": "Erreur lors de la création du fichier : ",
|
"fr": "Erreur lors de la création du fichier : ",
|
||||||
"en": "Error creating file: ",
|
"en": "Error creating file: ",
|
||||||
},
|
},
|
||||||
|
# NTFY section
|
||||||
|
"Deploy - Install NTFY notification server": {
|
||||||
|
"fr": "Déployer - Installer le serveur de notifications NTFY",
|
||||||
|
"en": "Deploy - Install NTFY notification server",
|
||||||
|
},
|
||||||
|
"Deploy a local NTFY push notification server (Ubuntu/Arch)": {
|
||||||
|
"fr": "Déployer un serveur local de notifications NTFY (Ubuntu/Arch)",
|
||||||
|
"en": "Deploy a local NTFY push notification server (Ubuntu/Arch)",
|
||||||
|
},
|
||||||
|
"NTFY server port (default: 8080): ": {
|
||||||
|
"fr": "Port du serveur NTFY (défaut : 8080) : ",
|
||||||
|
"en": "NTFY server port (default: 8080): ",
|
||||||
|
},
|
||||||
|
"NTFY base URL": {
|
||||||
|
"fr": "URL de base NTFY",
|
||||||
|
"en": "NTFY base URL",
|
||||||
|
},
|
||||||
|
"Installing NTFY server (requires sudo)...": {
|
||||||
|
"fr": "Installation du serveur NTFY (sudo requis)...",
|
||||||
|
"en": "Installing NTFY server (requires sudo)...",
|
||||||
|
},
|
||||||
|
"NTFY server installed and started successfully!": {
|
||||||
|
"fr": "Serveur NTFY installé et démarré avec succès!",
|
||||||
|
"en": "NTFY server installed and started successfully!",
|
||||||
|
},
|
||||||
|
"Error installing NTFY server: ": {
|
||||||
|
"fr": "Erreur lors de l'installation du serveur NTFY : ",
|
||||||
|
"en": "Error installing NTFY server: ",
|
||||||
|
},
|
||||||
|
"NTFY install script not found: ": {
|
||||||
|
"fr": "Script d'installation NTFY introuvable : ",
|
||||||
|
"en": "NTFY install script not found: ",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue