Refactor and streamline project setup and management scripts

- Removed `run.sh` and `test.sh` scripts to simplify the project structure.
- Added `setup-first-install.sh` to handle initial setup tasks with options to skip specific steps.
- Updated `invario-stack-autostart.service` to use `gitea.sh` for starting the stack on boot.
- Removed `start-stack-on-boot.sh` as its functionality is now integrated into the service.
- Enhanced `admin_system.html` with a new live logs panel, including automatic updates and log source selection.
- Improved CSS styles for the new logs panel for better UI consistency.
- Added JavaScript functionality to fetch and display live logs from the server.
This commit is contained in:
2026-04-18 20:09:44 +02:00
parent 09cb8306ff
commit d9b812812f
12 changed files with 815 additions and 848 deletions
+154 -54
View File
@@ -820,6 +820,30 @@ def _run_command(command: list[str], cwd: str | None = None, timeout: int = 900)
return True, output or "OK"
def _instance_compose_file(instance_dir: str) -> str | None:
preferred = os.path.join(instance_dir, "docker-compose-multitenant.yml")
fallback = os.path.join(instance_dir, "docker-compose.yml")
if os.path.isfile(preferred):
return preferred
if os.path.isfile(fallback):
return fallback
return None
def _instance_compose_cmd(instance_dir: str, args: list[str]) -> list[str] | None:
compose_file = _instance_compose_file(instance_dir)
if not compose_file:
return None
cmd = ["docker", "compose", "-f", compose_file]
env_file = os.path.join(instance_dir, ".docker-build.env")
if os.path.isfile(env_file):
cmd.extend(["--env-file", ".docker-build.env"])
cmd.extend(args)
return cmd
def _collect_command_candidates(base_binaries: list[str], args: list[str]) -> list[list[str]]:
candidates: list[list[str]] = []
seen: set[tuple[str, ...]] = set()
@@ -943,21 +967,109 @@ def _collect_core_logs() -> tuple[bool, str]:
return _run_command(command, cwd=BASE_DIR, timeout=180)
def _truncate_log_blob(text: str, max_lines: int = 220, max_chars: int = 32000) -> str:
rows = (text or "").splitlines()
if len(rows) > max_lines:
rows = rows[-max_lines:]
clipped = "\n".join(rows).strip()
if len(clipped) > max_chars:
clipped = clipped[-max_chars:]
return clipped or "Keine Ausgabe"
def _run_first_success(candidates: list[list[str]], cwd: str | None = None, timeout: int = 120) -> tuple[bool, str, str]:
if not candidates:
return False, "Kein Befehl verfügbar.", ""
failures: list[str] = []
for command in candidates:
ok, output = _run_command(command, cwd=cwd, timeout=timeout)
if ok:
return True, output, " ".join(command)
failures.append(f"{' '.join(command)} -> {_tail_output(output, 3)}")
return False, " | ".join(failures[-2:]) if failures else "Kein Detail vorhanden.", ""
def _collect_core_live_logs(lines: int = 220) -> dict:
compose_file = os.path.join(BASE_DIR, "docker-compose.yml")
command = [
"docker",
"compose",
"-f",
compose_file,
"logs",
"--no-color",
"--tail",
str(max(lines, 20)),
"website",
"mongodb",
]
ok, output = _run_command(command, cwd=BASE_DIR, timeout=180)
return {
"ok": ok,
"label": "Docker Compose (website, mongodb)",
"logs": _truncate_log_blob(output),
}
def _collect_systemd_service_snapshot(service_name: str, lines: int = 160) -> dict:
status_ok, status_out, status_cmd = _run_first_success(
_collect_command_candidates(["systemctl"], ["is-active", service_name]),
timeout=60,
)
status = (status_out or "").strip().splitlines()[-1] if status_ok else "unavailable"
logs_ok, logs_out, logs_cmd = _run_first_success(
_collect_command_candidates(
["journalctl"],
["-u", service_name, "--no-pager", "-n", str(max(lines, 40))],
),
timeout=120,
)
if logs_ok:
logs_text = _truncate_log_blob(logs_out)
else:
logs_text = _truncate_log_blob(f"Service-Logs konnten nicht geladen werden.\n{logs_out}")
return {
"service": service_name,
"status": status,
"status_ok": status_ok,
"status_command": status_cmd,
"logs_ok": logs_ok,
"logs_command": logs_cmd,
"logs": logs_text,
}
def _collect_homepage_service_logs() -> dict:
services = [
"invario-hosts-sync.service",
"invario-stack-autostart.service",
"nginx.service",
]
snapshots = [_collect_systemd_service_snapshot(service) for service in services]
return {
"generated_at": _utc_now_iso(),
"core": _collect_core_live_logs(),
"services": snapshots,
}
def _collect_instance_logs(subdomain: str) -> tuple[bool, str]:
instance_dir = _resolve_instance_dir(subdomain)
if not instance_dir:
return False, "Instanzverzeichnis nicht gefunden."
command = [
"docker",
"compose",
"--env-file",
".docker-build.env",
"logs",
"--no-color",
"--tail",
"500",
]
command = _instance_compose_cmd(
instance_dir,
["logs", "--no-color", "--tail", "500"],
)
if not command:
return False, "Compose-Datei der Instanz wurde nicht gefunden."
return _run_command(command, cwd=instance_dir, timeout=180)
@@ -1096,25 +1208,13 @@ def _set_instance_library_enabled(instance_dir: str, enabled: bool) -> tuple[boo
def _restart_instance_stack(instance_dir: str) -> tuple[bool, str]:
restart_cmd = [
"docker",
"compose",
"--env-file",
".docker-build.env",
"restart",
"app",
"nginx",
"mongodb",
]
up_cmd = [
"docker",
"compose",
"--env-file",
".docker-build.env",
"up",
"-d",
"--remove-orphans",
]
restart_cmd = _instance_compose_cmd(instance_dir, ["restart", "app", "nginx", "mongodb", "redis"])
if not restart_cmd:
return False, "Compose-Datei der Instanz wurde nicht gefunden."
up_cmd = _instance_compose_cmd(instance_dir, ["up", "-d", "--remove-orphans"])
if not up_cmd:
return False, "Compose-Datei der Instanz wurde nicht gefunden."
# restart may fail for stopped services; ensure desired state with up -d afterwards.
_run_command(restart_cmd, cwd=instance_dir, timeout=420)
@@ -1144,19 +1244,14 @@ def _delete_instance_stack(subdomain: str) -> tuple[bool, str]:
details: list[str] = []
if os.path.isdir(target_dir):
compose_file = os.path.join(target_dir, "docker-compose.yml")
if os.path.isfile(compose_file):
down_cmd = [
"docker",
"compose",
"--env-file",
".docker-build.env",
"down",
"--remove-orphans",
"--volumes",
"--timeout",
"40",
]
compose_file = _instance_compose_file(target_dir)
if compose_file:
down_cmd = _instance_compose_cmd(
target_dir,
["down", "--remove-orphans", "--volumes", "--timeout", "40"],
)
if not down_cmd:
return False, "Compose-Datei der Instanz wurde nicht gefunden."
down_ok, down_out = _run_command(down_cmd, cwd=target_dir, timeout=900)
if not down_ok:
return False, f"Docker-Stack konnte nicht gestoppt werden.\n{_tail_output(down_out, 12)}"
@@ -1269,20 +1364,17 @@ def _create_instance_admin_user(
"}"
)
up_ok, up_out = _run_command(
["docker", "compose", "--env-file", ".docker-build.env", "up", "-d", "mongodb"],
cwd=instance_dir,
timeout=180,
)
up_cmd = _instance_compose_cmd(instance_dir, ["up", "-d", "mongodb"])
if not up_cmd:
return False, "Compose-Datei der Instanz wurde nicht gefunden."
up_ok, up_out = _run_command(up_cmd, cwd=instance_dir, timeout=180)
if not up_ok:
return False, f"MongoDB der Instanz konnte nicht gestartet werden: {_tail_output(up_out, 10)}"
ok, output = _run_command(
exec_cmd = _instance_compose_cmd(
instance_dir,
[
"docker",
"compose",
"--env-file",
".docker-build.env",
"exec",
"-T",
"mongodb",
@@ -1292,9 +1384,11 @@ def _create_instance_admin_user(
eval_script,
db_name,
],
cwd=instance_dir,
timeout=240,
)
if not exec_cmd:
return False, "Compose-Datei der Instanz wurde nicht gefunden."
ok, output = _run_command(exec_cmd, cwd=instance_dir, timeout=240)
if not ok:
return False, f"Instanz-Admin konnte nicht angelegt werden: {_tail_output(output, 12)}"
@@ -2224,6 +2318,12 @@ def admin_system_stats():
return jsonify(_build_server_management_snapshot(instances))
@app.route('/admin/system/logs/live')
@admin_required
def admin_system_live_logs():
return jsonify(_collect_homepage_service_logs())
@app.route('/admin/system/logs/core')
@admin_required
def admin_download_core_logs():
+279 -18
View File
@@ -17,6 +17,9 @@ WILDCARD_KEY_FILE="/etc/nginx/certs/wildcard.meine-domain.key"
NGINX_SITES_AVAILABLE="/etc/nginx/sites-available"
NGINX_SITES_ENABLED="/etc/nginx/sites-enabled"
RELEASE_BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
RELEASE_IMAGE_ASSET_PREFIX="inventarsystem-image-"
print_kv() {
local key="$1"
local value="$2"
@@ -84,9 +87,219 @@ NUITKA_BUILD=0
INVENTAR_HTTP_PORT=$http_port
INVENTAR_HTTPS_PORT=$https_port
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag
INVENTAR_MULTITENANT_ENABLED=true
INVENTAR_SESSION_BACKEND=redis
INVENTAR_REDIS_HOST=redis
INVENTAR_REDIS_PORT=6379
INVENTAR_QUERY_CACHE_ENABLED=true
EOF
}
preferred_compose_file() {
if [ -f "docker-compose-multitenant.yml" ]; then
printf '%s' "docker-compose-multitenant.yml"
return 0
fi
printf '%s' "docker-compose.yml"
}
repo_slug_from_url() {
local repo_url="$1"
local normalized=""
normalized="$repo_url"
normalized="${normalized#https://github.com/}"
normalized="${normalized#http://github.com/}"
normalized="${normalized#git@github.com:}"
normalized="${normalized%.git}"
if [[ "$normalized" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
printf '%s' "$normalized"
return 0
fi
return 1
}
fetch_release_metadata() {
local repo_slug="$1"
local requested_tag="$2"
local out_file="$3"
local api_url=""
if [ "$requested_tag" = "latest" ]; then
api_url="https://api.github.com/repos/$repo_slug/releases/latest"
else
api_url="https://api.github.com/repos/$repo_slug/releases/tags/$requested_tag"
fi
if ! curl -fsSL "$api_url" -o "$out_file"; then
return 1
fi
return 0
}
extract_release_info() {
local meta_file="$1"
python3 - <<'PY' "$meta_file" "$RELEASE_BUNDLE_ASSET" "$RELEASE_IMAGE_ASSET_PREFIX"
import json
import sys
meta_file, bundle_asset, image_prefix = sys.argv[1], sys.argv[2], sys.argv[3]
with open(meta_file, "r", encoding="utf-8") as fh:
data = json.load(fh)
tag = (data.get("tag_name") or "").strip()
bundle_url = ""
image_url = ""
for asset in data.get("assets", []):
name = (asset.get("name") or "").strip()
url = (asset.get("browser_download_url") or "").strip()
if not url:
continue
if name == bundle_asset:
bundle_url = url
if tag and name == f"{image_prefix}{tag}.tar.gz":
image_url = url
print(tag)
print(bundle_url)
print(image_url)
PY
}
pin_compose_app_image() {
local instance_dir="$1"
local resolved_tag="$2"
local compose_file=""
for compose_file in "$instance_dir/docker-compose.yml" "$instance_dir/docker-compose-multitenant.yml"; do
[ -f "$compose_file" ] || continue
python3 - <<'PY' "$compose_file" "$resolved_tag"
import re
import sys
compose_file, tag = sys.argv[1], sys.argv[2]
target_image = f"ghcr.io/aiirondev/legendary-octo-garbanzo:{tag}"
with open(compose_file, "r", encoding="utf-8") as fh:
lines = fh.readlines()
out = []
in_app = False
in_build = False
image_set = False
for line in lines:
stripped = line.lstrip(" ")
indent = len(line) - len(stripped)
if not in_app and re.match(r"^\s{2}app:\s*$", line):
in_app = True
image_set = False
out.append(line)
out.append(f" image: {target_image}\n")
continue
if in_app:
if indent == 2 and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped):
in_app = False
in_build = False
if in_app:
if in_build:
if indent > 4:
continue
in_build = False
if re.match(r"^\s{4}build:\s*$", line):
in_build = True
continue
if re.match(r"^\s{4}image:\s*", line):
if image_set:
continue
out.append(f" image: {target_image}\n")
image_set = True
continue
out.append(line)
with open(compose_file, "w", encoding="utf-8") as fh:
fh.writelines(out)
PY
done
}
install_from_release() {
local target_dir="$1"
local repo_url="$2"
local requested_tag="$3"
local tmp_dir=""
local repo_slug=""
local meta_file=""
local bundle_path=""
local image_path=""
local tag=""
local bundle_url=""
local image_url=""
repo_slug="$(repo_slug_from_url "$repo_url")" || fail "Repository-URL nicht unterstützbar für Release-Install: $repo_url"
tmp_dir="$(mktemp -d)"
meta_file="$tmp_dir/release.json"
if ! fetch_release_metadata "$repo_slug" "$requested_tag" "$meta_file"; then
fail "Release-Metadaten konnten nicht geladen werden (Repo: $repo_slug, Tag: $requested_tag)."
fi
mapfile -t release_info < <(extract_release_info "$meta_file")
tag="${release_info[0]:-}"
bundle_url="${release_info[1]:-}"
image_url="${release_info[2]:-}"
[ -n "$tag" ] || fail "Release-Metadaten enthalten keinen gültigen Tag."
[ -n "$bundle_url" ] || fail "Release-Asset fehlt: $RELEASE_BUNDLE_ASSET"
[ -n "$image_url" ] || fail "Release-Image-Asset fehlt: ${RELEASE_IMAGE_ASSET_PREFIX}${tag}.tar.gz"
bundle_path="$tmp_dir/$RELEASE_BUNDLE_ASSET"
image_path="$tmp_dir/${RELEASE_IMAGE_ASSET_PREFIX}${tag}.tar.gz"
curl -fsSL "$bundle_url" -o "$bundle_path" || fail "Release-Bundle konnte nicht geladen werden."
curl -fsSL "$image_url" -o "$image_path" || fail "Release-Image konnte nicht geladen werden."
mkdir -p "$target_dir"
tar -xzf "$bundle_path" -C "$target_dir" || fail "Release-Bundle konnte nicht entpackt werden."
docker load -i "$image_path" >/dev/null 2>&1 || fail "Docker-Image konnte nicht geladen werden."
docker tag "ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" "ghcr.io/aiirondev/legendary-octo-garbanzo:latest" >/dev/null 2>&1 || true
pin_compose_app_image "$target_dir" "$tag"
rm -rf "$tmp_dir" >/dev/null 2>&1 || true
print_kv "APP_IMAGE_TAG" "$tag"
}
ensure_requested_app_image() {
local app_image_tag="$1"
local app_image="ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag"
if docker image inspect "$app_image" >/dev/null 2>&1; then
return 0
fi
if ! docker pull "$app_image" >/dev/null 2>&1; then
return 1
fi
return 0
}
set_library_enabled() {
local instance_dir="$1"
local enabled="$2"
@@ -122,13 +335,16 @@ PY
}
normalize_instance_compose() {
local compose_file="docker-compose.yml"
[ -f "$compose_file" ] || return 0
local compose_file=""
python3 - <<'PY'
for compose_file in "docker-compose.yml" "docker-compose-multitenant.yml"; do
[ -f "$compose_file" ] || continue
python3 - <<'PY' "$compose_file"
from pathlib import Path
import sys
path = Path("docker-compose.yml")
path = Path(sys.argv[1])
content = path.read_text(encoding="utf-8")
lines = content.splitlines()
@@ -185,22 +401,68 @@ if content.endswith("\n"):
if normalized != content:
path.write_text(normalized, encoding="utf-8")
PY
done
}
run_instance_start() {
local start_output
local retry_output
local update_output
local compose_file
local has_redis=0
compose_file="$(preferred_compose_file)"
if [ -f "$compose_file" ] && grep -Eq '^\s{2}redis:\s*$' "$compose_file"; then
has_redis=1
fi
stack_is_running() {
local running_services
running_services="$(docker compose --env-file .docker-build.env ps --status running --services 2>/dev/null || true)"
running_services="$(docker compose -f "$compose_file" --env-file .docker-build.env ps --status running --services 2>/dev/null || true)"
printf '%s\n' "$running_services" | grep -Fxq app || return 1
printf '%s\n' "$running_services" | grep -Fxq nginx || return 1
printf '%s\n' "$running_services" | grep -Fxq mongodb || return 1
if [ "$has_redis" = "1" ]; then
printf '%s\n' "$running_services" | grep -Fxq redis || return 1
fi
return 0
}
if [ "$compose_file" = "docker-compose-multitenant.yml" ]; then
if start_output="$(docker compose -f "$compose_file" --env-file .docker-build.env up -d --remove-orphans 2>&1)"; then
print_kv "MESSAGE" "Instanz gestartet (Inventarsystem Multiinstancing aktiv)."
return 0
fi
if stack_is_running; then
print_kv "MESSAGE" "Instanz gestartet (Multiinstancing läuft, Healthcheck im Startskript war nicht erreichbar)."
return 0
fi
if printf '%s' "$start_output" | grep -qi "local app image not found"; then
if [ ! -x ./update.sh ]; then
fail "Multiinstancing-Start fehlgeschlagen und update.sh fehlt. Letzte Meldung: $(printf '%s' "$start_output" | tail -n1)"
fi
if ! update_output="$(bash ./update.sh 2>&1)"; then
if stack_is_running; then
print_kv "MESSAGE" "Instanz gestartet (update.sh meldete Healthcheck-Fehler, Multiinstancing-Dienste laufen)."
return 0
fi
fail "update.sh fehlgeschlagen: $(printf '%s' "$update_output" | tail -n1)"
fi
if retry_output="$(docker compose -f "$compose_file" --env-file .docker-build.env up -d --remove-orphans 2>&1)"; then
print_kv "MESSAGE" "Instanz gestartet (Multiinstancing-Image automatisch per update.sh geladen)."
return 0
fi
fail "Multiinstancing-Start nach update.sh fehlgeschlagen: $(printf '%s' "$retry_output" | tail -n1)"
fi
fail "Multiinstancing-Start fehlgeschlagen: $(printf '%s' "$start_output" | tail -n1)"
fi
if start_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then
return 0
fi
@@ -237,10 +499,11 @@ run_instance_start() {
setup_or_update_repo() {
local target_dir="$1"
local repo_url="$2"
local app_image_tag="$3"
if [ -f "$target_dir/start.sh" ]; then
# Existing installation: keep it docker-only and update via project tooling.
if [ -x "$target_dir/update.sh" ]; then
# Existing installation: keep update flow inside Inventarsystem tooling.
if [ "$app_image_tag" = "latest" ] && [ -x "$target_dir/update.sh" ]; then
(cd "$target_dir" && bash ./update.sh >/dev/null 2>&1 || true)
fi
return 0
@@ -250,15 +513,7 @@ setup_or_update_repo() {
fail "Zielverzeichnis ist nicht leer: $target_dir"
fi
# Clone directly instead of running the upstream installer, which expects
# host-level systemd/cron setup and can fail inside containers.
if [ -d "$target_dir" ]; then
rmdir "$target_dir" 2>/dev/null || true
fi
if ! git clone --depth 1 "$repo_url" "$target_dir" >/dev/null 2>&1; then
fail "Repository konnte nicht geklont werden: $repo_url"
fi
install_from_release "$target_dir" "$repo_url" "$app_image_tag"
}
write_nginx_site() {
@@ -453,7 +708,9 @@ FULL_DOMAIN="$SUBDOMAIN.$PARENT_DOMAIN"
require_cmd docker
require_cmd bash
require_cmd git
require_cmd curl
require_cmd tar
require_cmd python3
mkdir -p "$BASE_DIR"
@@ -466,10 +723,14 @@ if [ -z "$HTTP_PORT" ]; then
fi
if [ "$ACTION" = "create" ] || [ "$ACTION" = "start" ]; then
setup_or_update_repo "$INSTANCE_DIR" "$REPO_URL"
setup_or_update_repo "$INSTANCE_DIR" "$REPO_URL" "$APP_IMAGE_TAG"
[ -f "$INSTANCE_DIR/start.sh" ] || fail "start.sh im Zielrepository nicht gefunden: $INSTANCE_DIR"
if ! ensure_requested_app_image "$APP_IMAGE_TAG"; then
fail "App-Image konnte nicht geladen werden: ghcr.io/aiirondev/legendary-octo-garbanzo:$APP_IMAGE_TAG"
fi
write_env_file "$INSTANCE_DIR" "$HTTP_PORT" "$HTTPS_PORT" "$APP_IMAGE_TAG"
set_library_enabled "$INSTANCE_DIR" "$LIBRARY_ENABLED"
-109
View File
@@ -1,109 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MONGO_DBPATH="${MONGO_DBPATH:-$SCRIPT_DIR/.mongo-data}"
start_mongodb_if_needed() {
if ! command -v mongod >/dev/null 2>&1; then
echo "Error: mongod not found. Install MongoDB first."
exit 1
fi
if pgrep -x mongod >/dev/null 2>&1; then
echo "MongoDB already running."
return
fi
mkdir -p "$MONGO_DBPATH"
echo "Starting MongoDB with dbPath: $MONGO_DBPATH"
mongod --dbpath "$MONGO_DBPATH" --bind_ip 127.0.0.1 --port 27017 --fork --logpath "$SCRIPT_DIR/mongodb.log" || {
echo "Failed to start MongoDB. Check $SCRIPT_DIR/mongodb.log for details."
exit 1
}
}
start_mongodb_if_needed
# Clean up any existing MongoDB repos to avoid conflicts
echo "=== Cleaning up existing MongoDB repositories ==="
sudo rm -f /etc/apt/sources.list.d/mongodb*.list
sudo apt-key del 7F0CEB10 2930ADAE8CAF5059EE73BB4B58712A2291FA4AD5 20691EEC35216C63CAF66CE1656408E390CFB1F5 4B7C549A058F8B6B 2069827F925C2E182330D4D4B5BEA7232F5C6971 E162F504A20CDF15827F718D4B7C549A058F8B6B 9DA31620334BD75D9DCB49F368818C72E52529D4 F5679A222C647C87527C2F8CB00A0BD1E2C63C11 2023-02-15 > /dev/null 2>&1 || true
# Update system packages
echo "=== Updating system packages ==="
sudo apt update || { echo "Failed to update package lists"; exit 1; }
# Add MongoDB repository depending on OS (Ubuntu Server or Linux Mint)
echo "=== Adding MongoDB repository ==="
# Detect OS id from /etc/os-release
OS_ID=$(awk -F= '/^ID=/{print $2}' /etc/os-release | tr -d '"')
# Prefer Ubuntu base codename from /etc/os-release when available
UBUNTU_BASE_CODENAME=$(awk -F= '/^UBUNTU_CODENAME=/{print $2}' /etc/os-release | tr -d '"')
if [ -z "$UBUNTU_BASE_CODENAME" ]; then
UBUNTU_BASE_CODENAME=$(lsb_release -cs 2>/dev/null || awk -F= '/^VERSION_CODENAME=/{print $2}' /etc/os-release | tr -d '"')
fi
if [ "$OS_ID" = "linuxmint" ]; then
# Map Linux Mint codename to Ubuntu base codename when needed
MINT_CODENAME=$(lsb_release -cs 2>/dev/null || awk -F= '/^VERSION_CODENAME=/{print $2}' /etc/os-release | tr -d '"')
if [ -z "$UBUNTU_BASE_CODENAME" ] || [ "$UBUNTU_BASE_CODENAME" = "$MINT_CODENAME" ]; then
case "$MINT_CODENAME" in
xia) UBUNTU_BASE_CODENAME="noble" ;;
vanessa|vera|victoria) UBUNTU_BASE_CODENAME="jammy" ;;
ulyana|ulyssa|uma|una) UBUNTU_BASE_CODENAME="focal" ;;
esac
fi
echo "Detected Linux Mint ($MINT_CODENAME) → using Ubuntu base '$UBUNTU_BASE_CODENAME'"
elif [ "$OS_ID" = "ubuntu" ];
then
echo "Detected Ubuntu ($UBUNTU_BASE_CODENAME)"
else
echo "Non-Ubuntu/Mint OS detected ($OS_ID). Skipping MongoDB apt setup."
exit 1
fi
# Select MongoDB series per Ubuntu base codename
case "$UBUNTU_BASE_CODENAME" in
noble|jammy)
MONGO_SERIES="7.0" ;;
focal)
MONGO_SERIES="6.0" ;;
*)
echo "Unknown Ubuntu codename '$UBUNTU_BASE_CODENAME', defaulting to 7.0"
MONGO_SERIES="7.0" ;;
esac
# Use jammy repo path for noble until MongoDB publishes noble (avoid 404)
MONGO_APT_CODENAME="$UBUNTU_BASE_CODENAME"
if [ "$UBUNTU_BASE_CODENAME" = "noble" ]; then
MONGO_APT_CODENAME="jammy"
echo "Using jammy repo path for MongoDB on noble"
fi
# Install repo key and list using series and apt codename
wget -qO - https://www.mongodb.org/static/pgp/server-${MONGO_SERIES}.asc | sudo gpg --dearmor -o /usr/share/keyrings/mongodb-server-${MONGO_SERIES}.gpg
echo "deb [signed-by=/usr/share/keyrings/mongodb-server-${MONGO_SERIES}.gpg arch=amd64,arm64] https://repo.mongodb.org/apt/ubuntu ${MONGO_APT_CODENAME}/mongodb-org/${MONGO_SERIES} multiverse" | \
sudo tee /etc/apt/sources.list.d/mongodb-org-${MONGO_SERIES}.list
# Install MongoDB
sudo apt-get update || exit 1
sudo apt-get install -y mongodb-org || exit 1
if [[ -n "${CONDA_DEFAULT_ENV:-}" ]] && command -v conda >/dev/null 2>&1; then
conda deactivate || true
fi
source .venv/bin/activate
# Do not run apt here because third-party repos in dev containers can fail.
# We only check and provide the install hint if patchelf is missing.
if ! command -v patchelf >/dev/null 2>&1; then
echo "Error: patchelf is required for Nuitka standalone builds on Linux."
echo "Install with: sudo apt update && sudo apt install -y patchelf"
exit 1
fi
pip install --upgrade pip
pip install -U nuitka ordered-set zstandard flask flask-jwt-extended cryptography pyotp qrcode bleach pymongo
python -m nuitka --standalone --follow-imports --include-data-dir=templates=templates --include-data-dir=static=static --include-data-dir=data=data --assume-yes-for-downloads --output-dir=build --remove-output main.py
if [[ -x "./build/main.dist/main.bin" ]]; then
./build/main.dist/main.bin
else
echo "Build step finished but executable not found at ./build/main.dist/main.bin"
exit 1
fi
+155
View File
@@ -134,6 +134,18 @@
</div>
</section>
<section class="panel logs-panel">
<h2>Live-Logs (Core + Services)</h2>
<p class="logs-hint">Zeigt die letzten Zeilen der normalen Dienste direkt im Admin-Frontend an. Aktualisiert automatisch alle 20 Sekunden.</p>
<div class="logs-toolbar">
<label for="logSourceSelect">Quelle</label>
<select id="logSourceSelect"></select>
<button type="button" id="refreshLiveLogsBtn">Jetzt aktualisieren</button>
<span id="liveLogsUpdatedAt">Stand: -</span>
</div>
<pre id="liveLogsOutput" class="log-output">Lade Logs ...</pre>
</section>
<section class="instance-tools panel">
<h2>Instanz-Operationen</h2>
{% if instances %}
@@ -357,6 +369,48 @@ button,
.actions { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; }
.row-actions { gap: 0.4rem; }
.actions form { margin: 0; }
.logs-panel {
margin-top: 0;
}
.logs-hint {
margin-bottom: 0.6rem;
color: var(--term-muted);
}
.logs-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.55rem;
}
.logs-toolbar label,
.logs-toolbar span {
color: var(--term-muted);
font-size: 0.85rem;
}
.logs-toolbar select {
border: 1px solid var(--term-border);
border-radius: 8px;
background: #0b1114;
color: var(--term-text);
padding: 0.36rem 0.5rem;
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
}
.log-output {
margin: 0;
border: 1px solid var(--term-border);
border-radius: 10px;
background: #05090b;
color: #cbeed0;
min-height: 260px;
max-height: 520px;
overflow: auto;
padding: 0.72rem;
font-size: 0.8rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.inline-admin-form {
margin-top: 0.55rem;
display: grid;
@@ -436,6 +490,10 @@ td {
const pct = (value) => `${Number(value || 0).toFixed(1)} %`;
const clampPct = (value) => Math.max(0, Math.min(100, Number(value || 0)));
const historyLimit = 24;
const liveLogsState = {
sources: [],
selected: ''
};
const history = {
labels: [],
@@ -605,6 +663,85 @@ td {
}
};
const setLiveLogSources = (sources) => {
const select = document.getElementById('logSourceSelect');
if (!select) {
return;
}
const previous = liveLogsState.selected;
select.innerHTML = '';
(sources || []).forEach((source) => {
const option = document.createElement('option');
option.value = source.key;
option.textContent = source.label;
select.appendChild(option);
});
if (!sources || !sources.length) {
liveLogsState.sources = [];
liveLogsState.selected = '';
return;
}
liveLogsState.sources = sources;
const hasPrevious = sources.some((item) => item.key === previous);
liveLogsState.selected = hasPrevious ? previous : sources[0].key;
select.value = liveLogsState.selected;
};
const renderLiveLogs = () => {
const output = document.getElementById('liveLogsOutput');
const source = liveLogsState.sources.find((item) => item.key === liveLogsState.selected);
if (!output) {
return;
}
if (!source) {
output.textContent = 'Keine Log-Quelle verfügbar.';
return;
}
output.textContent = source.logs || 'Keine Ausgabe';
};
const updateLiveLogs = async () => {
try {
const response = await fetch('/admin/system/logs/live', { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const sources = [];
const core = data.core || {};
sources.push({
key: 'core',
label: `Core Docker (${core.ok ? 'ok' : 'error'})`,
logs: core.logs || 'Keine Ausgabe'
});
(data.services || []).forEach((service) => {
const name = service.service || 'service';
const status = service.status || 'unavailable';
sources.push({
key: `svc:${name}`,
label: `${name} (${status})`,
logs: service.logs || 'Keine Ausgabe'
});
});
setLiveLogSources(sources);
setText('liveLogsUpdatedAt', `Stand: ${data.generated_at || '-'}`);
renderLiveLogs();
} catch (error) {
setText('liveLogsUpdatedAt', 'Stand: Fehler beim Laden');
const output = document.getElementById('liveLogsOutput');
if (output) {
output.textContent = 'Live-Logs konnten nicht geladen werden.';
}
}
};
const setUsageBar = (fillId, labelId, valuePct) => {
const pctValue = clampPct(valuePct);
const fill = document.getElementById(fillId);
@@ -679,6 +816,24 @@ td {
updateSystem();
window.setInterval(updateSystem, 15000);
const logSourceSelect = document.getElementById('logSourceSelect');
if (logSourceSelect) {
logSourceSelect.addEventListener('change', (event) => {
liveLogsState.selected = event.target.value || '';
renderLiveLogs();
});
}
const refreshLiveLogsBtn = document.getElementById('refreshLiveLogsBtn');
if (refreshLiveLogsBtn) {
refreshLiveLogsBtn.addEventListener('click', () => {
updateLiveLogs();
});
}
updateLiveLogs();
window.setInterval(updateLiveLogs, 20000);
})();
</script>
{% endblock %}
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MONGO_DBPATH="${MONGO_DBPATH:-$SCRIPT_DIR/data/db}"
MONGO_LOGPATH="${MONGO_LOGPATH:-$SCRIPT_DIR/data/mongod.log}"
start_mongodb_if_needed() {
if ! command -v mongod >/dev/null 2>&1; then
echo "Error: mongod not found. Install MongoDB first."
exit 1
fi
if pgrep -x mongod >/dev/null 2>&1; then
echo "MongoDB already running."
return
fi
mkdir -p "$MONGO_DBPATH"
echo "Starting MongoDB with dbPath: $MONGO_DBPATH"
mongod --dbpath "$MONGO_DBPATH" --bind_ip 127.0.0.1 --port 27017 --fork --logpath "$MONGO_LOGPATH"
}
start_mongodb_if_needed