diff --git a/Makefile b/Makefile index fc9920f..465190a 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,7 @@ endif -include ./conf/make.installation.Makefile -include ./conf/make.installation.poetry.Makefile -include ./conf/make.test.Makefile +-include ./conf/make.ssh.Makefile -include ./conf/make.todo.Makefile # Include private Makefile diff --git a/conf/make.ssh.Makefile b/conf/make.ssh.Makefile new file mode 100644 index 0000000..c899fe9 --- /dev/null +++ b/conf/make.ssh.Makefile @@ -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),)" diff --git a/script/install/install_ntfy.sh b/script/install/install_ntfy.sh new file mode 100755 index 0000000..4cac17b --- /dev/null +++ b/script/install/install_ntfy.sh @@ -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" <" 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 "$@" diff --git a/script/shell_script_odoo/brin_advisor.py b/script/shell_script_odoo/brin_advisor.py new file mode 100644 index 0000000..2f27518 --- /dev/null +++ b/script/shell_script_odoo/brin_advisor.py @@ -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() diff --git a/script/shell_script_odoo/brin_cluster.py b/script/shell_script_odoo/brin_cluster.py new file mode 100644 index 0000000..1bcbc57 --- /dev/null +++ b/script/shell_script_odoo/brin_cluster.py @@ -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") diff --git a/script/todo/database_manager.py b/script/todo/database_manager.py index 617a8e6..798a30e 100644 --- a/script/todo/database_manager.py +++ b/script/todo/database_manager.py @@ -54,6 +54,70 @@ class DatabaseManager: else: 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: path_image_db = os.path.join(os.getcwd(), "image_db") print("[1] By filename from image_db") diff --git a/script/todo/todo.py b/script/todo/todo.py index 53cccd2..cedf8bf 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -651,6 +651,22 @@ class TODO: choices = [ {"prompt_description": t("Clone ERPLibre locally (git clone)")}, {"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) @@ -663,6 +679,30 @@ class TODO: self._deploy_clone_erplibre() elif status == "2": 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: print(t("Command not found !")) @@ -689,6 +729,54 @@ class TODO: except Exception as 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): import getpass import re @@ -793,6 +881,171 @@ class TODO: except Exception as 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): print(f"🤖 {t('What do you need for development?')}") # help_info = """Commande : @@ -1220,6 +1473,7 @@ class TODO: }, {"prompt_description": t("Restore from backup (.zip)")}, {"prompt_description": t("Create backup (.zip)")}, + {"prompt_description": t("Erase a database")}, ] help_info = self.fill_help_info(choices) @@ -1234,6 +1488,8 @@ class TODO: self.db_manager.restore_from_database() elif status == "3": self.db_manager.create_backup_from_database() + elif status == "4": + self.db_manager.drop_database() else: print(t("Command not found !")) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6867282..fe50a7b 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -299,6 +299,52 @@ TRANSLATIONS = { "fr": "Faites des modifications sur les bases de données!", "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!": { "fr": "Manipuler les processus d'exécution!", "en": "Manage execution processes!", @@ -722,6 +768,95 @@ TRANSLATIONS = { "fr": "Erreur lors de l'ajout de l'automatisation : ", "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 "Choose language / Choisir la langue": { "fr": "Choisir la langue / Choose language", @@ -816,6 +951,39 @@ TRANSLATIONS = { "fr": "Erreur lors de la création du fichier : ", "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: ", + }, }