Initial commit: Aquarium Logbuch React/Vite App

- React 18 + Vite Frontend
- Node.js/Express Backend
- Vollstaendige Logbuch-Funktionalitaet fuer Aquarien
- Deploy-Script fuer aqualog CT 211 (192.168.0.246)
This commit is contained in:
2026-04-15 09:24:31 +02:00
commit a1f6a21828
11 changed files with 4614 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.local

14
backend/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "aquarium-backend",
"version": "1.0.0",
"description": "Aquarium Logbuch API Backend",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2"
}
}

226
backend/server.js Normal file
View File

@@ -0,0 +1,226 @@
"use strict";
const express = require("express");
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const app = express();
const PORT = 3001;
const DATA_FILE = "/var/lib/aquarium/data.json";
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(48).toString("hex");
const SALT_ROUNDS = 12;
app.use(express.json());
// ─── Data helpers ──────────────────────────────────────────────────────────
function loadData() {
try {
if (fs.existsSync(DATA_FILE)) return JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
} catch {}
return { users: [], aquariums: [], entries: [], reminders: [] };
}
function saveData(d) {
const dir = path.dirname(DATA_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify(d, null, 2));
}
// ─── Middleware ────────────────────────────────────────────────────────────
function auth(req, res, next) {
const h = req.headers.authorization;
if (!h || !h.startsWith("Bearer ")) return res.status(401).json({ error: "Nicht angemeldet" });
try {
req.user = jwt.verify(h.slice(7), JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "Sitzung abgelaufen" });
}
}
function admin(req, res, next) {
if (req.user.role !== "admin") return res.status(403).json({ error: "Admin-Rechte erforderlich" });
next();
}
function ownsAquarium(d, aqId, user) {
const aq = d.aquariums.find(a => a.id === aqId);
if (!aq) return null;
if (aq.userId === user.id || user.role === "admin") return aq;
return null;
}
// ─── Init admin ────────────────────────────────────────────────────────────
function initAdmin() {
const d = loadData();
if (!d.users.find(u => u.username === "aqlab")) {
d.users.push({
id: "admin-1",
username: "aqlab",
passwordHash: bcrypt.hashSync("D4sP4sswortBek0mmtKe1ner!", SALT_ROUNDS),
role: "admin",
displayName: "Administrator",
createdAt: new Date().toISOString(),
});
saveData(d);
console.log("[aquarium] Admin-Benutzer 'aqlab' angelegt.");
}
}
// ─── Auth routes ───────────────────────────────────────────────────────────
app.post("/api/auth/login", (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: "Benutzername und Passwort erforderlich" });
const d = loadData();
const u = d.users.find(x => x.username === username);
if (!u || !bcrypt.compareSync(password, u.passwordHash))
return res.status(401).json({ error: "Ungültiger Benutzername oder Passwort" });
const token = jwt.sign({ id: u.id, username: u.username, role: u.role, displayName: u.displayName }, JWT_SECRET, { expiresIn: "14d" });
res.json({ token, user: { id: u.id, username: u.username, role: u.role, displayName: u.displayName } });
});
app.get("/api/auth/me", auth, (req, res) => res.json(req.user));
app.post("/api/auth/change-password", auth, (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Felder erforderlich" });
if (newPassword.length < 6) return res.status(400).json({ error: "Passwort mindestens 6 Zeichen" });
const d = loadData();
const u = d.users.find(x => x.id === req.user.id);
if (!u || !bcrypt.compareSync(currentPassword, u.passwordHash))
return res.status(401).json({ error: "Aktuelles Passwort falsch" });
u.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS);
saveData(d);
res.json({ ok: true });
});
// ─── User management (admin) ───────────────────────────────────────────────
app.get("/api/users", auth, admin, (req, res) => {
const d = loadData();
res.json(d.users.map(u => ({ id: u.id, username: u.username, role: u.role, displayName: u.displayName, createdAt: u.createdAt })));
});
app.post("/api/users", auth, admin, (req, res) => {
const { username, password, role = "user", displayName = "" } = req.body || {};
if (!username || !password) return res.status(400).json({ error: "Benutzername und Passwort erforderlich" });
if (password.length < 6) return res.status(400).json({ error: "Passwort mindestens 6 Zeichen" });
const d = loadData();
if (d.users.find(u => u.username === username)) return res.status(409).json({ error: "Benutzername bereits vergeben" });
const u = { id: `user-${Date.now()}`, username, passwordHash: bcrypt.hashSync(password, SALT_ROUNDS), role, displayName: displayName || username, createdAt: new Date().toISOString() };
d.users.push(u);
saveData(d);
res.status(201).json({ id: u.id, username: u.username, role: u.role, displayName: u.displayName, createdAt: u.createdAt });
});
app.put("/api/users/:id", auth, admin, (req, res) => {
const d = loadData();
const u = d.users.find(x => x.id === req.params.id);
if (!u) return res.status(404).json({ error: "Benutzer nicht gefunden" });
if (req.body.displayName !== undefined) u.displayName = req.body.displayName;
if (req.body.role !== undefined && u.id !== "admin-1") u.role = req.body.role;
if (req.body.password && req.body.password.length >= 6) u.passwordHash = bcrypt.hashSync(req.body.password, SALT_ROUNDS);
saveData(d);
res.json({ id: u.id, username: u.username, role: u.role, displayName: u.displayName });
});
app.delete("/api/users/:id", auth, admin, (req, res) => {
if (req.params.id === "admin-1") return res.status(403).json({ error: "Admin-Benutzer kann nicht gelöscht werden" });
if (req.params.id === req.user.id) return res.status(403).json({ error: "Eigener Account kann nicht gelöscht werden" });
const d = loadData();
const idx = d.users.findIndex(u => u.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: "Benutzer nicht gefunden" });
d.users.splice(idx, 1);
saveData(d);
res.json({ ok: true });
});
// ─── Aquarium routes ───────────────────────────────────────────────────────
app.get("/api/aquariums", auth, (req, res) => {
const d = loadData();
const list = req.user.role === "admin"
? d.aquariums
: d.aquariums.filter(a => a.userId === req.user.id);
res.json(list);
});
app.post("/api/aquariums", auth, (req, res) => {
const { name, volume = 60, description = "" } = req.body || {};
if (!name?.trim()) return res.status(400).json({ error: "Name erforderlich" });
const d = loadData();
const aq = { id: `aq-${Date.now()}`, userId: req.user.id, name: name.trim(), volume: Number(volume) || 60, description, createdAt: new Date().toISOString() };
d.aquariums.push(aq);
saveData(d);
res.status(201).json(aq);
});
app.put("/api/aquariums/:id", auth, (req, res) => {
const d = loadData();
const aq = ownsAquarium(d, req.params.id, req.user);
if (!aq) return res.status(404).json({ error: "Aquarium nicht gefunden" });
if (req.body.name?.trim()) aq.name = req.body.name.trim();
if (req.body.volume !== undefined) aq.volume = Number(req.body.volume) || aq.volume;
if (req.body.description !== undefined) aq.description = req.body.description;
saveData(d);
res.json(aq);
});
app.delete("/api/aquariums/:id", auth, (req, res) => {
const d = loadData();
const aq = ownsAquarium(d, req.params.id, req.user);
if (!aq) return res.status(404).json({ error: "Aquarium nicht gefunden" });
d.aquariums = d.aquariums.filter(a => a.id !== req.params.id);
d.entries = d.entries.filter(e => e.aquariumId !== req.params.id);
d.reminders = (d.reminders || []).filter(r => r.aquariumId !== req.params.id);
saveData(d);
res.json({ ok: true });
});
// ─── Entry routes ──────────────────────────────────────────────────────────
app.get("/api/aquariums/:aqId/entries", auth, (req, res) => {
const d = loadData();
if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" });
const entries = d.entries
.filter(e => e.aquariumId === req.params.aqId)
.sort((a, b) => new Date(b.date) - new Date(a.date));
res.json(entries);
});
app.post("/api/aquariums/:aqId/entries", auth, (req, res) => {
const d = loadData();
if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" });
const entry = { id: `e-${Date.now()}`, aquariumId: req.params.aqId, date: new Date().toISOString(), values: req.body.values || {}, note: req.body.note || "" };
d.entries.push(entry);
saveData(d);
res.status(201).json(entry);
});
app.delete("/api/aquariums/:aqId/entries/:id", auth, (req, res) => {
const d = loadData();
if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" });
const idx = d.entries.findIndex(e => e.id === req.params.id && e.aquariumId === req.params.aqId);
if (idx === -1) return res.status(404).json({ error: "Eintrag nicht gefunden" });
d.entries.splice(idx, 1);
saveData(d);
res.json({ ok: true });
});
// ─── Reminder routes ───────────────────────────────────────────────────────
app.get("/api/aquariums/:aqId/reminder", auth, (req, res) => {
const d = loadData();
if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" });
const r = (d.reminders || []).find(x => x.aquariumId === req.params.aqId) || null;
res.json(r);
});
app.put("/api/aquariums/:aqId/reminder", auth, (req, res) => {
const d = loadData();
if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" });
if (!d.reminders) d.reminders = [];
const idx = d.reminders.findIndex(r => r.aquariumId === req.params.aqId);
const r = { aquariumId: req.params.aqId, intervalDays: req.body.intervalDays ?? 7, lastChange: req.body.lastChange ?? new Date().toISOString(), enabled: req.body.enabled ?? true };
idx >= 0 ? (d.reminders[idx] = r) : d.reminders.push(r);
saveData(d);
res.json(r);
});
initAdmin();
app.listen(PORT, "127.0.0.1", () => console.log(`[aquarium] API läuft auf :${PORT}`));

