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:
Mathieu Benoit 2026-08-07 02:53:24 -04:00
commit 54730243d5
8 changed files with 1399 additions and 0 deletions

View file

@ -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

130
conf/make.ssh.Makefile Normal file
View 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
View 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 "$@"

View 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()

View 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")

View file

@ -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")

View file

@ -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 !"))

View file

@ -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: ",
},
}