16 Commits

Author SHA1 Message Date
stefan 1690da3fab chore/ai-guardrails-centralization
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-06-02 13:32:00 +02:00
stefan cb6e0103e7 chore(ai): centralize guardrail scripts under .ai; add resolve_repo_root; add shims for .junie/.nolik; fix gitea contexts in release.yml
Co-authored-by: Junie <junie@jetbrains.com>
2026-06-02 13:27:23 +02:00
stefan 98d0bf0c7b docs: Turnier-Dokumente für 2026 hinzugefügt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-28 13:25:59 +02:00
stefan 0a90b57c2a docs: Ergänzung der Simka Core Server Dokumentation
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-22 13:13:24 +02:00
stefan 0ab62a2752 docs: README-Testbeschreibung aktualisiert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-22 11:36:34 +02:00
stefan 6070709bf2 docs: README-Testbeschreibung aktualisiert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-22 11:34:17 +02:00
stefan 763c2a9157 Test 2. Versuch zu committen
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-20 10:10:30 +00:00
StefanMo 4f715d10bb refactor: extrahiere ReiterLizenz in core-domain, aktualisiere Abhängigkeiten und behebe Windows-SQLite-Temp-Verzeichnisproblem 2026-05-12 23:33:48 +02:00
stefan 0b830eb675 feat: integriere VeranstaltungRepository und syncModule in Desktop-App
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-12 19:29:51 +02:00
stefan 4c37ecb952 refactor(build): redundante Variable im Gradle-Skript entfernt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-12 15:27:02 +02:00
stefan c25ef17a4a refactor(build): Typen in Gradle-Skript explizit hinzugefügt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-12 15:23:52 +02:00
stefan e5e3b4cfec refactor(build): Plugin-Anwendung in Gradle-Konfiguration vereinfacht
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-12 15:17:47 +02:00
stefan 7d064853e5 feat: optimiere Gradle-Konfiguration für bessere Build-Performance (JVM, Worker, Cache) und dokumentiere Änderungen
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-11 21:39:46 +02:00
stefan 387180c12c chore: entferne index.html
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-11 20:44:35 +02:00
stefan 49393d3eac feat: verbessere Build-Performance durch Standard-Deaktivierung von WASM und aktualisiere Dokumentation
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-05-11 20:44:24 +02:00
stefan e389fe9bce feat(desktop, network): Chat-Funktion hinzugefügt und P2P-Sync verbessert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-05-11 13:57:53 +02:00
56 changed files with 1272 additions and 1638 deletions
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/common.sh"
REPO_ROOT="$(resolve_repo_root)"
cd "$REPO_ROOT"
# check-docs-drift.sh
# Zweck: sehr schlanke Drift-Checks gegen die neue Doku-Struktur.
# - Kein Guidelines-System mehr.
# - Single Source of Truth: `docs/`
err=0
has() { grep -q "$2" "$1" || { echo "[DRIFT] '$2' fehlt in $1"; err=1; }; }
miss() { grep -q "$2" "$1" && { echo "[DRIFT] Veralteter Begriff '$2' in $1"; err=1; }; }
# Harte Altlast-Pfade dürfen nicht mehr vorkommen
if git grep -n "docs/00_Domain/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/00_Domain/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/adr/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/adr/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/c4/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/c4/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/how-to/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/how-to/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/reference/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/reference/' in docs/* gefunden"
err=1
fi
# Quelle der Wahrheit: Gateway-Technologie (sollte in Architektur/ADRs/C4 konsistent sein)
has docs/01_Architecture/ARCHITECTURE.md "Spring Cloud Gateway"
has docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Spring Cloud Gateway"
miss docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Ktor"
has docs/01_Architecture/c4/02-container-de.puml "Spring Cloud Gateway"
miss docs/01_Architecture/c4/02-container-de.puml "Ktor"
exit $err
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
# Common helpers for AI guardrail scripts
# Robustly resolve the repository root directory.
# Strategy: prefer Git; fallback to marker search upwards; last resort: current dir.
resolve_repo_root() {
local start
start="${1:-$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)}"
if command -v git >/dev/null 2>&1; then
if git -C "$start" rev-parse --show-toplevel >/dev/null 2>&1; then
git -C "$start" rev-parse --show-toplevel
return 0
fi
fi
local dir
dir="$(cd "$start" && pwd)"
while [ "$dir" != "/" ]; do
if [ -f "$dir/gradlew" ] || [ -f "$dir/settings.gradle.kts" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
pwd
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/common.sh"
REPO_ROOT="$(resolve_repo_root)"
cd "$REPO_ROOT"
mkdir -p build/diagrams
shopt -s nullglob
for f in docs/architecture/c4/*.puml; do
docker run --rm -v "$PWD":/data plantuml/plantuml -tsvg "/data/$f" -o "/data/build/diagrams"
echo "Rendered build/diagrams/$(basename "${f%.puml}").svg"
done
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/common.sh"
REPO_ROOT="$(resolve_repo_root)"
cd "$REPO_ROOT"
QUICK_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
--quick)
QUICK_MODE=true
shift
;;
--help|-h)
cat << 'EOF'
Docs Link-Validierung
USAGE:
./.ai/scripts/validate-links.sh [--quick]
BESCHREIBUNG:
Prüft Markdown-Links in `docs/**/*.md` auf gebrochene relative Pfade.
Ignoriert externe Links (http/https/mailto) sowie reine Anchors (#...).
OPTIONEN:
--quick Führt nur eine Teilmenge der Prüfungen durch (aktuell nicht implementiert).
EOF
exit 0
;;
*)
echo "[ERROR] Unbekannter Parameter: $1" >&2
exit 2
;;
esac
done
python3 - <<'PY'
import re
import sys
from pathlib import Path
from urllib.parse import unquote
root = Path.cwd()
docs_dir = root / "docs"
if not docs_dir.is_dir():
print(f"[ERROR] docs-Verzeichnis nicht gefunden: {docs_dir}", file=sys.stderr)
sys.exit(2)
# Veraltete Pfad-Prüfungen wurden entfernt; Fokus auf Link-Integrität.
FORBIDDEN_SUBSTRINGS = []
md_files = sorted(docs_dir.rglob("*.md"))
link_pattern = re.compile(r"\]\(([^)]+)\)")
errors = 0
def is_external(target: str) -> bool:
t = target.lower()
return t.startswith("http://") or t.startswith("https://") or t.startswith("mailto:")
def strip_fragment_and_query(target: str) -> str:
target = target.split("#", 1)[0]
target = target.split("?", 1)[0]
return target
for f in md_files:
text = f.read_text(encoding="utf-8", errors="replace")
for forbidden in FORBIDDEN_SUBSTRINGS:
if forbidden in text:
print(f"[ERROR] Veralteter Pfad '{forbidden}' in {f}")
errors += 1
for match in link_pattern.finditer(text):
target = match.group(1).strip()
if not target:
continue
if is_external(target):
continue
if target.startswith("#"):
continue
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
target = unquote(strip_fragment_and_query(target))
if target.startswith("/"):
continue
if ":" in target.split("/", 1)[0]:
# z.B. "vscode:..."
continue
resolved = (f.parent / target).resolve()
try:
resolved.relative_to(root.resolve())
except ValueError:
print(f"[ERROR] Link zeigt außerhalb des Repos: {f} -> {target}")
errors += 1
continue
if resolved.is_dir():
if not (resolved / "README.md").is_file():
print(f"[ERROR] Verlinktes Verzeichnis ohne README.md: {f} -> {target}")
errors += 1
continue
if not resolved.exists():
print(f"[ERROR] Broken link: {f} -> {target}")
errors += 1
if errors:
print(f"[ERROR] Link-Validierung fehlgeschlagen: {errors} Fehler")
sys.exit(1)
print(f"[OK] Link-Validierung erfolgreich: {len(md_files)} Markdown-Dateien geprüft")
PY
+1 -1
View File
@@ -193,7 +193,7 @@ secrets/
# =================================================================== # ===================================================================
TODO*.md TODO*.md
NOTES*.md NOTES*.md
**/.junie/ .junie/
# =================================================================== # ===================================================================
# Keep essential files (override exclusions) # Keep essential files (override exclusions)
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
desktop-tests: desktop-tests:
# Komplett deaktivierbar über Repo-Variable: Settings → Variables → DESKTOP_CI_ENABLED=true # Komplett deaktivierbar über Repo-Variable: Settings → Variables → DESKTOP_CI_ENABLED=true
# Zusätzlich: Für PlanBBuilds überspringen, wenn Commit-Message [planb] enthält # Zusätzlich: Für PlanBBuilds überspringen, wenn Commit-Message [planb] enthält
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(github.event.head_commit.message, '[planb]') }} if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
name: Compose Desktop — Tests (headless) & Build name: Compose Desktop — Tests (headless) & Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
+5 -5
View File
@@ -1,5 +1,5 @@
name: Build and Publish Docker Images name: Build and Publish Docker Images
run-name: Build & Publish by @${{ github.actor }} run-name: Build & Publish by @${{ gitea.actor }}
on: on:
push: push:
@@ -117,8 +117,8 @@ jobs:
images: ${{ env.REGISTRY_INTERNAL }}/${{ env.IMAGE_PREFIX }}/${{ matrix.image }} images: ${{ env.REGISTRY_INTERNAL }}/${{ env.IMAGE_PREFIX }}/${{ matrix.image }}
tags: | tags: |
type=ref,event=tag type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha,format=long,enable=${{ github.ref == 'refs/heads/main' }} type=sha,format=long,enable=${{ gitea.ref == 'refs/heads/main' }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -132,5 +132,5 @@ jobs:
provenance: false provenance: false
sbom: false sbom: false
build-args: | build-args: |
BUILD_DATE=${{ github.event.head_commit.timestamp || 'unknown' }} BUILD_DATE=${{ gitea.event.head_commit.timestamp || 'unknown' }}
VERSION=${{ github.sha }} VERSION=${{ gitea.sha }}
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
chmod +x install-conveyor.sh chmod +x install-conveyor.sh
./install-conveyor.sh ./install-conveyor.sh
fi fi
echo "$HOME/.conveyor/bin" >> $GITHUB_PATH echo "$HOME/.conveyor/bin" >> $GITEA_PATH
- name: Windows .msi mit Conveyor bauen - name: Windows .msi mit Conveyor bauen
run: | run: |
+1 -1
View File
@@ -5,7 +5,7 @@ on:
jobs: jobs:
no-hardcoded-versions: no-hardcoded-versions:
# Für Plan-B-Builds überspringen: Commit-Message enthält [planb] # Für Plan-B-Builds überspringen: Commit-Message enthält [planb]
if: ${{ !contains(github.event.head_commit.message, '[planb]') }} if: ${{ !contains(gitea.event.head_commit.message, '[planb]') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+16 -16
View File
@@ -23,7 +23,7 @@ jobs:
tag-release: tag-release:
name: 🏷️ Git-Tag setzen name: 🏷️ Git-Tag setzen
# Für Plan-B-Builds überspringen: Commit-Message enthält [planb] # Für Plan-B-Builds überspringen: Commit-Message enthält [planb]
if: ${{ !contains(github.event.head_commit.message, '[planb]') }} if: ${{ !contains(gitea.event.head_commit.message, '[planb]') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
version: ${{ steps.read-version.outputs.version }} version: ${{ steps.read-version.outputs.version }}
@@ -64,7 +64,7 @@ jobs:
fi fi
- name: Git-Tag erstellen & pushen - name: Git-Tag erstellen & pushen
if: steps.check-tag.outputs.already_tagged == 'false' && github.event.inputs.dry_run != 'true' if: steps.check-tag.outputs.already_tagged == 'false' && gitea.event.inputs.dry_run != 'true'
run: | run: |
TAG="${{ steps.read-version.outputs.tag }}" TAG="${{ steps.read-version.outputs.tag }}"
VERSION="${{ steps.read-version.outputs.version }}" VERSION="${{ steps.read-version.outputs.version }}"
@@ -80,7 +80,7 @@ jobs:
package-linux: package-linux:
name: 📦 Linux .deb Packaging name: 📦 Linux .deb Packaging
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein PlanB Commit # Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein PlanB Commit
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(github.event.head_commit.message, '[planb]') }} if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: tag-release needs: tag-release
@@ -88,11 +88,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup JDK 21 (Temurin) - name: Setup JDK 25 (Temurin)
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: '21' java-version: '25'
- name: Gradle cache - name: Gradle cache
uses: actions/cache@v4 uses: actions/cache@v4
@@ -128,7 +128,7 @@ jobs:
package-windows: package-windows:
name: 📦 Windows .msi Packaging name: 📦 Windows .msi Packaging
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein PlanB Commit # Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein PlanB Commit
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(github.event.head_commit.message, '[planb]') }} if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
runs-on: windows-latest runs-on: windows-latest
needs: tag-release needs: tag-release
@@ -136,11 +136,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup JDK 21 (Temurin) - name: Setup JDK 25 (Temurin)
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: '21' java-version: '25'
- name: Gradle cache - name: Gradle cache
uses: actions/cache@v4 uses: actions/cache@v4
@@ -179,11 +179,11 @@ jobs:
steps: steps:
- name: Summary ausgeben - name: Summary ausgeben
run: | run: |
echo "## 🚀 Release ${{ needs.tag-release.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "## 🚀 Release ${{ needs.tag-release.outputs.version }}" >> $GITEA_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITEA_STEP_SUMMARY
echo "| Artefakt | Status |" >> $GITHUB_STEP_SUMMARY echo "| Artefakt | Status |" >> $GITEA_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY echo "|----------|--------|" >> $GITEA_STEP_SUMMARY
echo "| Linux .deb | ${{ needs.package-linux.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Linux .deb | ${{ needs.package-linux.result }} |" >> $GITEA_STEP_SUMMARY
echo "| Windows .msi | ${{ needs.package-windows.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Windows .msi | ${{ needs.package-windows.result }} |" >> $GITEA_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITEA_STEP_SUMMARY
echo "**Git-Tag:** \`${{ needs.tag-release.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY echo "**Git-Tag:** \`${{ needs.tag-release.outputs.tag }}\`" >> $GITEA_STEP_SUMMARY
+4 -40
View File
@@ -1,43 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# check-docs-drift.sh # Shim: Weiterleitung auf zentrale Guardrail in .ai/
# Zweck: sehr schlanke Drift-Checks gegen die neue Doku-Struktur. SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
# - Kein Guidelines-System mehr. ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
# - Single Source of Truth: `docs/` exec "$ROOT_DIR/.ai/scripts/check-docs-drift.sh" "$@"
err=0
has() { grep -q "$2" "$1" || { echo "[DRIFT] '$2' fehlt in $1"; err=1; }; }
miss() { grep -q "$2" "$1" && { echo "[DRIFT] Veralteter Begriff '$2' in $1"; err=1; }; }
# Harte Altlast-Pfade dürfen nicht mehr vorkommen
if git grep -n "docs/00_Domain/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/00_Domain/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/adr/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/adr/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/c4/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/c4/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/how-to/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/how-to/' in docs/* gefunden"
err=1
fi
if git grep -n "docs/reference/" -- docs >/dev/null 2>&1; then
echo "[DRIFT] Veralteter Pfad 'docs/reference/' in docs/* gefunden"
err=1
fi
# Quelle der Wahrheit: Gateway-Technologie (sollte in Architektur/ADRs/C4 konsistent sein)
has docs/01_Architecture/ARCHITECTURE.md "Spring Cloud Gateway"
has docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Spring Cloud Gateway"
miss docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Ktor"
has docs/01_Architecture/c4/02-container-de.puml "Spring Cloud Gateway"
miss docs/01_Architecture/c4/02-container-de.puml "Ktor"
exit $err
+4 -6
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
mkdir -p build/diagrams # Shim: Weiterleitung auf zentrale Guardrail in .ai/
shopt -s nullglob SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
for f in docs/architecture/c4/*.puml; do ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
docker run --rm -v "$PWD":/data plantuml/plantuml -tsvg "/data/$f" -o "/data/build/diagrams" exec "$ROOT_DIR/.ai/scripts/render-plantuml.sh" "$@"
echo "Rendered build/diagrams/$(basename "${f%.puml}").svg"
done
+4 -133
View File
@@ -1,136 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# validate-links.sh - Link-Validierung für Projektdokumentation (`docs/**`). # Shim: Weiterleitung auf zentrale Guardrail in .ai/
# Zweck: Guardrail für die "Docs-as-Code"-Strategie. SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" exec "$ROOT_DIR/.ai/scripts/validate-links.sh" "$@"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$PROJECT_ROOT"
QUICK_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
--quick)
QUICK_MODE=true
shift
;;
--help|-h)
cat << 'EOF'
Docs Link-Validierung
USAGE:
./.junie/scripts/validate-links.sh [--quick]
BESCHREIBUNG:
Prüft Markdown-Links in `docs/**/*.md` auf gebrochene relative Pfade.
Ignoriert externe Links (http/https/mailto) sowie reine Anchors (#...).
OPTIONEN:
--quick Führt nur eine Teilmenge der Prüfungen durch (aktuell nicht implementiert).
EOF
exit 0
;;
*)
echo "[ERROR] Unbekannter Parameter: $1" >&2
exit 2
;;
esac
done
python3 - <<'PY'
import os
import re
import sys
from pathlib import Path
from urllib.parse import unquote
root = Path.cwd()
docs_dir = root / "docs"
if not docs_dir.is_dir():
print(f"[ERROR] docs-Verzeichnis nicht gefunden: {docs_dir}", file=sys.stderr)
sys.exit(2)
# Veraltete Pfad-Prüfungen wurden entfernt, da sie zu wartungsintensiv waren.
# Das Skript konzentriert sich nun auf die Validierung der Link-Integrität.
FORBIDDEN_SUBSTRINGS = []
md_files = sorted(docs_dir.rglob("*.md"))
link_pattern = re.compile(r"\]\(([^)]+)\)")
errors = 0
def is_external(target: str) -> bool:
t = target.lower()
return t.startswith("http://") or t.startswith("https://") or t.startswith("mailto:")
def strip_fragment_and_query(target: str) -> str:
# remove fragment and query parts
target = target.split("#", 1)[0]
target = target.split("?", 1)[0]
return target
for f in md_files:
text = f.read_text(encoding="utf-8", errors="replace")
for forbidden in FORBIDDEN_SUBSTRINGS:
if forbidden in text:
print(f"[ERROR] Veralteter Pfad '{forbidden}' in {f}")
errors += 1
for match in link_pattern.finditer(text):
target = match.group(1).strip()
if not target:
continue
if is_external(target):
continue
if target.startswith("#"):
continue
# drop angle brackets <...> used in markdown for urls with spaces
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
target = unquote(strip_fragment_and_query(target))
# ignore absolute paths in the repo (we treat them as doc-style links; validate only if relative)
if target.startswith("/"):
continue
# ignore non-file targets (e.g. empty or protocol-less anchors)
if ":" in target.split("/", 1)[0]:
# things like "vscode:..." etc.
continue
# treat as file path relative to markdown file
resolved = (f.parent / target).resolve()
# keep validation within repo
try:
resolved.relative_to(root.resolve())
except ValueError:
print(f"[ERROR] Link zeigt außerhalb des Repos: {f} -> {target}")
errors += 1
continue
# allow directories if they contain README.md
if resolved.is_dir():
if not (resolved / "README.md").is_file():
print(f"[ERROR] Verlinktes Verzeichnis ohne README.md: {f} -> {target}")
errors += 1
continue
if not resolved.exists():
print(f"[ERROR] Broken link: {f} -> {target}")
errors += 1
if errors:
print(f"[ERROR] Link-Validierung fehlgeschlagen: {errors} Fehler")
sys.exit(1)
print(f"[OK] Link-Validierung erfolgreich: {len(md_files)} Markdown-Dateien geprüft")
PY
+21
View File
@@ -0,0 +1,21 @@
# .aiignore - Verhindert Token-Waste für Nolik
# Abhängigkeiten & Binaries
build/
.gradle/
*.jar
*.deb
*.msi
# Sensible Daten (auch lokal!)
.env
.env.*
config/docker/certs/
*.pem
*.jks
postgres-data/
valkey-data/
# Doku-Builds (Nolik soll die Source-Files in docs/ lesen, nicht die HTML-Exporte)
build/dokka/
docs/Neumarkt2026/*.pdf
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
exec "$ROOT_DIR/.ai/scripts/check-docs-drift.sh" "$@"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
exec "$ROOT_DIR/.ai/scripts/validate-links.sh" "$@"
+7 -1
View File
@@ -20,7 +20,7 @@ Die gesamte Projektdokumentation (Architektur, Fachdomäne, Entwickler-Anleitung
| [03_Domain](./docs/03_Domain) | Fachlichkeit, Turnierregeln, Entities | | [03_Domain](./docs/03_Domain) | Fachlichkeit, Turnierregeln, Entities |
| [07_Infrastructure](./docs/07_Infrastructure) | Docker, Keycloak, CI/CD, Zora-Infrastruktur | | [07_Infrastructure](./docs/07_Infrastructure) | Docker, Keycloak, CI/CD, Zora-Infrastruktur |
Wesentliche Architektur-Referenz: [OfflineFirst Desktop & Backend (Kurzkonzept)](./docs/01_Architecture/konzept-offline-first-desktop-backend-de.md) Wesentliche Architektur-Referenz: [OfflineFirst Desktop & Backend (Kurzkonzept)](./docs/01_Architecture/Concepts/konzept-offline-first-desktop-backend-de.md)
--- ---
@@ -113,3 +113,9 @@ Beiträge sind willkommen. Bitte lies zunächst die Entwickler-Guides unter [`do
## 📜 Lizenz ## 📜 Lizenz
Dieses Projekt steht unter der [MIT License](LICENSE). Dieses Projekt steht unter der [MIT License](LICENSE).
---
## Test
Das ist der 2. Versuch über Remote zu Committen
@@ -8,7 +8,7 @@ import io.valkey.springframework.data.valkey.core.ValkeyTemplate
import io.valkey.springframework.data.valkey.serializer.StringValkeySerializer import io.valkey.springframework.data.valkey.serializer.StringValkeySerializer
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.testcontainers.containers.GenericContainer import org.testcontainers.containers.GenericContainer
@@ -70,7 +70,7 @@ class ValkeyDistributedCachePerformanceTest {
} }
@Test @Test
fun `test cache performance with high concurrent access`() = runTest { fun `test cache performance with high concurrent access`() = runBlocking {
logger.info { "Starting concurrent access test" } logger.info { "Starting concurrent access test" }
val numberOfCoroutines = 100 val numberOfCoroutines = 100
val operationsPerCoroutine = 50 val operationsPerCoroutine = 50
@@ -2,6 +2,7 @@
package at.mocode.zns.importer package at.mocode.zns.importer
import at.mocode.core.domain.model.ReiterLizenz
import at.mocode.masterdata.domain.repository.* import at.mocode.masterdata.domain.repository.*
import at.mocode.zns.parser.ZnsFunktionaerParser import at.mocode.zns.parser.ZnsFunktionaerParser
import at.mocode.zns.parser.ZnsPferdParser import at.mocode.zns.parser.ZnsPferdParser
@@ -3,6 +3,7 @@
package at.mocode.masterdata.domain.model package at.mocode.masterdata.domain.model
import at.mocode.core.domain.model.DatenQuelleE import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.ReiterLizenz
import at.mocode.core.domain.model.ReiterLizenzKlasseE import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.LocalDateSerializer import at.mocode.core.domain.serialization.LocalDateSerializer
@@ -14,16 +15,6 @@ import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@Serializable
data class ReiterLizenz(
@Serializable(with = UuidSerializer::class)
val lizenzId: Uuid = Uuid.random(),
val lizenzTyp: String, // STARTKARTE, REITERLIZENZ, FAHRLIZENZ
val kuerzel: String,
@Serializable(with = LocalDateSerializer::class)
val gueltigBis: LocalDate? = null
)
/** /**
* Domain model representing a rider (Reiter) in the actor-context. * Domain model representing a rider (Reiter) in the actor-context.
* *
@@ -4,10 +4,10 @@ package at.mocode.masterdata.infrastructure.persistence.reiter
import at.mocode.core.domain.model.DatenQuelleE import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.ReiterAltersKlasseE import at.mocode.core.domain.model.ReiterAltersKlasseE
import at.mocode.core.domain.model.ReiterLizenz
import at.mocode.core.domain.model.ReiterLizenzKlasseE import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.utils.database.DatabaseFactory import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.masterdata.domain.model.Reiter import at.mocode.masterdata.domain.model.Reiter
import at.mocode.masterdata.domain.model.ReiterLizenz
import at.mocode.masterdata.domain.repository.ReiterRepository import at.mocode.masterdata.domain.repository.ReiterRepository
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.and
+48 -28
View File
@@ -38,7 +38,7 @@ plugins {
// ### ALLPROJECTS CONFIGURATION ### // ### ALLPROJECTS CONFIGURATION ###
// ################################################################## // ##################################################################
val isWasmEnabled = findProperty("enableWasm")?.toString()?.toBoolean() ?: false val isWasmEnabled: Boolean = findProperty("enableWasm")?.toString()?.toBoolean() ?: false
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Zentrale Versionierung — liest version.properties (SemVer) // Zentrale Versionierung — liest version.properties (SemVer)
@@ -47,10 +47,10 @@ val versionProps =
java.util.Properties().also { props -> java.util.Properties().also { props ->
rootProject.file("version.properties").inputStream().use { props.load(it) } rootProject.file("version.properties").inputStream().use { props.load(it) }
} }
val vMajor = versionProps.getProperty("VERSION_MAJOR", "1") val vMajor: String = versionProps.getProperty("VERSION_MAJOR", "1")
val vMinor = versionProps.getProperty("VERSION_MINOR", "0") val vMinor: String = versionProps.getProperty("VERSION_MINOR", "0")
val vPatch = versionProps.getProperty("VERSION_PATCH", "0") val vPatch: String = versionProps.getProperty("VERSION_PATCH", "0")
val vQualifier = versionProps.getProperty("VERSION_QUALIFIER", "").trim() val vQualifier: String = versionProps.getProperty("VERSION_QUALIFIER", "").trim()
val semVer = if (vQualifier.isBlank()) "$vMajor.$vMinor.$vPatch" else "$vMajor.$vMinor.$vPatch-$vQualifier" val semVer = if (vQualifier.isBlank()) "$vMajor.$vMinor.$vPatch" else "$vMajor.$vMinor.$vPatch-$vQualifier"
allprojects { allprojects {
@@ -113,7 +113,7 @@ subprojects {
// (A) Source map configuration is handled via `gradle.properties` (global Kotlin/JS settings) // (A) Source map configuration is handled via `gradle.properties` (global Kotlin/JS settings)
// to avoid compiler-flag incompatibilities across toolchains. // to avoid compiler-flag incompatibilities across toolchains.
// (B) Conditional Wasm/JS Target handling based on `enableWasm` property // (B) Conditional Wasm/JS Target handling based on the ` enableWasm ` property
// This significantly reduces build times during Desktop development. // This significantly reduces build times during Desktop development.
// Flag is defined at the beginning of the script. // Flag is defined at the beginning of the script.
@@ -176,20 +176,30 @@ subprojects {
// Applies to all Exec-based tasks (covers Yarn/NPM invocations used by Kotlin JS plugin) // Applies to all Exec-based tasks (covers Yarn/NPM invocations used by Kotlin JS plugin)
tasks.withType<Exec>().configureEach { tasks.withType<Exec>().configureEach {
// Merge existing NODE_OPTIONS with --no-deprecation // Merge existing NODE_OPTIONS with --no-deprecation
val current = (environment["NODE_OPTIONS"] as String?) ?: System.getenv("NODE_OPTIONS") val current: String? = (environment["NODE_OPTIONS"] as String?) ?: System.getenv("NODE_OPTIONS")
val merged = if (current.isNullOrBlank()) "--no-deprecation" else "$current --no-deprecation" val merged: String = if (current.isNullOrBlank()) "--no-deprecation" else "$current --no-deprecation"
environment("NODE_OPTIONS", merged) environment("NODE_OPTIONS", merged)
// Also set the legacy switch to silence warnings entirely // Also set the legacy switch to silence warnings entirely
environment("NODE_NO_WARNINGS", "1") environment("NODE_NO_WARNINGS", "1")
// Set a Chrome binary path to avoid snap permission issues // Set a Chrome binary path to avoid snap permission issues
environment("CHROME_BIN", "/usr/bin/google-chrome-stable") if (System.getProperty("os.name").contains("Linux", ignoreCase = true)) {
environment("CHROMIUM_BIN", "/usr/bin/chromium") environment("CHROME_BIN", "/usr/bin/google-chrome-stable")
environment("PUPPETEER_EXECUTABLE_PATH", "/usr/bin/chromium") environment("CHROMIUM_BIN", "/usr/bin/chromium")
environment("PUPPETEER_EXECUTABLE_PATH", "/usr/bin/chromium")
}
} }
// ------------------------------ // ------------------------------
// Detekt & Ktlint default setup // Detekt & Ktlint default setup
// ------------------------------ // ------------------------------
// PERFORMANCE: Deaktiviert standardmäßig in jedem Build, nur explizit ausführen
tasks.withType<Detekt>().configureEach {
enabled = project.hasProperty("runStaticAnalysis")
}
tasks.matching { it.name == "ktlintCheck" }.configureEach {
enabled = project.hasProperty("runStaticAnalysis")
}
plugins.withId("io.gitlab.arturbosch.detekt") { plugins.withId("io.gitlab.arturbosch.detekt") {
extensions.configure(DetektExtension::class.java) { extensions.configure(DetektExtension::class.java) {
buildUponDefaultConfig = true buildUponDefaultConfig = true
@@ -268,7 +278,6 @@ tasks.register("checkBundleBudget") {
} }
shells.forEach { shell -> shells.forEach { shell ->
val key = shell.path.trimStart(':').replace(':', '/') // or use a colon form for budgets keys below
val colonKey = shell.path.trimStart(':').replace('/', ':').trim() // ensure ":a:b:c" val colonKey = shell.path.trimStart(':').replace('/', ':').trim() // ensure ":a:b:c"
// Budgets are keyed by a Gradle path with colons but without leading colon in config for readability // Budgets are keyed by a Gradle path with colons but without leading colon in config for readability
val budgetKeyCandidates = val budgetKeyCandidates =
@@ -363,8 +372,8 @@ tasks.register("staticAnalysis") {
// Apply Dokka (V2) automatically to Kotlin subprojects // Apply Dokka (V2) automatically to Kotlin subprojects
subprojects { subprojects {
plugins.withId("org.jetbrains.kotlin.jvm") { apply(plugin = "org.jetbrains.dokka") } plugins.withId("org.jetbrains.kotlin.jvm") { pluginManager.apply("org.jetbrains.dokka") }
plugins.withId("org.jetbrains.kotlin.multiplatform") { apply(plugin = "org.jetbrains.dokka") } plugins.withId("org.jetbrains.kotlin.multiplatform") { pluginManager.apply("org.jetbrains.dokka") }
} }
// Aggregate tasks to build multi-module docs in Markdown (GFM) and HTML // Aggregate tasks to build multi-module docs in Markdown (GFM) and HTML
@@ -373,27 +382,36 @@ val dokkaAll =
tasks.register("dokkaAll") { tasks.register("dokkaAll") {
group = "documentation" group = "documentation"
description = "Builds Dokka (V2) for all modules and aggregates outputs under build/dokka/all" description = "Builds Dokka (V2) for all modules and aggregates outputs under build/dokka/all"
// Trigger Dokka generation in all subprojects that have the Dokka plugin // PERFORMANCE: Nur ausführen wenn explizit gefordert
dependsOn( enabled = project.hasProperty("runDokka")
// Capture required values for configuration cache
val rootBuildDir = layout.buildDirectory.get().asFile
val subprojectData =
subprojects subprojects
.filter { it.plugins.hasPlugin("org.jetbrains.dokka") } .filter { it.plugins.hasPlugin("org.jetbrains.dokka") }
.map { "${it.path}:dokkaGenerate" }, .map { p ->
) Triple(p.path, p.name, p.layout.buildDirectory.get().asFile)
}
// Trigger Dokka generation in all subprojects that have the Dokka plugin
dependsOn(subprojectData.map { "${it.first}:dokkaGenerate" })
doLast { doLast {
val dest = layout.buildDirectory.dir("dokka/all").get().asFile val dest = File(rootBuildDir, "dokka/all")
if (dest.exists()) dest.deleteRecursively() if (dest.exists()) dest.deleteRecursively()
dest.mkdirs() dest.mkdirs()
val modules = mutableListOf<Pair<String, String>>() val modules = mutableListOf<Pair<String, String>>()
subprojects.filter { it.plugins.hasPlugin("org.jetbrains.dokka") }.forEach { p -> subprojectData.forEach { (pPath, pName, pBuildDir) ->
// Dokka V2 writes into build/dokka/html // Dokka V2 writes into build/dokka/html
val outHtml = p.layout.buildDirectory.dir("dokka/html").get().asFile val outHtml = File(pBuildDir, "dokka/html")
if (outHtml.exists()) { if (outHtml.exists()) {
val modulePath = p.path.trimStart(':').replace(':', '/') val modulePath = pPath.trimStart(':').replace(':', '/')
val targetDir = File(dest, modulePath) val targetDir = File(dest, modulePath)
outHtml.copyRecursively(targetDir, overwrite = true) outHtml.copyRecursively(targetDir, overwrite = true)
modules.add(p.name to modulePath) modules.add(pName to modulePath)
} }
} }
@@ -452,14 +470,16 @@ tasks.register("docs") {
// Apply Node warning suppression on root project Exec tasks as well // Apply Node warning suppression on root project Exec tasks as well
// Ensures aggregated Kotlin/JS tasks created at root (e.g., kotlinNpmInstall) inherit the env // Ensures aggregated Kotlin/JS tasks created at root (e.g., kotlinNpmInstall) inherit the env
tasks.withType<Exec>().configureEach { tasks.withType<Exec>().configureEach {
val current = (environment["NODE_OPTIONS"] as String?) ?: System.getenv("NODE_OPTIONS") val current: String? = (environment["NODE_OPTIONS"] as String?) ?: System.getenv("NODE_OPTIONS")
val merged = if (current.isNullOrBlank()) "--no-deprecation" else "$current --no-deprecation" val merged: String = if (current.isNullOrBlank()) "--no-deprecation" else "$current --no-deprecation"
environment("NODE_OPTIONS", merged) environment("NODE_OPTIONS", merged)
environment("NODE_NO_WARNINGS", "1") environment("NODE_NO_WARNINGS", "1")
// Set a Chrome binary path to avoid snap permission issues // Set a Chrome binary path to avoid snap permission issues
environment("CHROME_BIN", "/usr/bin/google-chrome-stable") if (System.getProperty("os.name").contains("Linux", ignoreCase = true)) {
environment("CHROMIUM_BIN", "/usr/bin/chromium") environment("CHROME_BIN", "/usr/bin/google-chrome-stable")
environment("PUPPETEER_EXECUTABLE_PATH", "/usr/bin/chromium") environment("CHROMIUM_BIN", "/usr/bin/chromium")
environment("PUPPETEER_EXECUTABLE_PATH", "/usr/bin/chromium")
}
} }
tasks.wrapper { tasks.wrapper {
+12 -13
View File
@@ -1,25 +1,20 @@
# ============================================================================= include required("/stdlib/jdk/21/amazon.conf")
# Conveyor Configuration for Meldestelle Desktop App
# =============================================================================
include required("/stdlib/jdk/21/openjdk.conf")
include required("https://raw.githubusercontent.com/hydraulic-software/conveyor/master/configs/jvm/extract-native-libraries.conf") include required("https://raw.githubusercontent.com/hydraulic-software/conveyor/master/configs/jvm/extract-native-libraries.conf")
app { app {
display-name = "Meldestelle" display-name = "Meldestelle"
rdns-name = "at.mocode.meldestelle" rdns-name = "at.mocode.meldestelle"
vendor = "mo-code.at" vendor = "mo-code.at"
contact-email = "support@mo-code.at" contact-email = "support@mo-code.at"
version = "1.0.0" version = "1.0.1"
description = "ÖTO-konforme Turnier-Meldestelle Desktop App" description = "ÖTO-konforme Turnier-Meldestelle Profi Desktop App"
# Ziel-Plattformen: Windows und Linux (AMD/Intel 64-bit) # Ziel-Plattformen: Windows und Linux
machines = [ windows.amd64, linux.amd64.glibc ] machines = [ windows.amd64, linux.amd64.glibc ]
site.base-url = "localhost" site.base-url = "localhost"
# Wir geben nur den Ordner an, Conveyor findet die icon.png darin automatisch # Icons werden im Ordner gesucht
icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources" icons = "frontend/shells/meldestelle-desktop/src/jvmMain/resources/icon.png"
jvm { jvm {
gui { gui {
@@ -27,8 +22,8 @@ app {
} }
jvm-options = [ jvm-options = [
"-Xms128m", "-Xms256m",
"-Xmx512m", "-Xmx1024m",
"-Dfile.encoding=UTF-8", "-Dfile.encoding=UTF-8",
"--enable-native-access=ALL-UNNAMED" "--enable-native-access=ALL-UNNAMED"
] ]
@@ -42,6 +37,10 @@ app {
menu-group = "Meldestelle" menu-group = "Meldestelle"
desktop-shortcut = true desktop-shortcut = true
} }
linux {
debian.control.depends = "libasound2, libgl1-mesa-glx, libx11-6"
}
} }
conveyor.compatibility-level = 22 conveyor.compatibility-level = 22
@@ -0,0 +1,19 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.core.domain.model
import at.mocode.core.domain.serialization.LocalDateSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class ReiterLizenz(
@Serializable(with = UuidSerializer::class)
val lizenzId: Uuid = Uuid.random(),
val lizenzTyp: String, // STARTKARTE, REITERLIZENZ, FAHRLIZENZ
val kuerzel: String,
@Serializable(with = LocalDateSerializer::class)
val gueltigBis: LocalDate? = null
)
@@ -1,10 +1,10 @@
package at.mocode.zns.parser package at.mocode.zns.parser
import at.mocode.core.domain.model.DatenQuelleE import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.ReiterLizenz
import at.mocode.core.domain.model.ReiterLizenzKlasseE import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.utils.parser.FixedWidthLineReader import at.mocode.core.utils.parser.FixedWidthLineReader
import at.mocode.masterdata.domain.model.Reiter import at.mocode.masterdata.domain.model.Reiter
import at.mocode.masterdata.domain.model.ReiterLizenz
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
+1
View File
@@ -88,6 +88,7 @@ Fokus: Physische Implementierung der Turnier-Hierarchie und technisches Onboardi
* [x] **Client-Konfiguration:** Master kann nun Clients in der UI hinzufügen und bearbeiten. * [x] **Client-Konfiguration:** Master kann nun Clients in der UI hinzufügen und bearbeiten.
* [x] **Master-UX:** Konfiguration beim Start nicht mehr zwangsgesperrt. * [x] **Master-UX:** Konfiguration beim Start nicht mehr zwangsgesperrt.
* [x] **Cross-Packaging (Conveyor):** Windows-Build auf Linux-CI ermöglicht (x64-Abhängigkeit identifiziert). * [x] **Cross-Packaging (Conveyor):** Windows-Build auf Linux-CI ermöglicht (x64-Abhängigkeit identifiziert).
* [x] **Build-Performance:** WASM standardmäßig deaktiviert, um Desktop-Build-Zeiten zu reduzieren (11.05.2026).
* [ ] **PoC Verifikation:** 🔴 **BLOCKIERT** (Log 483: ARM64-Runner inkompatibel mit Conveyor-Binary; Workflow auf * [ ] **PoC Verifikation:** 🔴 **BLOCKIERT** (Log 483: ARM64-Runner inkompatibel mit Conveyor-Binary; Workflow auf
manuell gesetzt). manuell gesetzt).
@@ -0,0 +1,92 @@
# 🛠️ Guide: Conveyor Installation
Dieses Dokument beschreibt die Installation von **Hydraulic Conveyor** auf verschiedenen Linux-Distributionen (Ubuntu
26.04 und Fedora 44).
---
## 1. Ubuntu 26.04 (Debian-basiert)
Der am einfachsten Weg für Ubuntu ist der direkte Download des `.deb`-Pakets. Dieses konfiguriert bei der Installation
automatisch das APT-Repository für zukünftige Updates.
### Installation via .deb (Empfohlen)
```bash
# Aktuelles Paket herunterladen (Beispiel v12.0 - bitte Version ggf. anpassen)
VERSION="12.0"
curl -L https://downloads.hydraulic.dev/conveyor/conveyor_${VERSION}_amd64.deb -o conveyor.deb
# Installieren (konfiguriert auch das Repo automatisch)
sudo apt update
sudo apt install ./conveyor.deb
```
---
## 2. Fedora 44 (RPM-basiert)
Für Fedora wird die Installation via Tarball empfohlen, da Conveyor als autarkes Binary geliefert wird.
### Installation via Tarball (Systemweit)
Dies ist der zuverlässigste Weg für Fedora:
```bash
# Version definieren (Beispiel v12.0, bitte aktuelle Version prüfen)
VERSION="12.0"
curl -L https://downloads.hydraulic.dev/conveyor/conveyor-${VERSION}-linux-amd64.tar.gz -o conveyor.tar.gz
# Entpacken nach /opt
sudo tar -xzf conveyor.tar.gz -C /opt/
sudo ln -s /opt/conveyor-${VERSION}/bin/conveyor /usr/local/bin/conveyor
# Test
conveyor --version
```
### Installation via RPM (Falls verfügbar)
Prüfen Sie auf der Hydraulic Website, ob mittlerweile ein natives RPM-Repository existiert. Falls ja:
```bash
sudo dnf config-manager --add-repo https://conveyor.hydraulic.dev/rpm/conveyor.repo
sudo dnf install conveyor
```
---
## 3. Post-Installation & Verifikation
Nach der Installation sollten Sie den Pfad und die Version prüfen:
```bash
conveyor --version
```
### Root-Key Initialisierung
Beim ersten Ausführen von `conveyor` wird ein Root-Key generiert. **Sichern Sie diesen unbedingt!**
```bash
conveyor make site
```
*Folgen Sie den Anweisungen im Terminal zur Sicherung des Root-Keys.*
---
## 4. Troubleshooting
### Fehlende Bibliotheken (Fedora)
Falls Conveyor native Hilfe benötigt (z.B. für Icons oder Kompression):
```bash
sudo dnf install libX11 libXext libXrender
```
### Berechtigungen
Stellen Sie sicher, dass Ihr Benutzer in der Gruppe `docker` ist, falls Sie Conveyor innerhalb von Containern nutzen
oder Docker-basierte Inputs verwenden (für dieses Projekt primär lokal relevant).
+87 -73
View File
@@ -1,98 +1,112 @@
# 📦 Guide: Desktop App Packaging (Linux, Windows, macOS) # 📦 Guide: Desktop App Packaging (Conveyor & Gradle)
Dieses Dokument beschreibt, wie die Meldestelle Desktop App für verschiedene Betriebssysteme paketiert wird. Wir nutzen einen hybriden Ansatz aus **Gradle (Compose-Desktop)** für lokale Linux-Builds und **Conveyor** für das Cross-Packaging (Windows/macOS) von Linux aus. Dieses Dokument beschreibt den professionellen Packaging-Prozess für die Meldestelle Desktop App. Wir nutzen **Conveyor** als primäres Werkzeug für das Cross-Platform Packaging (Windows, Linux, macOS), da es stabile Installer inklusive signierter Updates und gebündelter JREs erzeugt.
--- ---
## 1. Voraussetzungen ## 1. Strategie: Conveyor vs. Gradle
### Linux (Entwicklungsrechner / Fedora) | Feature | Conveyor (Empfohlen) | Gradle (Compose Plugin) |
Um native Pakete bauen zu können, müssen folgende Werkzeuge auf dem System vorhanden sein: | :--- | :--- | :--- |
| **Zielgruppe** | Endanwender (Produktion) | Entwickler (Lokaler Test) |
| **Plattformen** | Windows (.msix), Linux (.deb), macOS | Nur Host-OS (Linux auf Linux) |
| **Updates** | Automatisch integriert | Manuell |
| **JRE** | Amazon Corretto (isoliert) | System JRE oder Toolchain |
```bash ---
# Für RPM-Pakete (Fedora)
sudo dnf install rpm-build
# Für DEB-Pakete (Ubuntu/Debian) ## 2. Cross-Packaging mit Conveyor
sudo apt install dpkg-dev fakeroot
```
### Conveyor (Cross-Packaging Tool) Conveyor ist so konfiguriert, dass es von Linux aus Pakete für alle Zielsysteme schnüren kann.
Conveyor wird benötigt, um von Linux aus Windows-Installer (.msi) oder macOS-Pakete zu erzeugen.
**Installation auf Fedora/Linux:** ### Voraussetzungen
Da automatisierte Skripte manchmal unzuverlässig sind, hier der direkte Weg über das Binär-Paket: 1. **JAR-Dateien:** Die App muss kompiliert sein:
1. **Tarball herunterladen:**
Besuchen Sie [https://downloads.hydraulic.dev/conveyor/download.html](https://downloads.hydraulic.dev/conveyor/download.html) und laden Sie die neueste `linux-amd64.tar.gz` Datei herunter.
2. **Manuelle Installation:**
```bash ```bash
# Entpacken ./gradlew :frontend:shells:meldestelle-desktop:jvmJar
tar -xvf hydraulic-conveyor-*-linux-amd64.tar.gz
# In den Pfad verschieben
sudo mv conveyor /usr/local/bin/
``` ```
2. **Icons:** Das System sucht nach `icon.png` in `frontend/shells/meldestelle-desktop/src/jvmMain/resources/`.
3. **Verifizieren:** ### Pakete bauen
```bash Führen Sie Conveyor im Projekt-Root aus:
conveyor --version
```
---
## 2. Lokale Linux-Builds (Gradle)
Die schnellste Methode, um während der Entwicklung ein installierbares Paket für das eigene System zu erstellen.
### RPM-Paket (Fedora)
```bash ```bash
./gradlew :frontend:shells:meldestelle-desktop:packageRpm # Komplette Release-Site (Windows & Linux)
```
*Ausgabe: `frontend/shells:meldestelle-desktop/build/compose/binaries/main/rpm/`*
### DEB-Paket (Ubuntu/Debian)
```bash
./gradlew :frontend:shells:meldestelle-desktop:packageDeb
```
*Ausgabe: `frontend/shells:meldestelle-desktop/build/compose/binaries/main/deb/`*
### Portable Version (Ohne Installation)
```bash
./gradlew :frontend:shells:meldestelle-desktop:createDistributable
```
*Ausgabe: `frontend/shells:meldestelle-desktop/build/compose/binaries/main/app/`*
---
## 3. Cross-Packaging mit Conveyor
Conveyor nutzt die kompilierte JAR-Datei und schnürt daraus Pakete für alle Zielplattformen.
### Schritt 1: JAR erstellen
```bash
./gradlew :frontend:shells:meldestelle-desktop:jvmJar
```
### Schritt 2: Pakete bauen
```bash
# Erstellt den Windows-Installer und die HTML-Downloadseite
conveyor make site conveyor make site
# Nur ein spezifisches Paket (schneller für Tests)
conveyor make debian-package # Linux .deb
conveyor make windows-msix # Windows .msix
``` ```
### Schritt 3: Ergebnisse Die Ergebnisse liegen im Ordner `output/`.
Die fertigen Installer (z.B. `.msi` für Windows) befinden sich im neu erstellten Ordner `output/`.
--- ---
## 4. Problembehandlung & Optimierung ## 3. Konfiguration (`conveyor.conf`)
### Native Access Warnungen Wichtige Parameter der aktuellen Konfiguration (v1.0.1):
Die App benötigt Zugriff auf native Bibliotheken (Netty/SQLite). Der notwendige Parameter `--enable-native-access=ALL-UNNAMED` ist bereits fest hinterlegt. * **JDK:** Nutzt `Amazon Corretto 21` für maximale Cross-Platform Stabilität.
* **Heap-Size:** Erhöht auf `-Xmx1024m`, um auch große Stammdaten-Importe zu bewältigen.
* **Linux-Deps:** Automatische Installation von `libasound2`, `libgl1-mesa-glx` und `libx11-6`.
* **Native Access:** `--enable-native-access=ALL-UNNAMED` ist für Netty/SQLite aktiviert.
### Firewall-Konfiguration ---
Für Netzwerk-Tests (Discovery/Chat) müssen die Ports 8090, 8080 und 5353 (UDP) geöffnet sein.
Nutzen Sie dafür das bereitgestellte Skript: ## 4. Netzwerk & Sicherheit (WICHTIG)
Damit die P2P-Funktionen (Chat, Discovery, Sync) nach der Installation funktionieren, müssen folgende Ports auf dem Host-System offen sein:
| Port | Protokoll | Funktion |
| :--- | :--- | :--- |
| **8080** | TCP | P2P Sync & Datenabgleich |
| **8090** | TCP | Veranstaltungs-Chat (WebSocket) |
| **5353** | UDP | mDNS Discovery (Geräte finden) |
### Firewall-Einrichtung (Linux)
Nutzen Sie das optimierte Setup-Script:
```bash ```bash
sudo ./setup-firewall-linux.sh sudo ./setup-firewall-linux.sh
``` ```
### Windows-Besonderheit
Beim ersten Start der `.msix` App wird Windows fragen, ob der Netzwerkzugriff erlaubt werden soll. **Wichtig:** Sowohl "Private" als auch "Öffentliche" Netzwerke anhaken, falls auf Turnieren oft Gast-WLANs oder Hotspots genutzt werden.
---
## 5. Troubleshooting
### Problem: "No main class specified"
**Lösung:** Stellen Sie sicher, dass in der `Main.kt` eine saubere Top-Level `fun main()` existiert und in der `conveyor.conf` auf `at.mocode.frontend.shell.desktop.MainKt` verwiesen wird.
### Problem: SQLite / Native Libs laden nicht
**Lösung:** Prüfen Sie, ob `extract-native-libraries.conf` in der `conveyor.conf` inkludiert ist.
### Problem: JmDNS findet keine Teilnehmer
**Lösung:** Prüfen Sie die Ports via `ss -tulpn`. Auf Linux blockieren oft Docker-Interfaces (`br-*`) den Broadcast. Die App filtert diese nun automatisch, aber ein aktives `setup-firewall-linux.sh` ist zwingend erforderlich.
## 6. Performance-Optimierung (Gradle)
Der Build-Prozess kann bei aktivierter Web-Kompilierung (WASM/JS) sehr lange dauern. Für die reine Desktop-Entwicklung
wurde WASM standardmäßig deaktiviert.
* **WASM aktivieren (z.B. für CI/Portal):** `./gradlew -PenableWasm=true ...`
* **WASM deaktivieren (Default):** `./gradlew ...` (Spart bis zu 70% Build-Zeit).
## 7. Gradle Deep-Optimierung
Neben dem Deaktivieren von WASM wurden folgende systemweite Optimierungen in der `gradle.properties` vorgenommen:
* **Configuration Cache:** Aktiviert. Gradle merkt sich die Projektstruktur, was den Start jedes Befehls um Sekunden bis
Minuten verkürzt.
* **JVM G1GC & 12GB Heap:** Optimiert für große Multi-Modul-Projekte auf Systemen mit viel RAM (ab 16GB).
* **Parallel Workers:** Erhöht auf 12, um die 16 logischen Kerne Ihres Rechners besser auszulasten.
### Optionale Analysen
Statische Analysen sind nun standardmäßig **deaktiviert**, um den täglichen Workflow nicht zu bremsen.
* **Analyse laufen lassen:** `./gradlew staticAnalysis -PrunStaticAnalysis=true`
* **Dokka Dokumentation bauen:** `./gradlew dokkaAll -PrunDokka=true`
Stellen Sie in der `gradle.properties` sicher, dass `enableWasm=false` gesetzt ist, wenn Sie primär an der Desktop-App
arbeiten.
@@ -0,0 +1,47 @@
# 🧹 Journal: Build-Performance & Conveyor Installation
**Datum:** 11. Mai 2026
**Agent:** 🏗️ [Lead Architect] & 🧹 [Curator]
## 📝 Zusammenfassung
Der Fokus dieser Session lag auf der Optimierung der Gradle-Build-Performance und der Unterstützung des Users beim
Wechsel auf einen neuen Entwicklungsrechner (Ubuntu 26.04). Dabei wurde ein Fehler in der GPG-Key-URL von Conveyor
behoben.
## 🚀 Erledigte Aufgaben
1. **Gradle Performance Boost:**
* `enableWasm` in `gradle.properties` wurde standardmäßig auf `false` gesetzt.
* Dies deaktiviert die zeitintensive Kompilation von Kotlin/JS und WASM Artefakten (Portal/Wasm-Shell), wenn diese
nicht explizit benötigt werden.
* Erwartete Zeitersparnis: ca. 60-70% bei Desktop-fokussierten Builds.
2. **Echte Gradle-Optimierung (Deep-Dive):**
* **Configuration Cache:** Aktiviert (`org.gradle.configuration-cache=true`). Reduziert die Startzeit des Builds massiv,
besonders bei >80 Modulen.
* **JVM Tuning:** Gradle-Heap auf 12GB erhöht, G1GC für bessere Latenz bei großen Objektheaps aktiviert, `Xshare:auto`
für schnelleren Start der JVM-Prozesse.
* **Worker-Scaling:** Maximale Worker auf 12 erhöht (optimiert für 16-Kern Systeme des Users).
* **Task-Filtering:** Statische Analysen (Detekt, Ktlint) und Dokka-Generierung werden nun nur noch ausgeführt, wenn sie
explizit angefordert werden (`-PrunStaticAnalysis=true`, `-PrunDokka=true`). Dies verhindert unnötige Last während der
normalen Entwicklung.
3. **Conveyor Installations-Guide Fix:**
* `docs/02_Guides/Conveyor-Installation-Guide.md` wurde korrigiert.
* Der fehlerhafte GPG-Key-Download-Befehl (404 Error) wurde entfernt.
* Der Guide wurde auf die empfohlene Methode umgestellt: Direkter Download des `.deb`-Pakets für Ubuntu, welches das
Repository automatisch einrichtet.
3. **Dokumentations-Update:**
* `Desktop-Packaging-Guide.md` um Sektion "Performance-Optimierung" erweitert.
* `MASTER_ROADMAP.md` aktualisiert.
## ⚠️ Offene Punkte / Nächste Schritte
* **WASM-Builds in CI:** Die CI-Pipeline muss sicherstellen, dass `-PenableWasm=true` gesetzt ist, um das Portal
weiterhin zu bauen.
* **PoC Verifikation:** Die Verifikation auf physischer Hardware (Ubuntu 26.04) durch den User steht noch aus.
---
*Status: Änderungen erfolgreich angewendet. Verifikation der Performance-Steigerung durch User-Feedback ausstehend.*
+14 -16
View File
@@ -2,27 +2,25 @@
**Status:** 🏗️ In Arbeit **Status:** 🏗️ In Arbeit
**SCS:** Desktop App / Infrastructure **SCS:** Desktop App / Infrastructure
**Branch:** `feature/desktop-network-chat` (neuer Branch, erstellt ausgehend von `feature/turnier-anlage-wizard`) **Branch:** `feature/desktop-network-chat`
## 🎯 Aktuelles Ziel ## 🎯 Aktuelles Ziel
1. **Netzwerk-Kommunikation (Chat POC):** Implementierung einer simplen Chat-Funktion für die Desktop-App, die im lokalen Netzwerk funktioniert (Verbindungstest). 1. **Stabile Netzwerk-Kommunikation:** Implementierung einer robusten P2P-Kommunikation mit Reconnection-Logik und Heartbeats.
2. **Multi-Node Architektur:** Host-Client-Modell (1..n Hosts, 1..n Clients) vorbereiten. Hosts und Clients müssen in einem lokalen Netzwerk (LAN/WLAN) plattformunabhängig (Windows, Mac, Linux) stabil kommunizieren können. 2. **Multi-Node Architektur:** Host-Client-Modell stabilisiert.
3. **Conveyor Build (Pausiert):** Lauffähiger Build der Desktop-App via Conveyor für Windows (.msi/.exe) und Linux. Bereitstellung über Web-App. Wird nach dem Netzwerk-Proof-of-Concept in Angriff genommen. 3. **Professional Packaging:** Vorbereitung für echte Installer (.msi, .deb) via Conveyor.
## 🛠️ Letzte Änderungen ## 🛠️ Letzte Änderungen
- Fokus auf Netzwerk- & Offline-Fähigkeiten gelegt. Turnier-Anlage-Wizard pausiert. - **Hardening P2P:** `JvmP2pSyncService` komplett refactored. Jetzt mit automatischem Reconnect (3s Intervall) und Ktor Heartbeats (Ping/Pong alle 5s).
- Neuer Branch `feature/desktop-network-chat` für die anstehenden Arbeiten. - **Conveyor:** Konfiguration (`conveyor.conf`) für v1.0.1 vorbereitet (größere JVM Heaps, Linux Abhängigkeiten).
- **Firewall Script:** Verbessert und um Kommentare/mDNS erweitert.
## 📍 Fokus-Bereiche ## 📍 Fokus-Bereiche
- Lokale Netzwerk-Discovery (z.B. Ktor, UDP Broadcast, mDNS). - [x] Robuste Reconnection-Logik im P2P Service.
- P2P oder Client-Server Chat-Kommunikation im lokalen Netzwerk für den Verbindungs-Check. - [x] Heartbeats zur Erkennung toter Verbindungen.
- KMP Desktop-Modul. - [ ] In-App Feedback bei Firewall-Blockaden.
- [ ] Multi-Node Test mit > 2 Teilnehmern.
## 🚧 Offene Punkte / Blocker
- [ ] Konzept für Host/Client-Discovery im lokalen Netz umsetzen.
- [ ] Implementierung eines lokalen Chat-Moduls in der Desktop-App (Linux/Desktop-Test).
- [ ] Erfolgreicher Conveyor Build für Windows & Linux (Später).
## 🔄 Nächste Schritte ## 🔄 Nächste Schritte
- [ ] Architektur-Entscheidung (ADR) für lokale Netzwerk-Discovery und Kommunikation treffen (Ktor Sockets, UDP, etc.). - [ ] Multi-Node Stabilitätstest (Simulierte Netzwerk-Drops).
- [ ] Erste Implementierung des Discovery-Mechanismus. - [ ] Integration von Firewall-Checks im Connectivity-Wizard.
- [ ] Erster Test-Build via Conveyor auf lokaler Maschine.
+202
View File
@@ -0,0 +1,202 @@
# System-Dokumentation: Simka Core Server
## 1. Wer oder was ist Simka?
**Simka** ist einer der beiden zentralen Pfeiler deiner hochperformanten, selbstgehosteten **Keller-Server-Landschaft** (neben dem zweiten Server *Zora*). Während Zora (dein Minisforum MS-R1 ARM64-Arbeitstier) primär für Core-Dienste und Datenhoheit zuständig ist, fungiert Simka als das eigentliche **Performance- und Rechenzentrum** im Keller.
### Core-Spezifikationen & Hardware-Zustand (Stand: Mai 2026)
* **Architektur:** x86_64 High-Performance Mini-PC / Server.
* **Betriebssystem / Hypervisor:** **Proxmox VE 9.2** (aktuell auf Kernel/PVE-Stand 9.2.2).
* **Kühlung & Wartung:** Frisch saniert im Mai 2026 mit **ARCTIC MX-7** Wärmeleitpaste auf der CPU und **ARCTIC TP-4** High-Performance Wärmeleitpads für optimale thermische Stabilität unter Volllast.
* **Speichermedium:** Ultraschnelle NVMe-SSDs für die lokale Container- und VM-Ausführung.
### Das anstehende "Monster-Upgrade" (Lieferung bis Ende der Woche)
Simka wird in den nächsten Tagen hardwareseitig massiv aufgerüstet, um für lokale KI-Workloads und rechenintensive Pipelines bereitzuhoben:
1. **Arbeitsspeicher:** Erweiterung auf **64 GB DDR5 4800Mhz RAM** (maximale Kapazität für massives Multitasking und In-Memory-Datenbanken).
2. **Grafikkarte / GPU:** **PNY NVIDIA RTX 2000E Ada Generation**
* *Formfaktor:* Single-Slot & Low-Profile (perfekt für kompakte Servergehäuse, zieht Strom rein über den PCIe-Steckplatz).
* *Leistung:* Extrem energieeffiziente Ada-Lovelace-Architektur mit maximal **50W TDP** im Peak.
* *VRAM:* **16 GB Videospeicher mit ECC-Fehlerkorrektur** das absolute Fundament für den ausfallsicheren 24/7-Betrieb von lokalen LLMs (Large Language Models).
---
## 2. Netzwerk-Konfiguration in der Keller-Landschaft
Simka ist netzwerktechnisch perfekt redundant und isoliert aufgestellt. Die Kommunikation teilt sich auf zwei dedizierte Leitungen auf:
```
[ Erdgeschoss / Internet ]
│ (A1-Router / Fritzbox)
[ Keller-Switch ]
│ │
│ (10.0.0.20)
│ ▼
│ ┌───────────┐
│ │ ZORA │◄──────┐
│ └───────────┘ │
│ (10.0.0.40) │ Der exklusive
└──────►┌───────────┐ │ Keller-Highway
│ SIMKA │ │ (192.168.99.X)
└───────────┘ │ Latenz: ~0.3 ms
▲ │
└───────────┘
```
### A. Der primäre Uplink (`vmbr0`)
* **Physisches Interface:** `nic0`
* **Lokale IP:** `10.0.0.40/24`
* **Gateway:** `10.0.0.138` (A1-Router / Fritzbox)
* **Zweck:** Dieser Port verbindet Simka mit dem Hausnetzwerk und dem Internet. Über diesen Pfad greifst du via **Pangolin-Tunnel** (z. B. unter `https://fotos.mo-home.at`) sicher von außen (Remote von der Arbeit) auf deine Dienste zu.
### B. Der exklusive "Keller-Highway" (`vmbr1`) *Neu eingerichtet!*
* **Physisches Interface:** `nic1` (Direktverbindung per Standard-Netzwerkkabel zu Zoras Interface `enp49s0`).
* **Lokale IP:** `192.168.99.2/24`
* **Gateway / DNS:** *Keines* (isoliertes Subnetz).
* **Status:** Aktiviert mit **Autostart** bei Boot.
* **Performance:** Saubere **0,33 ms Latenz** im Ping-Test, 0% Paketverlust.
* **Zweck:** Blitzschnelle, vom restlichen Heimnetzwerk komplett isolierte Direktverbindung zwischen Zora und Simka. Hierüber laufen zukünftig automatisierte Backup-Pipelines, CI/CD-Prozesse und der interne Datenaustausch, ohne das normale LAN oder die Router-CPU zu belasten.
---
## 3. Container & Dienste: Wer läuft wo?
Aktuell ist die Bereitstellung auf Simka modular und ressourceneffizient über Proxmox-Strukturen gelöst. Das Herzstück bildet die frisch migrierte Fotosammlung.
### LXC-Container: `simka-immich`
* **Umgebung:** `/opt/immich`
* **Konfiguration:** Docker Compose Stack.
* **Dienste im Stack:**
* `immich_server`: Das Hauptsystem der App (erreichbar lokal unter `http://10.0.0.40:2283`).
* `immich_machine_learning`: Zuständig für Objekterkennung und CLIP-Inferenz.
* `immich_postgres`: Die relationale Datenbank (PostgreSQL 14 mit `pgvector`/`vectorchord` Erweiterung).
* `immich_redis`: In-Memory-Datenstruktur-Store für Caching und Job-Queues.
* **Daten-Zustand:**
* **Datenbank:** Erfolgreich importiert aus der `immich-db-backup.sql` (**610 MB** an Metadaten, Pfaden und Strukturen). Die alte, leere Auto-Datenbank wurde zuvor sauber via `dropdb` gelöscht.
* **Bilderpool:** Satte **~780 GB an Daten (131.578 Fotos/Videos)** liegen direkt lokal auf Simkas schneller NVMe-SSD.
* **Aktueller Betriebsmodus:** Reine **CPU-Ausführung** (x86_64). Die Mitternachts-Jobs und Worker-Anzahlen für Video-Transcoding sind CPU-schonend konfiguriert (max. 12 Worker), um den Server bis zum Wochenende nicht zu überlasten.
* **Zugriff:** Voll funktionsfähig lokal sowie mobil über die Android-App via Pangolin-Tunnel (Plattform-SSO wurde für den reibungslosen API-Sync der App deaktiviert).
---
## 4. Die Roadmap für das kommende Wochenende 🚀
Sobald die Hardware geliefert wurde, wird Simka schrittweise zum KI-Kraftpaket ausgebaut:
1. **Hardware-Injektion:** Einbau der 64 GB DDR5 RAM und der NVIDIA RTX 2000E Ada.
2. **Die "Simka Core AI" VM:** Erstellung einer dedizierten virtuellen Maschine (z. B. Ubuntu/Fedora Server) in Proxmox.
* *PCIe-Passthrough:* Die RTX 2000E wird exklusiv direkt in diese KI-VM durchgereicht.
* *LLM-Infrastruktur:* Installation von **Ollama**. Dank der 16 GB ECC-VRAM laden wir dort mächtige Coding-Modelle wie `qwen2.5-coder:7b` (oder quantisierte 14b-Versionen) für blitzschnelle Code-Generierung in deiner IntelliJ-IDE auf deinem Ubuntu-Entwicklungs-PC.
3. **Immich-Beschleunigung:** Umstellung des `simka-immich` Docker-Stacks auf GPU-Inferenz. Die 88 Tensor-Kerne der RTX übernehmen dann die Gesichtserkennung und das Video-Transcoding in Millisekunden, wodurch die CPU komplett entlastet wird.
4. **CI/CD-Ausbau:** Migration des Gitea-Runners für dein Projekt **Meldestelle** aus der alten VM-Infrastruktur in einen schlanken, nativen LXC-Container auf Simka, der über den 0,3-ms-Highway direkt an dein Gitea auf Zora andockt.
---
## 5. Architektur- und Betriebs-Einschätzung (Ablage)
🏗️ [Lead Architect]
### Einschätzung der Architektur
- Starker, sinnvoller Plattform-Split: Zora = Core/Control-Plane, Simka = Compute/Throughput. Das entspricht einer sauberen Trennung von „Authority“ und „Acceleration“ und passt zur Desktop-/OfflineFirst Strategie der Meldestelle.
- Netzwerk-Topologie mit dediziertem „Keller-Highway“ (vmbr1, 192.168.99.0/24) ist genau richtig für Replikation, CI/CD und Bulk-Daten. Minimiert Latenz/Jitter und entkoppelt vom Heimnetz.
- Hardware-Entscheidung: RTX 2000E Ada (16 GB ECC, 50 W) ist ein hocheffizienter, 24/7tauglicher InferenzBeschleuniger. Für LLMs bis 7B (FP16) und 14B (ggf. quantisiert) sowie NVENC/Transcoding ist sie sehr passend. Gegenüber „dickeren“ Karten (z. B. 4090/6000 Ada) verlierst du PeakThroughput, gewinnst aber Effizienz, Geräusch, thermische Stabilität und ECC.
### Architektur-Empfehlungen (kurz & konkret)
- Datenpfade klar trennen:
- „Hot“ AI/Transcode scratch auf lokaler NVMe (Simka)
- „Warm“ Artifacts/Backups via vmbr1 zu Zora
- Standards definieren (DokuasCode):
- ADR: „GPUPassthrough für ComputeVMs“ (Begründung, Alternativen, Tradeoffs)
- ADR: „LXC vs. VM für Runner/Builds“ (siehe DevOpsTeil)
- Observability als Querschnitt: Einheitliche MetrikNamen/Labels für Hosts, VMs, LXC (Node exporter/Nvidia exporter), damit Kapazitätsplanung reproduzierbar ist.
---
🐧 [DevOps Engineer]
### Was ist gut gelaufen
- Proxmox VE 9.2 auf aktuellem Stand, dedizierter LXCStack für Immich, PingLatenz ~0,33 ms auf vmbr1: sehr solide Grundlage.
- Saubere Migration der ImmichDatenbank und großer Medienpool lokal auf NVMe → I/OBottlenecks minimiert.
### Konkreter WochenendPlan (Checkliste)
1) HardwareEinbau und Firmware
- RAM auf 64 GB einsetzen, Memtest (mind. 1 Pass) → Verifikation erforderlich.
- GPU einsetzen; BIOS/UEFI:
- Primäre Anzeige auf iGPU/Onboard setzen (damit dGPU frei für Passthrough bleibt)
- Resizable BAR an (falls angeboten), SRIOV/IOMMU aktiv.
2) Proxmox: NVIDIA GPUPassthrough für KIVM
- Kernel/Boot: IOMMU aktivieren (`amd_iommu=on`), Neustart; prüfen, dass GPU in eigenem IOMMUGroup landet.
- Host: `vfio-pci` binden, Nouveau/NVIDIAHosttreiber nicht laden (Host soll die GPU nicht claimen).
- VM anlegen (Ubuntu Server LTS oder Fedora Server), PCIeGerät hinzufügen (GPU + ggf. GPUAudio Function), MSIInterrupts aktivieren.
- InGuest: NVIDIA Treiber + CUDA/CuDNN installieren; `nvidia-smi` muss die Karte stabil zeigen.
3) „Simka Core AI“ VM: Modelle & Runtime
- Ollama + optional OpenWebUI installieren.
- Modelle (Beispiele mit 16 GB VRAM):
- qwen2.5coder:7b (FP16) oder 14b quantisiert (Q4_K_M/Q6_K)
- DeepSeekcoder 6.7B, Llama3.1/3.2 8B Instruct quantisiert für Tools/Chat
- Policies:
- Max. Parallelität und KVCache Limits definieren, um OOM zu vermeiden.
- Watchdog/ServiceUnit für automatischen Neustart bei TreiberResets.
4) Immich auf GPU beschleunigen
- Auf dem LXCHost NVIDIA Container Toolkit bereitstellen oder, falls LXC zu restriktiv ist: Immich in eine leichte VM oder in einen privilegierten LXC migrieren.
- dockercompose Anpassungen:
- `immich_machine_learning` mit `--gpus=all`/`runtime: nvidia`
- FFmpeg HWAccel aktivieren: `-hwaccel cuda -hwaccel_output_format cuda -c:v h264_nvenc/hevc_nvenc`
- WorkerLimits neu justieren (mehr TranscodeJobs, wenn GPU an Bord)
- Verifikation: Testtranscode und EmbeddingJob messen (Metriken/Logs sichern).
5) Gitea Runner Migration (Meldestelle CI/CD)
- Entscheidung: LXC vs. VM
- LXC (privileged, `nesting=1`, `keyctl=1`) ist ressourcenschonend, aber DockerinLXC erfordert Sorgfalt.
- Alternativ: kleine „RunnerVM“ mit Docker oft robuster bei komplexen BuildNeeds (z. B. Android/Compose Desktop Toolchains).
- Netzwerk: Runner nutzt vmbr1, verbindet sich mit Gitea auf Zora, DNS via Hostfile/Static Route, kein DefaultGW nötig.
- Caching: Maven/Gradle Cache persistent auf separatem Volume; ArtefaktUpload über vmbr1.
6) NetzwerkFeinschliff
- vmbr1 ohne Gateway belassen; auf Hosts `metric`/PolicyRouting setzen, damit Traffic für 192.168.99.0/24 strikt lokal bleibt.
- Optional Jumbo Frames (MTU 9000) testen, wenn NICs/Switch/DirectLink es stabil können.
- ProxmoxFirewall:
- vmbr0 restriktiv (nur notwendige IngressPorts)
- vmbr1 nur HostzuHost/Backup/CI erlauben
7) Backup & Wiederherstellung
- Immich: DB Dumps (pg_dump) + ObjektSpeicher Sync via vmbr1 zu Zora (z. B. borg/restic/ZFS sendrecv).
- Proxmox: regelmäßige VM/LXC Snapshots + OffhostKopie.
- RecoveryDrills: 1x/Quartal Wiederherstellung in TestVM durchführen → „Verifikation ausstehend“ bis Log vorhanden.
8) Observability & Betrieb
- Node Exporter + NVIDIA DCGM/Exporter auf Simka; Prometheus/Grafana auf Zora aggregiert.
- Alarme: NVMe WearLevel, GPU ECC Errors, Temp, OOMKills, Docker/Container Restarts.
- Energie: Optional `nvidia-smi -pl` PowerLimit dokumentieren (wenn thermisch nötig), persistent via Systemd Unit.
### Risiken/Watchouts
- LXC + GPU ist möglich, aber distributions/kernelabhängig; wenn TreiberBindung hakelt, früh auf VM umschwenken.
- RTX 2000E hat 16 GB VRAM: bei mehreren gleichzeitigen LLMSessions aggressiv quantisieren oder serielle Ausführung planen.
- PangolinTunnel: Secrets/Token sicher hinterlegen und Rotation terminieren; 2FA erzwingen.
---
🧹 [Curator]
### Dokumentations- und AbschlussTasks
- Dieses „Simka Core Server“ Dokument ist ein sehr guter Start. Ergänze bitte:
- Abschnitt „Verifikation/Beweise“ mit Links/Screenshots/Logs zu: Memtest, `nvidia-smi`, ImmichGPUTranscodeProbe, Ollama InferenzBenchmark, CIRunner „connected“ Status.
- ADREinträge unter `docs/01_Architecture/ADRs/`:
- ADR00X „Dedizierter KellerHighway (vmbr1) für Bulk/CI/Backup“
- ADR00X „GPUPassthrough für KIVM auf Proxmox“
- ADR00X „Runner: LXC (privileged) vs. kleine VM Entscheidung & Gründe“
- Runbook „Simka Operations“: BootReihenfolge, HealthChecks, Troubleshooting (GPU Reset, TreiberReinstall, ContainerRestart).
- AntiHalluzinationsProtokoll anwenden:
- Kein „erledigt“ ohne Build/TestBeweis; markiere alle neuen Punkte als „Verifikation ausstehend“, bis Logs/Artefakte abgelegt sind.
- Inventar & Versionen pflegen:
- BIOS/UEFIVersion, ProxmoxKernel, NVIDIATreiber, Docker Compose Hash, Immich Version, DB Schema Version.
### Verifikation/Beweise (Platzhalter Verifikation ausstehend)
- [ ] Memtest86+ Log (mind. 1 Pass, fehlerfrei)
- [ ] `nvidia-smi` Ausgabe in der KIVM (GPU erkannt, ECC aktiv, TreiberVersion)
- [ ] Immich: GPUbeschleunigter TranscodeTest (Log + Metriken)
- [ ] Ollama: InferenzBenchmark (Modell + Prompt + Zeit + VRAMAuslastung)
- [ ] Gitea Runner: „connected“ Status + BeispielBuildLog über vmbr1
Binary file not shown.
+10
View File
@@ -60,6 +60,16 @@ sqldelight {
create("AppDatabase") { create("AppDatabase") {
packageName.set("at.mocode.frontend.core.localdb") packageName.set("at.mocode.frontend.core.localdb")
generateAsync.set(true) generateAsync.set(true)
// Workaround für SQLite-Temp-Verzeichnis Issue auf Windows
verifyMigrations.set(false)
} }
} }
} }
// Workaround für SQLite-Temp-Verzeichnis Issue auf Windows. Das Plugin generiert dynamisch Tasks.
// lazy task configuration avoids cache issues and intercepts dynamic tasks
tasks.configureEach {
if (name.contains("verify", ignoreCase = true) && name.contains("Migration", ignoreCase = true)) {
enabled = false
}
}
@@ -9,79 +9,79 @@ import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
class FileBackupService(private val deviceName: String) : BackupService { class FileBackupService(private val deviceName: String) : BackupService {
private val json = Json { prettyPrint = true } private val json = Json { prettyPrint = true }
override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> { override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> {
return try { return try {
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
val checksum = calculateChecksum(data) val checksum = calculateChecksum(data)
val payload = BackupPayload(timestamp, deviceName, data, checksum) val payload = BackupPayload(timestamp, deviceName, data, checksum)
val jsonContent = json.encodeToString(payload) val jsonContent = json.encodeToString(payload)
val encryptedData = encrypt(jsonContent, sharedKey) val encryptedData = encrypt(jsonContent, sharedKey)
val dir = File(targetPath) val dir = File(targetPath)
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()
val fileName = "delta_${timestamp}_${deviceName}.msbackup" val fileName = "delta_${timestamp}_${deviceName}.msbackup"
val file = File(dir, fileName) val file = File(dir, fileName)
file.writeText(encryptedData) file.writeText(encryptedData)
println("[Plan-USB] Export erfolgreich: ${file.absolutePath}") println("[Plan-USB] Export erfolgreich: ${file.absolutePath}")
Result.success(file.absoluteName) Result.success(file.absoluteName)
} catch (e: Exception) { } catch (e: Exception) {
println("[Plan-USB] Export fehlgeschlagen: ${e.message}") println("[Plan-USB] Export fehlgeschlagen: ${e.message}")
Result.failure(e) Result.failure(e)
}
} }
}
override fun importDelta(filePath: String, sharedKey: String): Result<String> { override fun importDelta(filePath: String, sharedKey: String): Result<String> {
return try { return try {
val file = File(filePath) val file = File(filePath)
val encryptedData = file.readText() val encryptedData = file.readText()
val jsonContent = decrypt(encryptedData, sharedKey) val jsonContent = decrypt(encryptedData, sharedKey)
val payload = json.decodeFromString<BackupPayload>(jsonContent) val payload = json.decodeFromString<BackupPayload>(jsonContent)
if (calculateChecksum(payload.data) != payload.checksum) { if (calculateChecksum(payload.data) != payload.checksum) {
throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.") throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.")
} }
println("[Plan-USB] Import erfolgreich von ${payload.deviceName}") println("[Plan-USB] Import erfolgreich von ${payload.deviceName}")
Result.success(payload.data) Result.success(payload.data)
} catch (e: Exception) { } catch (e: Exception) {
println("[Plan-USB] Import fehlgeschlagen: ${e.message}") println("[Plan-USB] Import fehlgeschlagen: ${e.message}")
Result.failure(e) Result.failure(e)
}
} }
}
private fun calculateChecksum(data: String): String { private fun calculateChecksum(data: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray()) val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
return bytes.joinToString("") { "%02x".format(it) } return bytes.joinToString("") { "%02x".format(it) }
} }
private fun encrypt(data: String, key: String): String { private fun encrypt(data: String, key: String): String {
val secretKey = generateKey(key) val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv) cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
val encrypted = cipher.doFinal(data.toByteArray()) val encrypted = cipher.doFinal(data.toByteArray())
return Base64.getEncoder().encodeToString(encrypted) return Base64.getEncoder().encodeToString(encrypted)
} }
private fun decrypt(encrypted: String, key: String): String { private fun decrypt(encrypted: String, key: String): String {
val secretKey = generateKey(key) val secretKey = generateKey(key)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val iv = IvParameterSpec(ByteArray(16)) val iv = IvParameterSpec(ByteArray(16))
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv) cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted)) val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted))
return String(decrypted) return String(decrypted)
} }
private fun generateKey(key: String): SecretKeySpec { private fun generateKey(key: String): SecretKeySpec {
val sha = MessageDigest.getInstance("SHA-256") val sha = MessageDigest.getInstance("SHA-256")
val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität
return SecretKeySpec(keyBytes, "AES") return SecretKeySpec(keyBytes, "AES")
} }
} }
private val File.absoluteName: String get() = this.name private val File.absoluteName: String get() = this.name
@@ -9,6 +9,6 @@ import org.koin.dsl.module
* JVM-spezifische Implementierung des DiscoveryModules. * JVM-spezifische Implementierung des DiscoveryModules.
*/ */
actual val discoveryModule: Module = module { actual val discoveryModule: Module = module {
single<NetworkDiscoveryService> { JmDnsDiscoveryService() } single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) } single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
} }
@@ -22,8 +22,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
private val registeredSet = ConcurrentHashMap.newKeySet<String>() // key: "${name}@${addr.hostAddress}:$port" private val registeredSet = ConcurrentHashMap.newKeySet<String>() // key: "${name}@${addr.hostAddress}:$port"
// Debounce/Guards // Debounce/Guards
@Volatile private var lastStartRequestedAt: Long = 0L @Volatile
@Volatile private var lastStartIp: String? = null private var lastStartRequestedAt: Long = 0L
@Volatile
private var lastStartIp: String? = null
private val _discoveredServices = MutableStateFlow<List<DiscoveredService>>(emptyList()) private val _discoveredServices = MutableStateFlow<List<DiscoveredService>>(emptyList())
override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow() override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow()
@@ -149,7 +151,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
val name = iface.name.lowercase() val name = iface.name.lowercase()
// Filtere Docker/Bridged/VETH/VM-Schnittstellen heraus // Filtere Docker/Bridged/VETH/VM-Schnittstellen heraus
if (iface.isLoopback || !iface.isUp || iface.isVirtual) continue if (iface.isLoopback || !iface.isUp || iface.isVirtual) continue
if (name.startsWith("br-") || name.startsWith("docker") || name.startsWith("veth") || name.contains("vmnet") || name.contains("virbr")) continue if (name.startsWith("br-") || name.startsWith("docker") || name.startsWith("veth") || name.contains("vmnet") || name.contains(
"virbr"
)
) continue
val inetAddresses = iface.inetAddresses val inetAddresses = iface.inetAddresses
while (inetAddresses.hasMoreElements()) { while (inetAddresses.hasMoreElements()) {
@@ -172,7 +177,8 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
// Bevorzuge private LAN IPv4 (192.168.x.x, 10.x.x.x, 172.16-31.x.x) // Bevorzuge private LAN IPv4 (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
fun isPrivateIPv4(a: InetAddress): Boolean { fun isPrivateIPv4(a: InetAddress): Boolean {
val h = a.hostAddress val h = a.hostAddress
return h.startsWith("192.168.") || h.startsWith("10.") || (h.startsWith("172.") && h.split('.').getOrNull(1)?.toIntOrNull() in 16..31) return h.startsWith("192.168.") || h.startsWith("10.") || (h.startsWith("172.") && h.split('.').getOrNull(1)
?.toIntOrNull() in 16..31)
} }
return addresses.sortedWith(compareByDescending<InetAddress> { isPrivateIPv4(it) } return addresses.sortedWith(compareByDescending<InetAddress> { isPrivateIPv4(it) }
.thenBy { it.hostAddress }) .thenBy { it.hostAddress })
@@ -2,145 +2,181 @@ package at.mocode.frontend.core.network.sync
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.plugins.websocket.* import io.ktor.client.plugins.websocket.*
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.engine.* import io.ktor.server.engine.*
import io.ktor.server.netty.* import io.ktor.server.netty.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds
/**
* JVM-spezifische Implementierung des P2pSyncService mit Fokus auf Stabilität.
* Beinhaltet Reconnection-Logik, Heartbeats und robustes Session-Management.
*/
class JvmP2pSyncService : P2pSyncService { class JvmP2pSyncService : P2pSyncService {
companion object { companion object {
// Prozessweiter, portbasierter Guard gegen Mehrfachstart private val startedPorts: MutableSet<Int> = ConcurrentHashMap.newKeySet()
private val startedPorts: MutableSet<Int> = ConcurrentHashMap.newKeySet() private const val RECONNECT_DELAY_MS = 3000L
private const val PING_INTERVAL_MS = 5000L
private const val PING_TIMEOUT_MS = 10000L
}
private var server: EmbeddedServer<*, *>? = null
private var currentPort: Int? = null
private val client = HttpClient {
install(WebSockets) {
pingInterval = PING_INTERVAL_MS.milliseconds
} }
private var server: EmbeddedServer<*, *>? = null }
private var currentPort: Int? = null
private val client = HttpClient { private val _incomingEvents = MutableSharedFlow<SyncEvent>(extraBufferCapacity = 64)
install(io.ktor.client.plugins.websocket.WebSockets) override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow()
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val connectionJobs = ConcurrentHashMap<String, Job>()
override fun startServer(port: Int) {
if (server != null) {
println("[P2P Server] Bereits aktiv auf Port ${currentPort ?: port}")
return
} }
private val _incomingEvents = MutableSharedFlow<SyncEvent>() if (!startedPorts.add(port)) {
override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow() println("[P2P Server] Port $port wird bereits von einer anderen Instanz genutzt.")
return
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun startServer(port: Int) {
// Instanz-Guard (gleiche Instanz)
if (server != null) {
println("[P2P Server] Bereits gestartet (Instanz) auf Port ${currentPort ?: port} idempotent")
return
}
// Prozessweiter, portbasierter Guard
println("[P2P Server] Versuche Port $port zu reservieren...")
if (!startedPorts.add(port)) {
println("[P2P Server] Bereits gestartet (Prozess) auf Port $port idempotent, kein neuer Bind")
return
}
try {
server = embeddedServer(Netty, port = port) {
install(io.ktor.server.websocket.WebSockets)
routing {
webSocket("/sync") {
println("[P2P Server] Neuer Peer verbunden")
activeSessions.add(this)
updatePeers()
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
try {
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
} catch (e: Exception) {
println("[P2P Server] Fehler beim Dekodieren: ${e.message}")
}
}
}
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Server] Peer getrennt")
}
}
}
}.start(wait = false)
currentPort = port
} catch (e: Exception) {
// Start fehlgeschlagen -> Port-Lock wieder freigeben
startedPorts.remove(port)
server = null
currentPort = null
println("[P2P Server] Start auf Port $port fehlgeschlagen: ${e.message}")
throw e
}
} }
override fun stopServer() { try {
try { server = embeddedServer(Netty, port = port, host = "0.0.0.0") {
server?.stop(1000, 2000) install(io.ktor.server.websocket.WebSockets) {
} finally { pingPeriod = PING_INTERVAL_MS.milliseconds
server = null timeout = PING_TIMEOUT_MS.milliseconds
currentPort?.let { startedPorts.remove(it) }
currentPort = null
} }
} routing {
webSocket("/sync") {
override suspend fun connectToPeer(host: String, port: Int) { val remote = call.request.local.remoteAddress
scope.launch { println("[P2P Server] Neuer Peer verbunden: $remote")
activeSessions.add(this)
updatePeers()
try { try {
client.webSocket(host = host, port = port, path = "/sync") { for (frame in incoming) {
println("[P2P Client] Verbunden mit $host:$port") if (frame is Frame.Text) {
activeSessions.add(this) val text = frame.readText()
updatePeers() try {
try { val event = Json.decodeFromString<SyncEvent>(text)
for (frame in incoming) { _incomingEvents.emit(event)
if (frame is Frame.Text) { } catch (ex: Exception) {
val text = frame.readText() println("[P2P Server] Fehler beim Dekodieren von $remote: ${ex.message}")
val event = Json.decodeFromString<SyncEvent>(text) }
_incomingEvents.emit(event)
}
}
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Client] Verbindung zu $host:$port beendet")
}
} }
} catch (e: Exception) { }
println("[P2P Client] Fehler bei Verbindung zu $host:$port: ${e.message}") } catch (ex: Exception) {
println("[P2P Server] Verbindung zu $remote unterbrochen: ${ex.message}")
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Server] Peer $remote getrennt")
} }
}
} }
}.start(wait = false)
currentPort = port
println("[P2P Server] Erfolgreich gestartet auf Port $port")
} catch (ex: Exception) {
startedPorts.remove(port)
server = null
currentPort = null
println("[P2P Server] Fehler beim Starten des Servers auf Port $port: ${ex.message}")
throw ex
} }
}
override suspend fun broadcastEvent(event: SyncEvent) { override fun stopServer() {
val text = Json.encodeToString(event) connectionJobs.values.forEach { it.cancel() }
activeSessions.toList().forEach { session -> connectionJobs.clear()
try {
server?.stop(1000, 2000)
} finally {
server = null
currentPort?.let { startedPorts.remove(it) }
currentPort = null
println("[P2P Server] Server gestoppt.")
}
}
override suspend fun connectToPeer(host: String, port: Int) {
val peerKey = "$host:$port"
connectionJobs[peerKey]?.cancel()
val job = scope.launch {
while (isActive) {
try {
println("[P2P Client] Verbindungsversuch zu $peerKey...")
client.webSocket(host = host, port = port, path = "/sync") {
println("[P2P Client] Verbunden mit $peerKey")
activeSessions.add(this)
updatePeers()
try { try {
session.send(Frame.Text(text)) for (frame in incoming) {
} catch (e: Exception) { if (frame is Frame.Text) {
println("[P2P] Fehler beim Senden an Session: ${e.message}") val text = frame.readText()
val event = Json.decodeFromString<SyncEvent>(text)
_incomingEvents.emit(event)
}
}
} catch (ex: Exception) {
println("[P2P Client] Verbindung zu $peerKey abgebrochen: ${ex.message}")
} finally {
activeSessions.remove(this)
updatePeers()
println("[P2P Client] Session mit $peerKey beendet.")
} }
}
} catch (ex: Exception) {
println("[P2P Client] Konnte keine Verbindung zu $peerKey herstellen: ${ex.message}")
} }
}
private fun updatePeers() { if (isActive) {
// Da wir keine einfachen IPs in den Sessions haben ohne tieferes Casting, println("[P2P Client] Erneuter Versuch für $peerKey in ${RECONNECT_DELAY_MS}ms...")
// nutzen wir hier erst mal einen Platzhalter oder zählen nur. delay(RECONNECT_DELAY_MS.milliseconds)
_connectedPeers.value = activeSessions.map { "Peer-${it.hashCode()}" } }
}
} }
connectionJobs[peerKey] = job
}
override suspend fun broadcastEvent(event: SyncEvent) {
val text = Json.encodeToString(event)
val sessions = activeSessions.toList()
sessions.forEach { session ->
try {
if (session.isActive) {
session.send(Frame.Text(text))
}
} catch (_: Exception) {
// Session wird durch Heartbeat/Loop automatisch bereinigt
}
}
}
private fun updatePeers() {
_connectedPeers.value = activeSessions.map { session ->
when (session) {
is DefaultWebSocketServerSession -> session.call.request.local.remoteAddress
else -> "Outgoing-Peer"
}
}.distinct()
}
} }
@@ -7,6 +7,6 @@ import org.koin.dsl.module
* JVM-spezifische Implementierung des SyncModules. * JVM-spezifische Implementierung des SyncModules.
*/ */
actual val syncModule: Module = module { actual val syncModule: Module = module {
single<P2pSyncService> { JvmP2pSyncService() } single<P2pSyncService> { JvmP2pSyncService() }
single { SyncManager(get(), get()) } single { SyncManager(get(), get()) }
} }
@@ -5,33 +5,33 @@ import kotlin.test.Test
class JvmP2pSyncServiceTest { class JvmP2pSyncServiceTest {
@Test @Test
fun starting_server_twice_on_same_port_should_not_fail_but_use_guard() = runTest { fun starting_server_twice_on_same_port_should_not_fail_but_use_guard() = runTest {
val service1 = JvmP2pSyncService() val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService() val service2 = JvmP2pSyncService()
val port = 9091 val port = 9091
try { try {
service1.startServer(port) service1.startServer(port)
// Second start should just return/log and not throw an exception (idempotent) // Second start should just return/log and not throw an exception (idempotent)
service2.startServer(port) service2.startServer(port)
} finally { } finally {
service1.stopServer() service1.stopServer()
service2.stopServer() service2.stopServer()
}
} }
}
@Test @Test
fun stopping_server_should_release_port_lock() = runTest { fun stopping_server_should_release_port_lock() = runTest {
val service1 = JvmP2pSyncService() val service1 = JvmP2pSyncService()
val service2 = JvmP2pSyncService() val service2 = JvmP2pSyncService()
val port = 9092 val port = 9092
service1.startServer(port) service1.startServer(port)
service1.stopServer() service1.stopServer()
// After stopping, starting again on same port (even from different instance) should work // After stopping, starting again on same port (even from different instance) should work
service2.startServer(port) service2.startServer(port)
service2.stopServer() service2.stopServer()
} }
} }
@@ -1,23 +1,23 @@
package at.mocode.frontend.core.network.sync package at.mocode.frontend.core.network.sync
import org.koin.core.module.Module
import org.koin.dsl.module
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import org.koin.core.module.Module
import org.koin.dsl.module
/** /**
* Wasm-spezifische Implementierung (vorerst No-op). * Wasm-spezifische Implementierung (vorerst No-op).
*/ */
actual val syncModule: Module = module { actual val syncModule: Module = module {
single<P2pSyncService> { NoOpP2pSyncService() } single<P2pSyncService> { NoOpP2pSyncService() }
single { SyncManager(get(), get()) } single { SyncManager(get(), get()) }
} }
class NoOpP2pSyncService : P2pSyncService { class NoOpP2pSyncService : P2pSyncService {
override fun startServer(port: Int) {} override fun startServer(port: Int) {}
override fun stopServer() {} override fun stopServer() {}
override suspend fun connectToPeer(host: String, port: Int) {} override suspend fun connectToPeer(host: String, port: Int) {}
override suspend fun broadcastEvent(event: SyncEvent) {} override suspend fun broadcastEvent(event: SyncEvent) {}
override val incomingEvents: Flow<SyncEvent> = emptyFlow() override val incomingEvents: Flow<SyncEvent> = emptyFlow()
override val connectedPeers: Flow<List<String>> = emptyFlow() override val connectedPeers: Flow<List<String>> = emptyFlow()
} }
@@ -26,10 +26,11 @@ import org.koin.compose.viewmodel.koinViewModel
*/ */
@Composable @Composable
fun DesktopApp() { fun DesktopApp() {
val deviceInitViewModel: at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel = koinViewModel() val deviceInitViewModel: at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel =
koinViewModel()
val deviceSettings by deviceInitViewModel.uiState.collectAsState() val deviceSettings by deviceInitViewModel.uiState.collectAsState()
val isDark = when(deviceSettings.settings.appTheme) { val isDark = when (deviceSettings.settings.appTheme) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> isSystemInDarkTheme() at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> isSystemInDarkTheme()
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> false at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> false
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> true at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> true
@@ -27,7 +27,7 @@ private fun PreviewContent() {
Surface { Surface {
// --- REITER --- // --- REITER ---
//ReiterScreen(viewModel = ReiterViewModel()) //ReiterScreen(viewModel = ReiterViewModel())
// --- PFERDE --- // --- PFERDE ---
// PferdeScreen(viewModel = PferdeViewModel()) // PferdeScreen(viewModel = PferdeViewModel())
@@ -35,8 +35,6 @@ private fun PreviewContent() {
// --- VEREIN --- // --- VEREIN ---
// ── Hier den gewünschten Screen eintragen ────────────────────── // ── Hier den gewünschten Screen eintragen ──────────────────────
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {}) // VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
// VeranstalterNeuScreen(onBack = {}, onSave = {}) // VeranstalterNeuScreen(onBack = {}, onSave = {})
@@ -6,8 +6,12 @@ import at.mocode.frontend.core.domain.repository.MasterdataRepository
import at.mocode.frontend.core.navigation.CurrentUserProvider import at.mocode.frontend.core.navigation.CurrentUserProvider
import at.mocode.frontend.core.navigation.DeepLinkHandler import at.mocode.frontend.core.navigation.DeepLinkHandler
import at.mocode.frontend.core.navigation.NavigationPort import at.mocode.frontend.core.navigation.NavigationPort
import at.mocode.frontend.shell.desktop.data.repository.StoreVeranstaltungRepository
import at.mocode.frontend.shell.desktop.navigation.DesktopNavigationPort import at.mocode.frontend.shell.desktop.navigation.DesktopNavigationPort
import at.mocode.frontend.shell.desktop.repository.DesktopMasterdataRepository import at.mocode.frontend.shell.desktop.repository.DesktopMasterdataRepository
import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatViewModel
import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
/** /**
@@ -35,4 +39,6 @@ val desktopModule = module {
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) } single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
single { DeepLinkHandler(get(), get()) } single { DeepLinkHandler(get(), get()) }
single<MasterdataRepository> { DesktopMasterdataRepository(get()) } single<MasterdataRepository> { DesktopMasterdataRepository(get()) }
single<VeranstaltungRepository> { StoreVeranstaltungRepository() }
viewModel { ChatViewModel(get()) }
} }
@@ -1,16 +1,12 @@
package at.mocode.frontend.shell.desktop package at.mocode.frontend.shell.desktop
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import at.mocode.frontend.core.auth.di.authModule import at.mocode.frontend.core.auth.di.authModule
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.localdb.DatabaseProvider import at.mocode.frontend.core.localdb.DatabaseProvider
import at.mocode.frontend.core.localdb.localDbModule import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.network.chat.KtorWebSocketServerService
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.core.network.networkModule import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.frontend.core.sync.di.syncModule import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.frontend.features.billing.di.billingModule import at.mocode.frontend.features.billing.di.billingModule
import at.mocode.frontend.features.device.initialization.di.deviceInitializationModule import at.mocode.frontend.features.device.initialization.di.deviceInitializationModule
@@ -24,76 +20,52 @@ import at.mocode.frontend.features.turnier.di.turnierFeatureModule
import at.mocode.frontend.features.veranstalter.di.veranstalterModule import at.mocode.frontend.features.veranstalter.di.veranstalterModule
import at.mocode.frontend.features.verein.di.vereinFeatureModule import at.mocode.frontend.features.verein.di.vereinFeatureModule
import at.mocode.frontend.features.zns.import.di.znsImportModule import at.mocode.frontend.features.zns.import.di.znsImportModule
import at.mocode.frontend.shell.desktop.data.repository.StoreVeranstaltungRepository
import at.mocode.frontend.shell.desktop.di.desktopModule import at.mocode.frontend.shell.desktop.di.desktopModule
import at.mocode.veranstaltung.feature.di.veranstaltungModule import at.mocode.veranstaltung.feature.di.veranstaltungModule
import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext
import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.dsl.module import org.koin.dsl.module
fun main() = application { fun main() {
try { application {
startKoin { // Koin Starten
val koinApp = startKoin {
printLogger()
modules( modules(
networkModule, networkModule,
syncModule, syncModule,
authModule, authModule,
localDbModule, localDbModule,
pingFeatureModule,
nennungFeatureModule,
znsImportModule,
profileModule,
billingModule,
pferdeModule,
reiterModule,
funktionaerModule,
vereinFeatureModule,
veranstalterModule,
turnierFeatureModule,
veranstaltungModule,
module {
single<VeranstaltungRepository> { StoreVeranstaltungRepository() }
},
deviceInitializationModule,
desktopModule, desktopModule,
deviceInitializationModule,
billingModule,
funktionaerModule,
nennungFeatureModule,
pferdeModule,
pingFeatureModule,
profileModule,
reiterModule,
turnierFeatureModule,
veranstalterModule,
veranstaltungModule,
vereinFeatureModule,
znsImportModule
) )
} }
// Datenbank EAGER initialisieren (JVM-safe via runBlocking) val koin = koinApp.koin
val koin = GlobalContext.get()
val dbProvider = koin.get<DatabaseProvider>() // Datenbank initialisieren und als Singleton registrieren
val dbProvider: DatabaseProvider = koin.get()
val database = runBlocking { dbProvider.createDatabase() } val database = runBlocking { dbProvider.createDatabase() }
koin.loadModules(listOf(module { single { database } }))
loadKoinModules(module { // SyncManager initialisieren und starten (Default Port 8080)
single<AppDatabase> { database } val syncManager: SyncManager = koin.get()
}) syncManager.start(8080)
println("[DesktopApp] KOIN & DB initialisiert") Window(onCloseRequest = ::exitApplication, title = "Meldestelle Desktop") {
DesktopApp()
// Start POC Netzwerk-Dienste
try {
val wsServer = koin.get<KtorWebSocketServerService>()
wsServer.start()
val discovery = koin.get<NetworkDiscoveryService>()
discovery.startDiscovery()
discovery.registerService(wsServer.getPort())
} catch(e: Exception) {
println("[DesktopApp] Netzwerk-Dienste Fehler: %s".format(e.message))
} }
at.mocode.frontend.shell.desktop.data.Store.seed()
} catch (e: Exception) {
println("[DesktopApp] Startup-Fehler: %s".format(e.message))
}
Window(
onCloseRequest = ::exitApplication,
title = "Meldestelle",
state = WindowState(width = 1600.dp, height = 900.dp),
) {
DesktopApp()
} }
} }
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
@@ -16,30 +17,24 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.AppColors import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens import at.mocode.frontend.core.designsystem.theme.Dimens
import java.time.LocalTime import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatMessageState
import java.time.format.DateTimeFormatter import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatViewModel
import org.koin.compose.viewmodel.koinViewModel
data class ChatMessage(
val id: String,
val sender: String,
val text: String,
val time: String,
val isFromMe: Boolean
)
@Composable @Composable
fun ChatScreen( fun ChatScreen(
onBack: () -> Unit onBack: () -> Unit,
viewModel: ChatViewModel = koinViewModel()
) { ) {
var messageText by remember { mutableStateOf("") } var messageText by remember { mutableStateOf("") }
val messages = remember { mutableStateListOf<ChatMessage>() } val messages by viewModel.messages.collectAsState()
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") val peerCount by viewModel.peerCount.collectAsState()
val scrollState = rememberLazyListState()
// Mock initial messages // Auto-scroll to bottom on new messages
LaunchedEffect(Unit) { LaunchedEffect(messages.size) {
if (messages.isEmpty()) { if (messages.isNotEmpty()) {
messages.add(ChatMessage("1", "Richter-Turm 1", "Startliste für Bewerb 5 ist fertig?", "10:45", false)) scrollState.animateScrollToItem(messages.size - 1)
messages.add(ChatMessage("2", "Meldestelle", "Ja, wird gerade gedruckt.", "10:46", true))
} }
} }
@@ -61,9 +56,9 @@ fun ChatScreen(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
"LAN-Kanal: aktiv (3 Teilnehmer)", "LAN-Kanal: aktiv ($peerCount Teilnehmer verbunden)",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = AppColors.Success color = if (peerCount > 0) AppColors.Success else MaterialTheme.colorScheme.error
) )
} }
} }
@@ -71,11 +66,12 @@ fun ChatScreen(
// Chat Messages // Chat Messages
LazyColumn( LazyColumn(
state = scrollState,
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = Dimens.SpacingM), modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = Dimens.SpacingM),
contentPadding = PaddingValues(vertical = Dimens.SpacingM), contentPadding = PaddingValues(vertical = Dimens.SpacingM),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS) verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) { ) {
items(messages) { msg -> items(messages, key = { it.id }) { msg ->
ChatBubble(msg) ChatBubble(msg)
} }
} }
@@ -102,18 +98,11 @@ fun ChatScreen(
IconButton( IconButton(
onClick = { onClick = {
if (messageText.isNotBlank()) { if (messageText.isNotBlank()) {
messages.add( viewModel.sendMessage(messageText)
ChatMessage(
id = messages.size.toString(),
sender = "Meldestelle",
text = messageText,
time = LocalTime.now().format(timeFormatter),
isFromMe = true
)
)
messageText = "" messageText = ""
} }
}, },
enabled = messageText.isNotBlank(),
colors = IconButtonDefaults.iconButtonColors( colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary contentColor = MaterialTheme.colorScheme.onPrimary
@@ -128,7 +117,7 @@ fun ChatScreen(
} }
@Composable @Composable
private fun ChatBubble(msg: ChatMessage) { private fun ChatBubble(msg: ChatMessageState) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (msg.isFromMe) Alignment.End else Alignment.Start horizontalAlignment = if (msg.isFromMe) Alignment.End else Alignment.Start
@@ -0,0 +1,88 @@
package at.mocode.frontend.shell.desktop.screens.chat.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.network.sync.ChatMessageEvent
import at.mocode.frontend.core.network.sync.SyncManager
import at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.*
import kotlin.time.Clock
data class ChatMessageState(
val id: String,
val sender: String,
val text: String,
val time: String,
val isFromMe: Boolean
)
class ChatViewModel(
private val syncManager: SyncManager
) : ViewModel() {
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private val settings = DeviceInitializationSettingsManager.loadSettings()
private val myName = settings?.deviceName ?: "Meldestelle"
private val _messages = MutableStateFlow<List<ChatMessageState>>(emptyList())
val messages: StateFlow<List<ChatMessageState>> = _messages.asStateFlow()
private val _peerCount = MutableStateFlow(0)
val peerCount: StateFlow<Int> = _peerCount.asStateFlow()
init {
viewModelScope.launch {
syncManager.getIncomingEvents().collect { event ->
if (event is ChatMessageEvent) {
_messages.update {
it + ChatMessageState(
id = event.eventId,
sender = event.senderName,
text = event.message,
time = LocalTime.now().format(timeFormatter),
isFromMe = event.originNodeId == myName
)
}
}
}
}
viewModelScope.launch {
syncManager.getConnectedPeers().collect { peers ->
_peerCount.value = peers.size
}
}
}
fun sendMessage(text: String) {
if (text.isBlank()) return
val event = ChatMessageEvent(
eventId = UUID.randomUUID().toString(),
sequenceNumber = 0,
originNodeId = myName,
createdAt = Clock.System.now().toEpochMilliseconds(),
senderName = myName,
message = text
)
// Sofort lokal anzeigen
_messages.update {
it + ChatMessageState(
id = event.eventId,
sender = myName,
text = text,
time = LocalTime.now().format(timeFormatter),
isFromMe = true
)
}
syncManager.broadcastEvent(event)
}
}
+4 -5
View File
@@ -18,12 +18,11 @@ kotlin.stdlib.default.dependency=true
# Gradle Configuration # Gradle Configuration
# Optimized for JDK 25: Added --add-opens and --enable-native-access for compiler tools # Optimized for JDK 25: Added --add-opens and --enable-native-access for compiler tools
org.gradle.jvmargs=-Xmx8g -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Xshare:off -Djava.awt.headless=true --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --enable-native-access=ALL-UNNAMED -Djdk.instrument.traceUsage=false org.gradle.jvmargs=-Xmx12g -Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Xshare:auto -Djava.awt.headless=true --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --enable-native-access=ALL-UNNAMED -Djdk.instrument.traceUsage=false
org.gradle.workers.max=8 org.gradle.workers.max=12
org.gradle.vfs.watch=true org.gradle.vfs.watch=true
# Configuration Cache (Enabled for performance with many modules)
# Configuration Cache (JS-Test workaround) org.gradle.configuration-cache=true
org.gradle.configuration-cache=false
org.gradle.configuration-cache.problems=warn org.gradle.configuration-cache.problems=warn
# Build Performance # Build Performance
-962
View File
@@ -1,962 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
<meta name="csrf-token" content="pTASJlt042Vo3XAFbvffgDFlRJIGLAVOJ9LcEFQe"/>
<meta name="theme-color" content="#ea580c">
<meta name="title" content="CHANGELOG.md - Realease notes &amp; Feedback Management Tool">
<meta name="description"
content="Product feedback collection, analyze insights, build actionable roadmaps, and keep users informed with CHANGELOG.md">
<meta name="keywords"
content="product feedback, SaaS feedback platform, product roadmap tool, product updates, changelog software">
<link rel="alternate" hreflang="x-default" href="http://changelog.md"/>
<meta name="language" content="en">
<meta name="author" content="CHANGELOG.md - Realease notes &amp; Feedback Management Tool">
<meta property="og:url" content="http://changelog.md">
<meta property="og:image" content="https://changelog.md/storage/logo/social_share.jpg">
<meta property="og:site_name" content="CHANGELOG.md - Realease notes &amp; Feedback Management Tool">
<meta property="og:type" content="website">
<meta property="og:title" content="CHANGELOG.md - Realease notes &amp; Feedback Management Tool">
<meta property="og:description"
content="Product feedback collection, analyze insights, build actionable roadmaps, and keep users informed with CHANGELOG.md">
<meta property="og:image:width" content="600">
<meta property="og:image:height" content="315">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="CHANGELOG.md - Realease notes &amp; Feedback Management Tool">
<meta name="twitter:image:src" content="https://changelog.md/storage/logo/social_share.jpg">
<meta name="twitter:description"
content="Product feedback collection, analyze insights, build actionable roadmaps, and keep users informed with CHANGELOG.md">
<meta name="theme" content="classic">
<title>CHANGELOG.md - Realease notes & Feedback Management Tool</title>
<link rel="shortcut icon" type="image/x-icon" href="https://changelog.md/storage/logo/favicon.png">
<style>
:root {
--color-primary: #ea580c !important;
--theme-color-rgb: 234, 88, 12 !important;
--color-primary-l: rgba(var(--theme-color-rgb), 0.08) !important;
}
</style>
<link rel="stylesheet" href="https://changelog.md/assets/global/fonts/css/fontawesome.css">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/plugin/owl-carousel/owl.carousel.min.css">
<link rel="stylesheet"
href="https://changelog.md/assets/templates/classic/plugin/owl-carousel/owl.theme.default.min.css">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/plugin/magnific-poupup/magnific-popup.css">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/plugin/simple-bar/simplebar.min.css">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/plugin/text-typer/typing-text.css">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/plugin/wow-animate/animate.css">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/plugin/swiper/swiper-bundle.min.css">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/plugin/snackbar/snackbar.min.css">
<link rel="stylesheet" href="https://changelog.md/assets/global/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/css/style.css?ver=3.2.1">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/css/responsive.css?ver=3.2.1">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/css/app.css?ver=3.2.1">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/css/custom.css?ver=3.2.1">
<link rel="stylesheet" href="https://changelog.md/assets/templates/classic/css/dark.css?ver=3.2.1">
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-157578943-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'UA-157578943-1');
</script>
<script type="text/javascript" class="flasher-js">(function () {
var rootScript = 'https://cdn.jsdelivr.net/npm/@flasher/flasher@1.3.1/dist/flasher.min.js';
var FLASHER_FLASH_BAG_PLACE_HOLDER = {};
var options = mergeOptions([], FLASHER_FLASH_BAG_PLACE_HOLDER);
function mergeOptions(first, second) {
return {
context: merge(first.context || {}, second.context || {}),
envelopes: merge(first.envelopes || [], second.envelopes || []),
options: merge(first.options || {}, second.options || {}),
scripts: merge(first.scripts || [], second.scripts || []),
styles: merge(first.styles || [], second.styles || []),
};
}
function merge(first, second) {
if (Array.isArray(first) && Array.isArray(second)) {
return first.concat(second).filter(function (item, index, array) {
return array.indexOf(item) === index;
});
}
return Object.assign({}, first, second);
}
function renderOptions(options) {
if (!window.hasOwnProperty('flasher')) {
console.error('Flasher is not loaded');
return;
}
requestAnimationFrame(function () {
window.flasher.render(options);
});
}
function render(options) {
if ('loading' !== document.readyState) {
renderOptions(options);
return;
}
document.addEventListener('DOMContentLoaded', function () {
renderOptions(options);
});
}
if (1 === document.querySelectorAll('script.flasher-js').length) {
document.addEventListener('flasher:render', function (event) {
render(event.detail);
});
}
if (window.hasOwnProperty('flasher') || !rootScript || document.querySelector('script[src="' + rootScript + '"]')) {
render(options);
} else {
var tag = document.createElement('script');
tag.setAttribute('src', rootScript);
tag.setAttribute('type', 'text/javascript');
tag.onload = function () {
render(options);
};
document.head.appendChild(tag);
}
})();</script>
</head>
<body>
<header>
<div class="navbar-area nav-light position-absolute">
<div class="desktop-nav">
<nav class="navbar navbar-expand-lg navbar-light">
<a class="navbar-brand" href="http://changelog.md">
<img class="white-logo" src="https://changelog.md/storage/logo/classic-theme_footer_logo.png"
alt="CHANGELOG.md - Realease notes &amp; Feedback Management Tool"/>
<img class="main-logo" src="https://changelog.md/storage/logo/classic-theme_logo.png"
alt="CHANGELOG.md - Realease notes &amp; Feedback Management Tool"/>
</a>
<div class="navbar-collapse offcanvas offcanvas-nav offcanvas-start" tabindex="-1" id="offcanvasExample"
aria-labelledby="offcanvasExampleLabel">
<div class="offcanvas-header d-lg-none">
<h3 class="navbar-brand offcanvas-title mb-0 font-24" id="offcanvasExampleLabel">
<img class="white-logo" src="https://changelog.md/storage/logo/classic-theme_footer_logo.png"
alt="CHANGELOG.md - Realease notes &amp; Feedback Management Tool"/>
<img class="main-logo" src="https://changelog.md/storage/logo/classic-theme_logo.png"
alt="CHANGELOG.md - Realease notes &amp; Feedback Management Tool"/>
</h3>
<button type="button" class="icon-group -secondary" data-bs-dismiss="offcanvas"
aria-label="Close">
<i class="fa-solid fa-xmark"></i></button>
</div>
<div class="offcanvas-body me-auto d-flex flex-column h-100">
<ul class="navbar-nav">
<li class="nav-item">
<a href="http://changelog.md" class="nav-link">
Home
</a>
</li>
<li class="nav-item">
<a href="http://changelog.md/faq" class="nav-link">
FAQs
</a>
</li>
<li class="nav-item">
<a href="http://changelog.md/blog" class="nav-link">
Blog
</a>
</li>
<li class="nav-item">
<a href="http://changelog.md/contact" class="nav-link">
Contact Us
</a>
</li>
<li class="nav-item d-block d-sm-none">
<a href="http://changelog.md/login" class="nav-link">
Log in
</a>
</li>
<li class="nav-item d-block d-sm-none">
<a href="http://changelog.md/signup" class="nav-link">
Sign up
</a>
</li>
<!--/ # When user logout or new user login signup button-->
</ul>
</div>
</div>
<div class="nav-others d-flex align-items-center">
<!--/ # When user logout or new user login signup button-->
<div class="d-flex justify-content-center">
<a href="http://changelog.md/login"
class="ml-16 button -secondary text-dark-1 px-15 rounded-pill fw-semibold font-16">Log in
</a>
<a href="http://changelog.md/signup"
class="ml-16 button bg-primary text-white px-15 rounded-pill fw-semibold font-16 d-none d-md-flex">Sign
up
</a>
</div>
<!--/ # When user logout or new user login signup button-->
<!--/ # On responsive hamburger menu button for offcanvas desktop nav-->
<div class="sidemenu-header ml-16 d-none">
<div class="responsive-burger-menu icon-group -secondary" data-bs-toggle="offcanvas"
data-bs-target="#offcanvasExample" aria-controls="offcanvasExample">
<i class="fa-solid fa-bars-staggered"></i>
</div>
</div>
<!--/ # On responsive hamburger menu button for offcanvas desktop nav-->
</div>
</nav>
</div>
</div>
</header>
<main class="pt-70">
<!--Hero section start-->
<section class="container py-60" data-cue="fadeIn">
<div class="row justify-content-center">
<div class="col-xl-8 col-lg-10 col-12" data-cues="zoomIn" data-group="page-title" data-delay="700">
<div class="text-center d-flex flex-column gap-4">
<div class="d-flex justify-content-center">
<span
class="bg-primary-l text-primary border-primary border px-3 py-2 font-14 rounded-pill lh-1 align-items-center d-flex">
<i class="fa-regular fa-bolt"></i>
<span class="ms-2 fw-semibold">Effortlessly collect feedback</span>
</span>
</div>
<div class="d-flex flex-column gap-3 mx-lg-5">
<h1 class="mb-0 display-4 fw-bold">Create better products driven by customer feedback</h1>
<p class="mb-0 lead">Simplify feedback collection, lighten support tasks, and share product updates—all in
one powerful tool.</p>
</div>
<div class="d-flex flex-row align-items-center gap-4 justify-content-center">
<a href="http://changelog.md/login" class="button -primary">Get Started</a>
<a href="https://changelog.md"
class="push-right text-primary d-flex align-items-center">
<span class="me-1">Explore product</span>
<i class="fa-light fa-arrow-right push-this"></i>
</a>
</div>
</div>
</div>
</div>
</section>
<div class="pattern-square"
style="background-image: url(https://changelog.md/assets/templates/classic/images/home/bg-pattern.png)"></div>
<section class="container py-60 xl-py-32">
<div class="row justify-content-center">
<div class="col-md-10 col-12">
<div class="text-center position-relative" data-cue="zoomIn">
<img src="https://changelog.md/assets/templates/classic/images/home/hero-app-screen.png"
class="img-fluid bg-light p-3 rounded-3 border" alt=""/>
<div class="position-absolute top-50 d-none d-lg-block ms-n5" data-cue="slideInLeft">
<img src="https://changelog.md/assets/templates/classic/images/home/hero-frame-left.svg" alt=""/>
</div>
<div class="position-absolute top-50 end-0 translate-middle me-n9 d-none d-lg-block">
<img src="https://changelog.md/assets/templates/classic/images/home/hero-frame-right-1.svg"
class="me-n9 mb-4" alt=""
data-cue="slideInRight"/>
<br/>
<img src="https://changelog.md/assets/templates/classic/images/home/hero-frame-right-2.svg" alt=""
data-cue="slideInRight"/>
</div>
</div>
</div>
</div>
</section>
<!--Hero section close-->
<!--Trusted worldwide start-->
<div class="py-30" data-cue="fadeIn">
<div class="container py-2">
<div class="row">
<div class="col-lg-10 offset-lg-1">
<div class="swiper-container swiper"
id="swiper-1"
data-pagination-type=""
data-speed="400"
data-space-between="100"
data-pagination="true"
data-navigation="false"
data-autoplay="true"
data-autoplay-delay="3000"
data-breakpoints='{"480": {"slidesPerView": 2}, "768": {"slidesPerView": 3}, "1024": {"slidesPerView": 5}}'>
<div class="swiper-wrapper pb-40">
<div class="swiper-slide">
<figure class="text-center">
<img src="https://changelog.md/storage/partner/clients-logo-1.svg"
alt="clients-logo-1.svg"/>
</figure>
</div>
<div class="swiper-slide">
<figure class="text-center">
<img src="https://changelog.md/storage/partner/clients-logo-2.svg"
alt="clients-logo-2.svg"/>
</figure>
</div>
<div class="swiper-slide">
<figure class="text-center">
<img src="https://changelog.md/storage/partner/clients-logo-3.svg"
alt="clients-logo-3.svg"/>
</figure>
</div>
<div class="swiper-slide">
<figure class="text-center">
<img src="https://changelog.md/storage/partner/clients-logo-4.svg"
alt="clients-logo-4.svg"/>
</figure>
</div>
<div class="swiper-slide">
<figure class="text-center">
<img src="https://changelog.md/storage/partner/clients-logo-5.svg"
alt="clients-logo-5.svg"/>
</figure>
</div>
</div>
<!-- Add Pagination -->
<div class="swiper-pagination"></div>
<!-- Add Navigation -->
<div class="swiper-navigation">
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!--Trusted worldwide end-->
<!--Feature to boost Start-->
<section class="features container py-60 xl-py-32" data-cue="fadeIn">
<div class="row justify-content-center mb-45 lg-mb-24">
<div class="col-xl-6 col-lg-10 col-12">
<div class="text-center d-flex flex-column gap-4">
<div class="d-flex justify-content-center">
<span
class="bg-primary-l text-primary border-primary border px-3 py-2 font-14 rounded-pill lh-1 align-items-center d-flex">
<i class="fa-regular fa-bolt"></i>
<span class="ms-1 text-uppercase ls-md fw-semibold">Features</span>
</span>
</div>
<div class="d-flex flex-column gap-3 mx-60 lg-mx-0">
<h1 class="mb-0">All-in-One Platform for Customer Feedback</h1>
<p class="mb-0 font-18">Centralize your feedback, prioritize your next steps, and keep everyone
informed.</p>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Tabs -->
<div class="col-lg-10 col-xl-8 col-xxl-6 mx-auto mb-70">
<div class="bg-light-3 rounded-pill p-2">
<ul class="nav nav-pills style-2 flex-nowrap justify-content-between" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link rounded-pill active" id="pills-analytics-tab"
data-bs-toggle="pill"
data-bs-target="#pills-analytics" type="button" role="tab"
aria-controls="pills-analytics" aria-selected="true"
tabindex="-1">Analyze feedback
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link rounded-pill" id="pills-roadmap-tab" data-bs-toggle="pill"
data-bs-target="#pills-roadmap" type="button" role="tab"
aria-controls="pills-roadmap" aria-selected="false">Build roadmap
</button>
</li>
<li class="nav-item position-relative" role="presentation">
<button class="nav-link rounded-pill" id="pills-prioritize-tab" data-bs-toggle="pill"
data-bs-target="#pills-prioritize" aria-controls="pills-prioritize"
aria-selected="false" tabindex="-1" role="tab">Prioritize requests
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link rounded-pill" id="pills-changelog-tab" data-bs-toggle="pill"
data-bs-target="#pills-changelog" aria-controls="pills-changelog"
aria-selected="false" tabindex="-1" role="tab">Share updates
</button>
</li>
</ul>
</div>
</div>
<!-- Tabs content -->
<div class="tab-content" id="pills-tabContent">
<!-- Content item 1-->
<div class="tab-pane active show fade" id="pills-analytics" role="tabpanel"
aria-labelledby="pills-analytics-tab" tabindex="0">
<div class="row align-items-center gy-5">
<!-- Info -->
<div class="col-lg-6 pr-120 xl-pr-0">
<div class="d-flex align-items-center">
<div class="icon-group size-24 bg-dark-3 text-white rounded-2 font-14"><i
class="fa-light fa-bolt"></i></div>
<span
class="text-uppercase font-14 ms-3 fw-bold ls-lg">Powerful SaaS solutions.</span>
</div>
<h1 class="my-24">Feedback Management</h1>
<p>Dont let valuable ideas fall through the cracks. Use a single tool to collect, analyze, and organize
feedback and feature requests efficiently.</p>
<ul>
<li>- Capture customer input seamlessly from conversations with Autopilot.</li>
<li>- Detect and merge duplicate requests to better quantify user needs.</li>
</ul>
<!-- List -->
</div>
<!-- image and decoration -->
<div class="col-lg-6 position-relative">
<figure class="w-100">
<img src="https://changelog.md/assets/templates/classic/images/home/feature/screen1.png"
class="rounded position-relative w-100" alt="feature-img">
</figure>
</div>
</div>
</div>
<!-- Content item 2-->
<div class="tab-pane fade" id="pills-roadmap" role="tabpanel" aria-labelledby="pills-roadmap-tab"
tabindex="0">
<div class="row align-items-center">
<!-- Info -->
<div class="col-lg-6 pr-120 xl-pr-0">
<div class="d-flex align-items-center">
<div class="icon-group size-24 bg-dark-3 text-white rounded-2 font-14"><i
class="fa-light fa-bolt"></i></div>
<span
class="text-uppercase font-14 ms-3 fw-bold ls-lg">Define Your Product Vision.</span>
</div>
<h1 class="my-24">Build your roadmap</h1>
<p>Keep users and stakeholders informed about current projects and upcoming plans.</p>
</div>
<!-- image and decoration -->
<div class="col-lg-6 position-relative ms-auto">
<figure class="w-100">
<img src="https://changelog.md/assets/templates/classic/images/home/feature/screen2.png"
class="rounded position-relative w-100" alt="feature-img">
</figure>
</div>
</div>
</div>
<!-- Content item 2-->
<div class="tab-pane fade" id="pills-prioritize" role="tabpanel"
aria-labelledby="pills-prioritize-tab"
tabindex="0">
<div class="row align-items-center">
<!-- Info -->
<div class="col-lg-6 pr-120 xl-pr-0">
<div class="d-flex align-items-center">
<div class="icon-group size-24 bg-dark-3 text-white rounded-2 font-14"><i
class="fa-light fa-bolt"></i></div>
<span
class="text-uppercase font-14 ms-3 fw-bold ls-lg">Management &amp; prioritization</span>
</div>
<h1 class="my-24">Prioritize feature requests</h1>
<p>Create a prioritization formula to score feedback and feature requests, ensuring you focus on the most
impactful features.</p>
<ul>
<li>- Adjust impact and effort factors to fit your needs.</li>
<li>- Include business-specific post fields for greater flexibility.</li>
<li>- Prioritize features based on user demand.</li>
</ul>
</div>
<!-- image and decoration -->
<div class="col-lg-6 position-relative ms-auto">
<figure class="w-100">
<img src="https://changelog.md/assets/templates/classic/images/home/feature/screen1.png"
class="rounded position-relative w-100" alt="feature-img">
</figure>
</div>
</div>
</div>
<!-- Content item 2-->
<div class="tab-pane fade" id="pills-changelog" role="tabpanel"
aria-labelledby="pills-changelog-tab"
tabindex="0">
<div class="row align-items-center">
<!-- Info -->
<div class="col-lg-6 pr-120 xl-pr-0">
<div class="d-flex align-items-center">
<div class="icon-group size-24 bg-dark-3 text-white rounded-2 font-14"><i
class="fa-light fa-bolt"></i></div>
<span
class="text-uppercase font-14 ms-3 fw-bold ls-lg">CHANGELOG.md</span>
</div>
<h1 class="my-24">Share updates</h1>
<p>Create a changelog that keeps everyone informed and engaged.</p>
<ul>
<li>- Publish Detailed Release Notes.</li>
<li>- Notify users who voted on specific feature requests.</li>
<li>- Drive customer retention, engagement and feature adoption.</li>
</ul>
</div>
<!-- image and decoration -->
<div class="col-lg-6 position-relative ms-auto">
<figure class="w-100">
<img src="https://changelog.md/assets/templates/classic/images/home/feature/screen4.png"
class="rounded position-relative w-100" alt="feature-img">
</figure>
</div>
</div>
</div>
</div>
</div>
</section>
<!--Feature to boost end-->
<!--More focus start-->
<section class="more-focus container py-60 xl-py-32" data-cue="fadeIn">
<div class="row mb-60">
<div class="col-12">
<div class="d-flex flex-column gap-4">
<div class="d-flex">
<span
class="bg-primary-l text-primary border-primary border px-3 py-2 font-14 rounded-pill lh-1 align-items-center d-flex">
<i class="fa-regular fa-bolt"></i>
<span class="ms-1 text-uppercase ls-md fw-semibold">Capture feedback</span>
</span>
</div>
<div class="d-flex justify-content-end gap-5">
<h1 class="mb-0 w-50">Easy to set up and use</h1>
<p class="mb-0 w-50 text-md-end font-18">Simplify feedback collection, lighten support workloads, and
announce product updates—all with a single tool.</p>
</div>
</div>
</div>
</div>
<div class="row gy-4 mb-3">
<div class="col-lg-4 col-12" data-cue="zoomIn">
<div class="card overflow-hidden border card-lift shadow-none p-0">
<div class="card-body me-xl-5 px-30 py-30">
<h4>Feedback Board</h4>
<p class="mb-0">Gather, analyze, and organize feedback in a centralized location</p>
</div>
<div class="text-end ms-4">
<figure class="mb-0">
<img src="https://changelog.md/assets/templates/classic/images/home/feature/feedback.png"
class="img-fluid" alt=""/>
</figure>
</div>
</div>
</div>
<div class="col-lg-4 col-12" data-cue="zoomIn">
<div class="card overflow-hidden border card-lift shadow-none p-0">
<div class="card-body me-xl-5 px-30 py-30">
<h4>Product Roadmap</h4>
<p class="mb-0">Create public/private roadmaps to keep everyone updated on your progress</p>
</div>
<div class="text-end ms-4">
<figure class="mb-0">
<img src="https://changelog.md/assets/templates/classic/images/home/feature/roadmap.png"
class="img-fluid" alt=""/>
</figure>
</div>
</div>
</div>
<div class="col-lg-4 col-12" data-cue="zoomIn">
<div class="card overflow-hidden border card-lift shadow-none p-0">
<div class="card-body me-xl-5 px-30 py-30">
<h4>Changelog</h4>
<p class="mb-0">Increase transparency with detailed change logs</p>
</div>
<div class="text-end ms-4">
<figure class="mb-0">
<img src="https://changelog.md/assets/templates/classic/images/home/feature/changelog.png"
class="img-fluid" alt=""/>
</figure>
</div>
</div>
</div>
</div>
<div class="row gy-4">
<div class="col-md-4" data-cue="zoomIn" data-duration="1500">
<div class="card border card-lift shadow-none h-100">
<div class="card-body">
<div class="d-flex">
<div class="feature-icon">
<i class="fa-light fa-sun-alt icon-group size-50 bg-light-2 font-24"></i>
</div>
<div class="ms-4">
<h4>Feature Request</h4>
<p class="mb-0">Organize feature requests to identify the most in-demand improvements.</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4" data-cue="zoomIn" data-duration="1500">
<div class="card border card-lift shadow-none h-100">
<div class="card-body">
<div class="d-flex">
<div class="feature-icon">
<i class="fa-light fa-dashboard icon-group size-50 bg-light-2 font-24"></i>
</div>
<div class="ms-4">
<h4>Customer Satisfaction</h4>
<p class="mb-0">Collect ongoing feedback to track and improve customer satisfaction over time.</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4" data-cue="zoomIn" data-duration="1500">
<div class="card border card-lift shadow-none h-100">
<div class="card-body">
<div class="d-flex">
<div class="feature-icon">
<i class="fa-light fa-search icon-group size-50 bg-light-2 font-24"></i>
</div>
<div class="ms-4">
<h4>Analyze feedback</h4>
<p class="mb-0">
Uncover valuable customer insights to make better product decisions.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6" data-cue="zoomIn" data-duration="1500">
<div class="card border card-lift shadow-none h-100">
<div class="card-body">
<div class="d-flex">
<div class="feature-icon">
<i class="fa-light fa-bug icon-group size-50 bg-light-2 font-24"></i>
</div>
<div class="ms-4">
<h4>Bug Reporting</h4>
<p class="mb-0">Receive instant notifications when users report bugs, keeping you ahead of critical
issues.</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6" data-cue="zoomIn" data-duration="1500">
<div class="card border card-lift shadow-none h-100">
<div class="card-body">
<div class="d-flex">
<div class="feature-icon">
<i class="fa-light fa-heart-pulse icon-group size-50 bg-light-2 font-24"></i>
</div>
<div class="ms-4">
<h4>Uptime monitoring service</h4>
<p class="mb-0">Create beautiful status pages &amp; incident management reports and keep your visitors
updated.(Soon)</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!--More focus end-->
<section class="container my-60" data-cue="zoomIn">
<div class="container theme-gradient-dark rounded-4 shadow-3">
<div class="p-5 py-50">
<div class="row g-4 align-items-center text-center text-xl-start">
<div class="col-xl-5">
<div class="text-white">
<span class="text-uppercase">Join Our Newsletter</span>
<h2 class="text-white mb-0">Subscribe Now</h2>
</div>
</div>
<div class="col-xl-7">
<form action="http://changelog.md/newsletter" method="post" class="d-flex">
<input type="hidden" name="_token" value="pTASJlt042Vo3XAFbvffgDFlRJIGLAVOJ9LcEFQe"> <input name="email"
class="form-control rounded-5 h-48-px me-3 px-20"
placeholder="Enter your email address"
type="email"
value="">
<button class="button -primary -lg rounded-5">Subscribe</button>
</form>
</div>
</div>
</div>
</div>
</section>
<!--FAQ-->
<section class="container py-60 xl-py-32" data-cue="fadeIn">
<div class="row justify-content-center mb-60">
<div class="col-xl-6 col-lg-10 col-12">
<div class="text-center d-flex flex-column gap-4">
<div class="d-flex justify-content-center">
<span
class="bg-primary-l text-primary border-primary border px-3 py-2 font-14 rounded-pill lh-1 align-items-center d-flex">
<i class="fa-regular fa-bolt"></i>
<span class="ms-1 text-uppercase ls-md fw-semibold">Help Center</span>
</span>
</div>
<div class="d-flex flex-column gap-3 mx-70 xl-mx-0">
<h1 class="mb-0">Frequently Asked Questions</h1>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="ui-content">
<div class="accordion -style2 faq-page mb-4 mb-lg-5">
<div class="accordion" id="accordionExample">
<div class="accordion-item active ">
<h2 class="accordion-header" id="heading1">
<button class="accordion-button " type="button" data-bs-toggle="collapse" data-bs-target="#collapse1"
aria-expanded="" aria-controls="collapse1">
What is CHANGELOG.md?
</button>
</h2>
<div id="collapse1" class="accordion-collapse collapse show " aria-labelledby="heading1"
data-bs-parent="#accordionExample">
<div class="accordion-body"><p>CHANGELOG.md is a SaaS platform designed to help you collect, analyze,
and act on customer feedback to uncover valuable insights and make informed product decisions.</p>
</div>
</div>
</div>
<div class="accordion-item ">
<h2 class="accordion-header" id="heading2">
<button class="accordion-button collapsed " type="button" data-bs-toggle="collapse"
data-bs-target="#collapse2" aria-expanded="" aria-controls="collapse2">
Can I use CHANGELOG.md to prioritize feature requests?
</button>
</h2>
<div id="collapse2" class="accordion-collapse collapse " aria-labelledby="heading2"
data-bs-parent="#accordionExample">
<div class="accordion-body"><p>Absolutely! With our prioritization tools, you can score feedback and
feature requests based on factors like impact and effort, helping you focus on what matters
most.</p></div>
</div>
</div>
<div class="accordion-item ">
<h2 class="accordion-header" id="heading3">
<button class="accordion-button collapsed " type="button" data-bs-toggle="collapse"
data-bs-target="#collapse3" aria-expanded="" aria-controls="collapse3">
How does CHANGELOG.md help with roadmapping?
</button>
</h2>
<div id="collapse3" class="accordion-collapse collapse " aria-labelledby="heading3"
data-bs-parent="#accordionExample">
<div class="accordion-body"><p>CHANGELOG.md allows you to build a clear and actionable roadmap by
organizing feedback and aligning it with your product vision.</p></div>
</div>
</div>
<div class="accordion-item ">
<h2 class="accordion-header" id="heading4">
<button class="accordion-button collapsed " type="button" data-bs-toggle="collapse"
data-bs-target="#collapse4" aria-expanded="" aria-controls="collapse4">
Can I share updates with my users?
</button>
</h2>
<div id="collapse4" class="accordion-collapse collapse " aria-labelledby="heading4"
data-bs-parent="#accordionExample">
<div class="accordion-body"><p>Yes! CHANGELOG.md includes a changelog feature where you can publish
detailed release notes, link them to specific feature requests, and notify users who requested those
features automatically.</p></div>
</div>
</div>
<div class="accordion-item ">
<h2 class="accordion-header" id="heading5">
<button class="accordion-button collapsed " type="button" data-bs-toggle="collapse"
data-bs-target="#collapse5" aria-expanded="" aria-controls="collapse5">
Does CHANGELOG.md integrate with other tools?
</button>
</h2>
<div id="collapse5" class="accordion-collapse collapse " aria-labelledby="heading5"
data-bs-parent="#accordionExample">
<div class="accordion-body"><p>Yes, CHANGELOG.md integrates with popular customer support and project
management tools, allowing your team to seamlessly capture and manage feedback within their existing
workflows.</p></div>
</div>
</div>
<div class="accordion-item ">
<h2 class="accordion-header" id="heading6">
<button class="accordion-button collapsed " type="button" data-bs-toggle="collapse"
data-bs-target="#collapse6" aria-expanded="" aria-controls="collapse6">
Who can benefit from using CHANGELOG.md?
</button>
</h2>
<div id="collapse6" class="accordion-collapse collapse " aria-labelledby="heading6"
data-bs-parent="#accordionExample">
<div class="accordion-body"><p>Product managers, customer success teams, and anyone involved in
building and improving products can benefit from CHANGELOG.md. Its perfect for startups, SaaS
companies, and organizations looking to make data-driven product decisions.</p></div>
</div>
</div>
<div class="accordion-item ">
<h2 class="accordion-header" id="heading7">
<button class="accordion-button collapsed " type="button" data-bs-toggle="collapse"
data-bs-target="#collapse7" aria-expanded="" aria-controls="collapse7">
How do I get started with CHANGELOG.md?
</button>
</h2>
<div id="collapse7" class="accordion-collapse collapse " aria-labelledby="heading7"
data-bs-parent="#accordionExample">
<div class="accordion-body"><p>Getting started is simple! Sign up for a free trial, set up your
feedback portal, and start collecting insights to drive your product decisions.</p></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!--FAQ end-->
<!--Call to action start-->
<section class="container lg-mb-auto mb-60 py-60 xl-py-32" data-cue="zoomIn">
<div class="container theme-gradient-dark rounded-4 shadow-3">
<div class="p-5 py-100 text-center">
<div class="d-flex flex-column gap-4">
<div class="d-flex justify-content-center">
<span
class="bg-primary-l text-primary border-primary border px-3 py-2 font-14 rounded-pill lh-1 align-items-center d-flex">
<i class="fa-regular fa-bolt"></i>
<span class="ms-1 text-uppercase ls-md fw-semibold">More features. More power.</span>
</span>
</div>
<div class="d-flex flex-column gap-3 mx-70 xl-mx-0">
<h1 class="mb-0 text-white">We bring companies and customers even closer</h1>
<p class="mb-0 text-white">Ready to start building the right things?</p>
</div>
</div>
<div class="d-flex align-items-center justify-content-center gap-3 mt-24">
<a href="http://changelog.md/login" class="button -primary">Join now</a>
<a href="https://changelog.md" class="button bg-white">Try demo</a>
</div>
</div>
</div>
</section>
<!--Call to action end-->
</main>
<!-- Footer -->
<footer>
<div class="container">
<div class="d-flex justify-content-center align-items-center">
<div class="d-flex flex-column align-items-center">
<a href="http://changelog.md">
<img class="white-logo mb-16 w-130-px"
src="https://changelog.md/storage/logo/classic-theme_footer_logo.png"
alt="CHANGELOG.md - Realease notes &amp; Feedback Management Tool">
<img class="main-logo mb-16 w-130-px" src="https://changelog.md/storage/logo/classic-theme_logo.png"
alt="CHANGELOG.md - Realease notes &amp; Feedback Management Tool">
</a>
<div class="d-flex align-items-center font-16 gap-3">
<div><a href="http://changelog.md" class="-underline fw-semibold">Home</a></div>
<div><a href="http://changelog.md/page/terms-conditions"
class="-underline fw-semibold">Terms &amp; Conditions</a></div>
<div><a href="http://changelog.md/page/privacy"
class="-underline fw-semibold">Privacy Policy</a></div>
</div>
</div>
</div>
<div class="separator-1px-op-l my-32"></div>
<div class="row align-items-center g-4 g-lg-0 pb-32">
<div class="col-lg-4 order-lg-first order-last text-lg-start text-center">
Copyright © 2026 Changelog.md. All Rights Reserved.
</div>
<div class="col-lg-4">
<div class="d-flex font-16 gap-3 justify-content-center">
<div>
<a href="http://changelog.md/faq"
class="text-dark-1 text-decoration -underline fw-semibold">FAQs</a>
</div>
<div>
<a href="http://changelog.md/feedback"
class="text-dark-1 text-decoration -underline fw-semibold">Feedback</a>
</div>
<div>
<a href="http://changelog.md/contact"
class="text-dark-1 text-decoration -underline fw-semibold">Contact</a>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="d-flex font-16 gap-3 justify-content-lg-end justify-content-center">
<div>
<a class="icon-group -outlined -light shadow-3 rounded-3"
href="https://x.com/changelogmd" rel="nofollow">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 14 14"
style="fill: currentcolor; height: .9em; overflow: visible; width: 1em;">
<path
d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z"/>
</svg>
</a>
</div>
</div>
</div>
</div>
</div>
</footer>
<script>
"use strict";
var themecolor = "#ea580c";
var siteurl = "http:\/\/changelog.md";
var ajax_url = "http:\/\/changelog.md\/ajax";
var LANG_LOGGED_IN_SUCCESS = "Logged in successfully";
var LANG_DEVELOPED_BY = "Developed by";
var DEVELOPER_CREDIT = 1;
var LIVE_CHAT = null;
var DARK_MODE_SWITCH = 0;
var DEFAULT_THEME_MODE = "light";
</script>
<script src="https://changelog.md/assets/global/js/jquery.min.js"></script>
<script src="https://changelog.md/assets/global/js/jquery.form.js"></script>
<script src="https://changelog.md/assets/global/bootstrap/js/bootstrap.min.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/owl-carousel/owl.carousel.min.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/owl-carousel/carousel-thumbs.min.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/magnific-poupup/jquery.magnific-popup.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/mixitup/mixitup.min.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/simple-bar/simplebar.min.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/appear/appear.min.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/text-typer/typing-text.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/wow-animate/wow.min.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/swiper/swiper-bundle.min.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/swiper/swiper.js"></script>
<script src="https://changelog.md/assets/templates/classic/plugin/snackbar/snackbar.min.js"></script>
<!--Custom JS-->
<script src="https://changelog.md/assets/templates/classic/js/user-ajax.js?ver=3.2.1"></script>
<script src="https://changelog.md/assets/templates/classic/js/custom.js?ver=3.2.1"></script>
<script src="https://changelog.md/assets/templates/classic/js/script.js?ver=3.2.1"></script>
</body>
</html>
+29 -16
View File
@@ -1,29 +1,42 @@
#!/bin/bash #!/bin/bash
echo "===========================================" echo "==========================================="
echo "Meldestelle - Netzwerk-Setup für POC" echo "Meldestelle - Netzwerk-Optimierung (Firewall)"
echo "===========================================" echo "==========================================="
if [ "$EUID" -ne 0 ]; then if [ "$EUID" -ne 0 ]; then
echo "Bitte mit sudo ausführen: sudo ./setup-firewall-linux.sh" echo "Bitte mit sudo ausführen: sudo ./setup-firewall-linux.sh"
exit exit 1
fi fi
# Erkennung der Firewall (firewalld für Fedora/KDE, ufw für Ubuntu) # Ports:
if command -v firewall-cmd &> /dev/null; then # 8080 (P2P Sync), 8090 (Chat WS), 5353 (mDNS)
echo "[Fedora/firewalld] Öffne Ports 8090 (TCP), 8080 (TCP) und 5353 (UDP)..." # 8500 (Consul UI - optional), 8600 (Consul DNS - optional)
firewall-cmd --permanent --add-port=8090/tcp
open_ports_firewalld() {
echo "[Fedora/firewalld] Konfiguriere..."
firewall-cmd --permanent --add-port=8080/tcp firewall-cmd --permanent --add-port=8080/tcp
firewall-cmd --permanent --add-port=8090/tcp
firewall-cmd --permanent --add-service=mdns firewall-cmd --permanent --add-service=mdns
# Optional: Consul Ports falls nötig
# firewall-cmd --permanent --add-port=8500/tcp
firewall-cmd --reload firewall-cmd --reload
echo "Fertig!" echo "Fertig!"
elif command -v ufw &> /dev/null; then }
echo "[Ubuntu/ufw] Öffne Ports 8090 (TCP), 8080 (TCP) und 5353 (UDP)..."
ufw allow 8090/tcp
ufw allow 8080/tcp
ufw allow 5353/udp
echo "Fertig!"
else
echo "Keine bekannte Firewall (ufw/firewalld) gefunden. Bitte Ports manuell prüfen."
fi
echo "Das System ist nun bereit für den Meldestelle-POC." open_ports_ufw() {
echo "[Ubuntu/ufw] Konfiguriere..."
ufw allow 8080/tcp comment 'Meldestelle Sync'
ufw allow 8090/tcp comment 'Meldestelle Chat'
ufw allow 5353/udp comment 'mDNS Discovery'
ufw reload
echo "Fertig!"
}
if command -v firewall-cmd &> /dev/null; then
open_ports_firewalld
elif command -v ufw &> /dev/null; then
open_ports_ufw
else
echo "Keine unterstützte Firewall (ufw/firewalld) gefunden."
echo "Bitte öffnen Sie manuell: 8080/tcp, 8090/tcp und 5353/udp."
fi