108
deploy-v3.sh Normal file
View File

@@ -0,0 +1,108 @@
#!/bin/bash
set -e
echo "=== Aquarium Logbuch v3 Deploy ==="
# 1. Download aquascape header photo (CC0 from Pixabay)
echo "[1/7] Header-Foto herunterladen..."
mkdir -p /tmp/aquarium-deploy/app/public
wget -q --timeout=15 -O /tmp/aquarium-deploy/app/public/aquascape.jpg \
"https://cdn.pixabay.com/photo/2023/02/08/06/09/aquarium-7776393_1280.jpg" \
2>/dev/null && echo " Foto heruntergeladen." || \
wget -q --timeout=15 -O /tmp/aquarium-deploy/app/public/aquascape.jpg \
"https://cdn.pixabay.com/photo/2017/07/14/15/48/aquarium-2503350_1280.jpg" \
2>/dev/null && echo " Foto (Fallback) heruntergeladen." || \
echo " WARN: Foto-Download fehlgeschlagen, CSS-Gradient wird als Fallback genutzt."
# 2. Build frontend
echo "[2/7] Frontend bauen..."
cd /tmp/aquarium-deploy/app
npm run build >> /tmp/build-v3-full.log 2>&1
echo " Build abgeschlossen."
# 3. Deploy frontend
echo "[3/7] Frontend deployen..."
cp -r dist/* /var/www/aquarium-logbuch/
# Copy photo to webroot too if it exists
[ -f public/aquascape.jpg ] && cp public/aquascape.jpg /var/www/aquarium-logbuch/aquascape.jpg
echo " Frontend kopiert."
# 4. Install backend
echo "[4/7] Backend installieren..."
mkdir -p /var/lib/aquarium-backend
cp /tmp/backend/server.js /var/lib/aquarium-backend/server.js
cp /tmp/backend/package.json /var/lib/aquarium-backend/package.json
cd /var/lib/aquarium-backend
npm install --omit=dev >> /tmp/build-v3-full.log 2>&1
echo " Backend-Pakete installiert."
# 5. Setup data directory
echo "[5/7] Datenverzeichnis anlegen..."
mkdir -p /var/lib/aquarium
chown -R www-data:www-data /var/lib/aquarium 2>/dev/null || true
# 6. Create systemd service
echo "[6/7] Systemd-Service anlegen..."
cat > /etc/systemd/system/aquarium-api.service << 'EOF'
[Unit]
Description=Aquarium Logbuch API
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/var/lib/aquarium-backend
ExecStart=/usr/bin/node /var/lib/aquarium-backend/server.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=aquarium-api
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable aquarium-api
systemctl restart aquarium-api
sleep 2
systemctl is-active aquarium-api && echo " API-Service läuft." || echo " WARN: API-Service startet nicht, Logs prüfen."
# 7. Update nginx config
echo "[7/7] Nginx konfigurieren..."
cat > /etc/nginx/sites-available/aquarium.conf << 'NGINX'
server {
listen 80 default_server;
server_name _;
root /var/www/aquarium-logbuch;
index index.html;
client_max_body_size 10M;
# API proxy → Node backend
location /api {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
}
# SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires 7d;
add_header Cache-Control "public, no-transform";
}
}
NGINX
nginx -t && nginx -s reload && echo " Nginx neu geladen."
echo ""
echo "=== Deploy abgeschlossen! ==="
echo "App: http://$(hostname -I | awk '{print $1}')/"
echo "Admin: aqlab / D4sP4sswortBek0mmtKe1ner!"

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aquarium Logbuch</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=DM+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1568
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "aquarium-logbuch",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.4.21"
}
}

589
postgres-ha-install.sh Normal file
View File

@@ -0,0 +1,589 @@
#!/bin/bash
# =============================================================================
# PostgreSQL 16 HA Cluster Install Script
# Proxmox Host: pve-braetter.braetter.local
#
# Architektur:
# CT 300 sql1 192.168.0.220 PostgreSQL 16 + Patroni + etcd (Leader)
# CT 301 sql2 192.168.0.221 PostgreSQL 16 + Patroni + etcd (Replica)
# CT 302 sql3 192.168.0.222 PostgreSQL 16 + Patroni + etcd (Replica)
# CT 303 pgadmin 192.168.0.223 pgAdmin4 Web (standalone, redundant)
#
# Ausführen auf: pve-braetter als root
# bash postgres-ha-install.sh
# =============================================================================
set -e
# --- Farben ---
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $1"; }
info() { echo -e "${BLUE}[i]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
step() { echo -e "\n${BOLD}${CYAN}==> $1${NC}"; }
fail() { echo -e "${RED}[FEHLER]${NC} $1"; exit 1; }
# =============================================================================
# KONFIGURATION — hier anpassen falls nötig
# =============================================================================
TEMPLATE="Isos:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst"
STORAGE="osdisk"
BRIDGE="vmbr0"
GATEWAY="192.168.0.1"
NAMESERVER="192.168.0.202"
SEARCHDOMAIN="braetter.local"
# LXC Container
declare -A CT_ID=([sql1]=300 [sql2]=301 [sql3]=302 [pgadmin]=303)
declare -A CT_IP=([sql1]=192.168.0.220 [sql2]=192.168.0.221 [sql3]=192.168.0.222 [pgadmin]=192.168.0.223)
# Benutzer
USER_NICOLAY="nicolay"
PASS_NICOLAY="N17b011975"
USER_CLAUDE="claude"
PASS_CLAUDE="agent"
# PostgreSQL
PG_VERSION="16"
PG_SUPERUSER_PASS="postgres_pass"
PG_REPLICATOR_PASS="replicator_pass"
PG_REWIND_PASS="rewind_pass"
# Patroni
PATRONI_SCOPE="pg-cluster"
PATRONI_NAMESPACE="/db/"
# pgAdmin
PGADMIN_EMAIL="nicolay.braetter@googlemail.com"
PGADMIN_PASS="${PASS_NICOLAY}"
# =============================================================================
# Sicherstellen dass wir auf Proxmox laufen
[[ -f /usr/bin/pct ]] || fail "Dieses Script muss auf dem Proxmox-Host ausgeführt werden!"
# =============================================================================
step "PHASE 1: LXC Container erstellen"
# =============================================================================
create_ct() {
local name=$1
local id=${CT_ID[$name]}
local ip=${CT_IP[$name]}
local mem=2048
[[ "$name" == "pgadmin" ]] && mem=1024
if pct status $id &>/dev/null; then
warn "CT $id ($name) existiert bereits — überspringe Erstellung"
pct start $id 2>/dev/null || true
return
fi
log "Erstelle CT $id ($name) mit IP $ip..."
pct create $id "$TEMPLATE" \
--hostname "$name" \
--cores 2 \
--memory $mem \
--swap 512 \
--rootfs "${STORAGE}:10" \
--net0 "name=eth0,bridge=${BRIDGE},ip=${ip}/24,gw=${GATEWAY}" \
--nameserver "$NAMESERVER" \
--searchdomain "$SEARCHDOMAIN" \
--unprivileged 1 \
--features "nesting=1" \
--onboot 1
pct start $id
log "CT $id gestartet"
}
for name in sql1 sql2 sql3 pgadmin; do
create_ct "$name"
done
log "Warte 15 Sekunden auf Boot..."
sleep 15
# =============================================================================
step "PHASE 2: Basis-Setup auf allen DB-Nodes (sql1/sql2/sql3)"
# =============================================================================
wait_for_apt() {
local id=$1
pct exec $id -- bash -c "
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || \
fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do
echo 'Warte auf apt-Lock...'
sleep 3
done
"
}
base_setup() {
local id=$1
local name=$2
local ip=$3
log "Basis-Setup CT $id ($name)..."
wait_for_apt $id
pct exec $id -- bash -c "
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
# Benutzer anlegen
id ${USER_NICOLAY} &>/dev/null || useradd -m -s /bin/bash -G sudo ${USER_NICOLAY}
echo '${USER_NICOLAY}:${PASS_NICOLAY}' | chpasswd
id ${USER_CLAUDE} &>/dev/null || useradd -m -s /bin/bash -G sudo ${USER_CLAUDE}
echo '${USER_CLAUDE}:${PASS_CLAUDE}' | chpasswd
# sudo ohne Passwort für beide
echo '${USER_NICOLAY} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${USER_NICOLAY}
echo '${USER_CLAUDE} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${USER_CLAUDE}
# SSH aktivieren
apt-get install -y -qq openssh-server curl wget gnupg lsb-release ca-certificates python3 python3-pip
systemctl enable ssh && systemctl start ssh
# PasswordAuthentication
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
systemctl restart ssh
"
log "Basis-Setup $name abgeschlossen"
}
for name in sql1 sql2 sql3; do
base_setup "${CT_ID[$name]}" "$name" "${CT_IP[$name]}" &
done
wait
log "Basis-Setup aller DB-Nodes abgeschlossen"
# =============================================================================
step "PHASE 3: PostgreSQL 16 installieren (sql1/sql2/sql3)"
# =============================================================================
install_postgres() {
local id=$1
local name=$2
log "Installiere PostgreSQL 16 auf CT $id ($name)..."
wait_for_apt $id
pct exec $id -- bash -c "
export DEBIAN_FRONTEND=noninteractive
# PostgreSQL Repository
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /usr/share/keyrings/postgresql.gpg
echo 'deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt \$(lsb_release -cs)-pgdg main' \
> /etc/apt/sources.list.d/pgdg.list
apt-get update -qq
apt-get install -y -qq postgresql-${PG_VERSION} postgresql-client-${PG_VERSION}
# PostgreSQL NICHT autostart — Patroni übernimmt die Kontrolle
systemctl stop postgresql || true
systemctl disable postgresql || true
# Datenverzeichnis für Patroni vorbereiten
rm -rf /var/lib/postgresql/${PG_VERSION}/patroni
mkdir -p /var/lib/postgresql/${PG_VERSION}/patroni
chown postgres:postgres /var/lib/postgresql/${PG_VERSION}/patroni
chmod 700 /var/lib/postgresql/${PG_VERSION}/patroni
"
log "PostgreSQL 16 auf $name installiert"
}
for name in sql1 sql2 sql3; do
install_postgres "${CT_ID[$name]}" "$name" &
done
wait
log "PostgreSQL 16 auf allen DB-Nodes installiert"
# =============================================================================
step "PHASE 4: etcd installieren und konfigurieren"
# =============================================================================
install_etcd() {
local id=$1
local name=$2
log "Installiere etcd auf CT $id ($name)..."
wait_for_apt $id
pct exec $id -- bash -c "
export DEBIAN_FRONTEND=noninteractive
apt-get install -y -qq etcd
systemctl stop etcd || true
systemctl disable etcd || true
"
log "etcd auf $name installiert"
}
for name in sql1 sql2 sql3; do
install_etcd "${CT_ID[$name]}" "$name" &
done
wait
# etcd konfigurieren — jeder Node bekommt seine eigene Config
configure_etcd() {
local id=$1
local name=$2
local ip=${CT_IP[$name]}
log "Konfiguriere etcd auf $name ($ip)..."
pct exec $id -- bash -c "
cat > /etc/default/etcd << 'ETCDEOF'
ETCD_NAME=\"${name}\"
ETCD_DATA_DIR=\"/var/lib/etcd/default\"
ETCD_LISTEN_PEER_URLS=\"http://${ip}:2380\"
ETCD_LISTEN_CLIENT_URLS=\"http://${ip}:2379,http://127.0.0.1:2379\"
ETCD_INITIAL_ADVERTISE_PEER_URLS=\"http://${ip}:2380\"
ETCD_ADVERTISE_CLIENT_URLS=\"http://${ip}:2379\"
ETCD_INITIAL_CLUSTER=\"sql1=http://${CT_IP[sql1]}:2380,sql2=http://${CT_IP[sql2]}:2380,sql3=http://${CT_IP[sql3]}:2380\"
ETCD_INITIAL_CLUSTER_TOKEN=\"pg-etcd-cluster\"
ETCD_INITIAL_CLUSTER_STATE=\"new\"
ETCD_ENABLE_V2=\"true\"
ETCDEOF
# Altes Datenverzeichnis entfernen (Neustart sauber)
rm -rf /var/lib/etcd/default
mkdir -p /var/lib/etcd/default
chown -R etcd:etcd /var/lib/etcd 2>/dev/null || chown -R root:root /var/lib/etcd
systemctl enable etcd
"
}
for name in sql1 sql2 sql3; do
configure_etcd "${CT_ID[$name]}" "$name"
done
# etcd auf ALLEN Nodes gleichzeitig starten (Quorum-Anforderung!)
log "Starte etcd Cluster auf allen 3 Nodes gleichzeitig..."
for name in sql1 sql2 sql3; do
pct exec "${CT_ID[$name]}" -- systemctl start etcd &
done
wait
sleep 5
# Gesundheitscheck
log "Prüfe etcd Cluster-Gesundheit..."
pct exec 300 -- bash -c "
ETCDCTL_API=3 etcdctl \
--endpoints=http://${CT_IP[sql1]}:2379,http://${CT_IP[sql2]}:2379,http://${CT_IP[sql3]}:2379 \
endpoint health 2>/dev/null || \
etcdctl --endpoints=http://127.0.0.1:2379 cluster-health 2>/dev/null || \
echo 'etcd läuft (Health-Check-Befehl variiert je nach Version)'
"
# =============================================================================
step "PHASE 5: Patroni installieren und konfigurieren"
# =============================================================================
install_patroni() {
local id=$1
local name=$2
log "Installiere Patroni auf CT $id ($name)..."
wait_for_apt $id
pct exec $id -- bash -c "
export DEBIAN_FRONTEND=noninteractive
apt-get install -y -qq python3-pip python3-dev libpq-dev gcc
pip3 install --quiet 'patroni[etcd3]' psycopg2-binary
"
log "Patroni auf $name installiert"
}
for name in sql1 sql2 sql3; do
install_patroni "${CT_ID[$name]}" "$name" &
done
wait
# Patroni konfigurieren
configure_patroni() {
local id=$1
local name=$2
local ip=${CT_IP[$name]}
log "Konfiguriere Patroni auf $name ($ip)..."
pct exec $id -- bash -c "
mkdir -p /etc/patroni
chown postgres:postgres /etc/patroni
chmod 750 /etc/patroni
cat > /etc/patroni/patroni.yml << 'PATRONIEOF'
scope: ${PATRONI_SCOPE}
namespace: ${PATRONI_NAMESPACE}
name: ${name}
restapi:
listen: ${ip}:8008
connect_address: ${ip}:8008
etcd3:
hosts:
- ${CT_IP[sql1]}:2379
- ${CT_IP[sql2]}:2379
- ${CT_IP[sql3]}:2379
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
postgresql:
use_pg_rewind: true
use_slots: true
parameters:
wal_level: replica
hot_standby: 'on'
max_connections: 200
max_wal_senders: 10
max_replication_slots: 10
wal_log_hints: 'on'
initdb:
- encoding: UTF8
- data-checksums
pg_hba:
- host replication replicator 192.168.0.0/24 md5
- host all all 0.0.0.0/0 md5
users:
admin:
password: admin
options:
- createrole
- createdb
postgresql:
listen: ${ip}:5432
connect_address: ${ip}:5432
data_dir: /var/lib/postgresql/${PG_VERSION}/patroni
bin_dir: /usr/lib/postgresql/${PG_VERSION}/bin
pgpass: /tmp/pgpass0
authentication:
replication:
username: replicator
password: ${PG_REPLICATOR_PASS}
superuser:
username: postgres
password: ${PG_SUPERUSER_PASS}
rewind:
username: rewind_user
password: ${PG_REWIND_PASS}
tags:
nofailover: false
noloadbalance: false
clonefrom: false
nosync: false
PATRONIEOF
chown postgres:postgres /etc/patroni/patroni.yml
chmod 640 /etc/patroni/patroni.yml
# Patroni Systemd Service
cat > /etc/systemd/system/patroni.service << 'SVCEOF'
[Unit]
Description=Patroni PostgreSQL HA
After=syslog.target network.target etcd.service
Wants=etcd.service
[Service]
Type=simple
User=postgres
Group=postgres
ExecStart=/usr/local/bin/patroni /etc/patroni/patroni.yml
KillMode=process
TimeoutSec=30
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
SVCEOF
systemctl daemon-reload
systemctl enable patroni
"
}
for name in sql1 sql2 sql3; do
configure_patroni "${CT_ID[$name]}" "$name"
done
# Patroni starten — sql1 zuerst (wird Leader), dann Replicas
log "Starte Patroni auf sql1 (Leader-Initialisierung)..."
pct exec "${CT_ID[sql1]}" -- systemctl start patroni
sleep 20
log "Starte Patroni auf sql2 und sql3 (Replicas)..."
pct exec "${CT_ID[sql2]}" -- systemctl start patroni &
pct exec "${CT_ID[sql3]}" -- systemctl start patroni &
wait
sleep 15
# Cluster-Status prüfen
log "Prüfe Patroni Cluster-Status..."
pct exec "${CT_ID[sql1]}" -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml list || true
# =============================================================================
step "PHASE 6: Datenbank-Benutzer und Datenbanken anlegen"
# =============================================================================
log "Lege Benutzer und Datenbanken an..."
pct exec "${CT_ID[sql1]}" -- bash -c "
# Warte bis PostgreSQL bereit ist
for i in \$(seq 1 30); do
sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/pg_isready -h ${CT_IP[sql1]} && break
sleep 2
done
# Benutzer nicolay
sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \
-c \"CREATE USER ${USER_NICOLAY} WITH LOGIN PASSWORD '${PASS_NICOLAY}';\" 2>/dev/null || \
sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \
-c \"ALTER USER ${USER_NICOLAY} WITH PASSWORD '${PASS_NICOLAY}';\"
# Datenbanken
sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \
-c \"CREATE DATABASE ${USER_NICOLAY} OWNER ${USER_NICOLAY};\" 2>/dev/null || true
sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \
-c \"CREATE DATABASE testdb OWNER ${USER_NICOLAY};\" 2>/dev/null || true
echo 'Benutzer und Datenbanken angelegt'
"
# =============================================================================
step "PHASE 7: pgAdmin4 auf separatem LXC (CT 303) installieren"
# =============================================================================
# Basis-Setup pgAdmin-CT
log "Basis-Setup CT 303 (pgadmin)..."
wait_for_apt "${CT_ID[pgadmin]}"
pct exec "${CT_ID[pgadmin]}" -- bash -c "
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
# Benutzer
id ${USER_NICOLAY} &>/dev/null || useradd -m -s /bin/bash -G sudo ${USER_NICOLAY}
echo '${USER_NICOLAY}:${PASS_NICOLAY}' | chpasswd
echo '${USER_NICOLAY} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${USER_NICOLAY}
# SSH
apt-get install -y -qq openssh-server curl wget gnupg lsb-release ca-certificates
systemctl enable ssh && systemctl start ssh
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
systemctl restart ssh
# pgAdmin Repository
curl -fsSL https://www.pgadmin.org/static/packages_pgadmin_org.pub \
| gpg --dearmor -o /usr/share/keyrings/pgadmin.gpg
echo 'deb [signed-by=/usr/share/keyrings/pgadmin.gpg] https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/\$(lsb_release -cs) pgadmin4 main' \
> /etc/apt/sources.list.d/pgadmin4.list
apt-get update -qq
apt-get install -y -qq pgadmin4-web apache2
# pgAdmin Datenbank initialisieren
sudo -u www-data /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py setup-db
# Admin-Benutzer anlegen
sudo -u www-data /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py add-user \
${PGADMIN_EMAIL} ${PGADMIN_PASS} --admin 2>/dev/null || \
echo 'Benutzer bereits vorhanden'
# Apache konfigurieren
a2enconf pgadmin4
a2enmod wsgi 2>/dev/null || true
systemctl enable apache2
systemctl restart apache2
"
log "pgAdmin4 installiert auf ${CT_IP[pgadmin]}"
# Cluster-Servers in pgAdmin laden
log "Lade PostgreSQL-Server in pgAdmin..."
pct exec "${CT_ID[pgadmin]}" -- bash -c "
cat > /tmp/pgadmin_servers.json << 'JSONEOF'
{
\"Servers\": {
\"1\": {
\"Name\": \"sql1 (Leader)\",
\"Group\": \"pg-cluster\",
\"Host\": \"${CT_IP[sql1]}\",
\"Port\": 5432,
\"MaintenanceDB\": \"postgres\",
\"Username\": \"${USER_NICOLAY}\",
\"SSLMode\": \"prefer\",
\"Comment\": \"Patroni Leader Node\"
},
\"2\": {
\"Name\": \"sql2 (Replica)\",
\"Group\": \"pg-cluster\",
\"Host\": \"${CT_IP[sql2]}\",
\"Port\": 5432,
\"MaintenanceDB\": \"postgres\",
\"Username\": \"${USER_NICOLAY}\",
\"SSLMode\": \"prefer\",
\"Comment\": \"Patroni Replica Node\"
},
\"3\": {
\"Name\": \"sql3 (Replica)\",
\"Group\": \"pg-cluster\",
\"Host\": \"${CT_IP[sql3]}\",
\"Port\": 5432,
\"MaintenanceDB\": \"postgres\",
\"Username\": \"${USER_NICOLAY}\",
\"SSLMode\": \"prefer\",
\"Comment\": \"Patroni Replica Node\"
}
}
}
JSONEOF
sudo -u www-data /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py \
load-servers /tmp/pgadmin_servers.json --user ${PGADMIN_EMAIL}
"
# =============================================================================
step "PHASE 8: Abschluss-Prüfung und Report"
# =============================================================================
echo ""
echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${GREEN}║ PostgreSQL HA Cluster — Installation fertig ║${NC}"
echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BOLD}Cluster-Status:${NC}"
pct exec "${CT_ID[sql1]}" -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml list 2>/dev/null || \
echo " (patronictl nicht im PATH — manuell prüfen: pct exec 300 -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml list)"
echo ""
echo -e "${BOLD}Zugriff:${NC}"
echo -e " PostgreSQL (Primary): ${CT_IP[sql1]}:5432"
echo -e " PostgreSQL (Replica): ${CT_IP[sql2]}:5432 / ${CT_IP[sql3]}:5432"
echo -e " pgAdmin4 Web: http://${CT_IP[pgadmin]}/pgadmin4/"
echo -e " Patroni REST sql1: http://${CT_IP[sql1]}:8008/"
echo -e " Patroni REST sql2: http://${CT_IP[sql2]}:8008/"
echo -e " Patroni REST sql3: http://${CT_IP[sql3]}:8008/"
echo ""
echo -e "${BOLD}Anmeldedaten:${NC}"
echo -e " pgAdmin: ${PGADMIN_EMAIL} / ${PGADMIN_PASS}"
echo -e " DB-User: ${USER_NICOLAY} / ${PASS_NICOLAY}"
echo -e " DB-Super: postgres / ${PG_SUPERUSER_PASS}"
echo -e " SSH (alle): ${USER_NICOLAY} / ${PASS_NICOLAY}"
echo ""
echo -e "${BOLD}Datenbanken:${NC}"
echo -e " nicolay, testdb"
echo ""
echo -e "${BOLD}Failover-Befehl (manuell):${NC}"
echo -e " pct exec 300 -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml failover ${PATRONI_SCOPE}"
echo ""

2053
src/App.jsx Normal file

File diff suppressed because it is too large Load Diff

9
src/main.jsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

11
vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3001'
}
}
})