Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3d327f28f | |||
| 2b05d8c952 | |||
| 6cb41c1277 | |||
| f0d02d6af1 | |||
| 6f5e24104b | |||
| 43406c29d1 | |||
| 7873c45cfc | |||
| 14c4192306 | |||
| 1efc7e01be | |||
| d567ba583b | |||
| 5a5af5375d | |||
| a60eb6eebf | |||
| 23b7a4cd2f | |||
| e6fd485049 | |||
| 15d9eba987 | |||
| 05d6d299da | |||
| ab9db1211c | |||
| 45b69e5ddb | |||
| 4971bc859b | |||
| b7f55b0de0 | |||
| 9c24e79bba | |||
| 79c325329c | |||
| 8f81ffb4c5 | |||
| 1d7692ea01 | |||
| 038390b8cd | |||
| f2c1dc2ba5 | |||
| 3eeae76e6c | |||
| 0228e6cb1d | |||
| 4c59cca1a4 | |||
| 8412ae76ee | |||
| ec4483c415 | |||
| 52656c715e | |||
| f6755aad42 | |||
| cbbcc09fc2 | |||
| aa8912e8b7 | |||
| 68488598af | |||
| 6e8bb8236e | |||
| bd92d60d08 | |||
| 4991587d99 | |||
| 2b8bf0b7d5 | |||
| 9ae316cdd9 | |||
| 12f62e5146 | |||
| 9ddaaaae1e | |||
| 3a1518571b | |||
| ebb266e099 | |||
| df677908d0 | |||
| bc61e87ab0 | |||
| 2d37ded5be | |||
| 31de22d460 | |||
| 96f918828c | |||
| eb912c3a73 | |||
| 3044b36e9f | |||
| e464628ab7 | |||
| a5a6f89f18 |
+2
-3
@@ -1,4 +1,3 @@
|
|||||||
NUITKA_BUILD=0
|
NUITKA_BUILD=0
|
||||||
INVENTAR_HTTP_PORT=80
|
INVENTAR_HTTP_PORT=10000
|
||||||
INVENTAR_HTTPS_PORT=443
|
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
||||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:latest
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
working_dir: /app/Web
|
||||||
|
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||||
|
image: ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
||||||
|
build: null
|
||||||
@@ -158,42 +158,17 @@ jobs:
|
|||||||
- name: Create release-only docker bundle
|
- name: Create release-only docker bundle
|
||||||
run: |
|
run: |
|
||||||
mkdir -p release-bundle
|
mkdir -p release-bundle
|
||||||
mkdir -p release-bundle/docker/nginx
|
|
||||||
cat > release-bundle/docker-compose.yml <<EOF
|
cat > release-bundle/docker-compose.yml <<EOF
|
||||||
services:
|
services:
|
||||||
nginx:
|
|
||||||
image: nginx:1.27-alpine
|
|
||||||
container_name: inventarsystem-nginx
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
ports:
|
|
||||||
- "${INVENTAR_HTTP_PORT:-80}:80"
|
|
||||||
- "${INVENTAR_HTTPS_PORT:-443}:443"
|
|
||||||
volumes:
|
|
||||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
|
||||||
- ./certs:/etc/nginx/certs:ro
|
|
||||||
|
|
||||||
mongodb:
|
|
||||||
image: mongo:7.0
|
|
||||||
container_name: inventarsystem-mongodb
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- mongodb_data:/data/db
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:latest}
|
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
||||||
pull_policy: never
|
|
||||||
container_name: inventarsystem-app
|
container_name: inventarsystem-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
mongodb:
|
- mongodb
|
||||||
condition: service_healthy
|
- redis
|
||||||
environment:
|
environment:
|
||||||
INVENTAR_MONGODB_HOST: mongodb
|
INVENTAR_MONGODB_HOST: mongodb
|
||||||
INVENTAR_MONGODB_PORT: "27017"
|
INVENTAR_MONGODB_PORT: "27017"
|
||||||
@@ -211,6 +186,33 @@ jobs:
|
|||||||
- app_backups:/data/backups
|
- app_backups:/data/backups
|
||||||
- app_logs:/data/logs
|
- app_logs:/data/logs
|
||||||
|
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7.0
|
||||||
|
container_name: inventarsystem-mongodb
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- mongodb_data:/data/db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: inventarsystem-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongodb_data:
|
mongodb_data:
|
||||||
app_uploads:
|
app_uploads:
|
||||||
@@ -219,9 +221,9 @@ jobs:
|
|||||||
app_qrcodes:
|
app_qrcodes:
|
||||||
app_backups:
|
app_backups:
|
||||||
app_logs:
|
app_logs:
|
||||||
|
redis_data:
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cp docker/nginx/default.conf release-bundle/docker/nginx/default.conf
|
|
||||||
cp start.sh release-bundle/start.sh
|
cp start.sh release-bundle/start.sh
|
||||||
cp stop.sh release-bundle/stop.sh
|
cp stop.sh release-bundle/stop.sh
|
||||||
cp restart.sh release-bundle/restart.sh
|
cp restart.sh release-bundle/restart.sh
|
||||||
@@ -229,8 +231,15 @@ jobs:
|
|||||||
cp config.json release-bundle/config.json
|
cp config.json release-bundle/config.json
|
||||||
cp update.sh release-bundle/update.sh
|
cp update.sh release-bundle/update.sh
|
||||||
cp init-admin.sh release-bundle/init-admin.sh
|
cp init-admin.sh release-bundle/init-admin.sh
|
||||||
|
|
||||||
|
# Multitenant scripts & docs
|
||||||
|
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
|
||||||
|
cp manage-tenant.sh release-bundle/manage-tenant.sh
|
||||||
|
cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
|
||||||
|
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md
|
||||||
|
cp MULTITENANT_PYTHON_API.md release-bundle/MULTITENANT_PYTHON_API.md
|
||||||
|
|
||||||
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/init-admin.sh
|
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/init-admin.sh release-bundle/manage-tenant.sh release-bundle/run-tenant-cmd.sh
|
||||||
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
||||||
|
|
||||||
- name: Create or update GitHub Release
|
- name: Create or update GitHub Release
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
# Fehlersuche: Geplante Ausleihen werden nicht automatisch aktiviert
|
|
||||||
|
|
||||||
## 🔍 Mögliche Ursachen
|
|
||||||
|
|
||||||
### 1. **Termin wurde für falsches Datum erstellt**
|
|
||||||
Die meisten Probleme entstehen, weil der Termin für ein **falsches Datum** (z.B. morgen statt heute) erstellt wurde.
|
|
||||||
|
|
||||||
### 2. **Scheduler läuft nicht**
|
|
||||||
Der automatische Scheduler kann deaktiviert sein oder nicht starten.
|
|
||||||
|
|
||||||
### 3. **Zeitzonen-Problem**
|
|
||||||
Die Startzeit kann in einer anderen Zeitzone gespeichert sein als erwartet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ SOFORT-FIX: Manuell aktivieren
|
|
||||||
|
|
||||||
Falls du eine geplante Ausleihe sofort aktivieren möchtest, nutze diesen Befehl auf dem Server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec inventarsystem-mongodb mongosh --eval "
|
|
||||||
db.ausleihungen.updateOne(
|
|
||||||
{_id: ObjectId('BOOKING_ID_HIER')},
|
|
||||||
{\$set: {Status: 'active', LastUpdated: new Date()}}
|
|
||||||
)" Inventarsystem
|
|
||||||
```
|
|
||||||
|
|
||||||
Ersetze `BOOKING_ID_HIER` mit der ID deiner Ausleihe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 DEBUG: Alle geplanten Ausleihen anschauen
|
|
||||||
|
|
||||||
Führe auf deinem Server aus:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec inventarsystem-mongodb mongosh --eval "
|
|
||||||
db.ausleihungen.find({Status: 'planned'}).pretty()
|
|
||||||
" Inventarsystem
|
|
||||||
```
|
|
||||||
|
|
||||||
Das zeigt dir:
|
|
||||||
- **Start**: Das gespeicherte Startdatum/Uhrzeit
|
|
||||||
- **Periode**: Die Schulstunde (falls verwendet)
|
|
||||||
- **Erstellungs-Zeit vs. aktuelle Zeit**: Vergleich
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 PROBLEM 1: Falsches Datum beim Erstellen
|
|
||||||
|
|
||||||
**Symptom**: Start-Zeit ist in der Zukunft (z.B. morgen statt heute)
|
|
||||||
|
|
||||||
**Lösung**:
|
|
||||||
1. In der Kalender-Ansicht das **richtige Datum** auswählen
|
|
||||||
2. Die **aktuelle Uhrzeit** für den Beginn nutzen (nicht eine zukünftige Zeit)
|
|
||||||
3. Für Schulstunden: Stelle sicher die **heutige Schulstunde** zu wählen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 PROBLEM 2: Scheduler läuft nicht
|
|
||||||
|
|
||||||
**Symptom**: Keine Logs über Termin-Aktivierungen
|
|
||||||
|
|
||||||
**Überprüfung**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Container-Logs anschauen
|
|
||||||
docker logs inventarsystem-app | grep -i "scheduler\|appointment\|status update"
|
|
||||||
|
|
||||||
# 2. Scheduler ist aktiviert?
|
|
||||||
docker exec inventarsystem-app python3 -c "
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, '/app/Web')
|
|
||||||
import settings as cfg
|
|
||||||
print(f'Scheduler aktiviert: {cfg.SCHEDULER_ENABLED}')
|
|
||||||
print(f'Scheduler Intervall: {cfg.SCHEDULER_INTERVAL_MIN} Minuten')
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Lösung wenn deaktiviert**:
|
|
||||||
- In `config.json` ändern:
|
|
||||||
```json
|
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval_minutes": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Container neu starten: `docker-compose restart app`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 PROBLEM 3: Startzeit als nur-Zeit (kein Datum)
|
|
||||||
|
|
||||||
**Symptom**: Start-Zeit zeigt nur "13:30" ohne Datum
|
|
||||||
|
|
||||||
**Überprüfung**: In der Datenbank nach `{Start: {$type: "string"}}` suchen statt datetime
|
|
||||||
|
|
||||||
**Lösung**: Die Ausleihe muss mit einem vollständigen Datum+Uhrzeit erstellt werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 AUTOMATISCHE AKTIVIERUNG TESTEN
|
|
||||||
|
|
||||||
Nach dem Fix: Erstelle eine Ausleihe für:
|
|
||||||
- **Heute** (das aktuelle Datum)
|
|
||||||
- **Schulstunde 7** (13:30-14:15)
|
|
||||||
- Startzeitpunkt sollte **jetzt oder in 1 Minute** sein
|
|
||||||
|
|
||||||
Warte 2 Minuten. Der Status sollte von `planned` zu `active` wechseln.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 SCHEDULER STATUS ÜBERPRÜFEN
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Zeige die letzten 50 Log-Zeilen
|
|
||||||
docker logs --tail 50 inventarsystem-app | tail -20
|
|
||||||
|
|
||||||
# Suche nach "Appointment status update"
|
|
||||||
docker logs inventarsystem-app 2>/dev/null | grep -i "Appointment status" | tail -5
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartete Log-Ausgabe:
|
|
||||||
```
|
|
||||||
Appointment status update finished: X changed (Y active, Z completed)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 DETAILLIERTE DEBUG-INFOS
|
|
||||||
|
|
||||||
Wenn obiges nicht funktioniert, führe dies aus:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec inventarsystem-app python3 -c "
|
|
||||||
from pymongo import MongoClient
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
client = MongoClient('mongodb', 27017)
|
|
||||||
db = client['Inventarsystem']
|
|
||||||
aus = db['ausleihungen']
|
|
||||||
|
|
||||||
print('=== GEPLANTE AUSLEIHEN ===')
|
|
||||||
for b in aus.find({'Status': 'planned'}).limit(3):
|
|
||||||
print(f\"Start: {b.get('Start')} | Typ: {type(b.get('Start')).__name__}\")
|
|
||||||
print(f\"Periode: {b.get('Period')} | User: {b.get('User')}\")
|
|
||||||
print(f\"Jetzt > Start? {datetime.datetime.now() > b.get('Start', datetime.datetime.max)}\")
|
|
||||||
print()
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 EMPFOHLENE EINSTELLUNG FÜR SCHULEN
|
|
||||||
|
|
||||||
In `config.json` verwende diese Werte:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval_minutes": 1,
|
|
||||||
"backup_interval_hours": 24
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Dies checkt **jede Minute** ob Termine aktiviert werden sollen - perfekt für Schulstunden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Weitere Hilfe?
|
|
||||||
|
|
||||||
Falls das nicht funktioniert:
|
|
||||||
1. Schreib mir die **exakte Uhrzeit** wann du die Ausleihe erstellt hast
|
|
||||||
2. Und wann sie hätte aktiviert werden sollen
|
|
||||||
3. Zeige mir ein Beispiel aus der DB mit `docker exec inventarsystem-mongodb mongosh...`
|
|
||||||
@@ -14,7 +14,7 @@ Die optimierte Multi-Tenant-Architektur unterstützt **mehrere isolierte Instanz
|
|||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
↓ ↓ ↓
|
↓ ↓ ↓
|
||||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
│ App :8001 │ │ App :8002 │ │ App :8003 │
|
│ App :10000 │ │ App :10002 │ │ App :10004 │
|
||||||
│ schule1 │ │ schule2 │ │ schule3 │
|
│ schule1 │ │ schule2 │ │ schule3 │
|
||||||
│ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │
|
│ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │
|
||||||
│ 20 Users │ │ 20 Users │ │ 20 Users │
|
│ 20 Users │ │ 20 Users │ │ 20 Users │
|
||||||
@@ -452,3 +452,44 @@ Für Support: Siehe Legal/LICENSE
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Version**: 1.0 | **Letzte Aktualisierung**: 2026-04-17 | **Kompatibilität**: Python 3.11+, MongoDB 7.0+, Redis 7+
|
**Version**: 1.0 | **Letzte Aktualisierung**: 2026-04-17 | **Kompatibilität**: Python 3.11+, MongoDB 7.0+, Redis 7+
|
||||||
|
|
||||||
|
## Tenant Management Operationen (manage-tenant.sh)
|
||||||
|
|
||||||
|
Um einzelne Tenants im Multi-Tenant-Umfeld im laufenden Betrieb und ohne globale Downtime zu verwalten, kann das neue CLI-Skript `manage-tenant.sh` verwendet werden.
|
||||||
|
|
||||||
|
### 1. Neuen Tenant hinzufügen
|
||||||
|
Initialisiert die MongoDB-Datenbankstruktur isoliert für einen neuen Tenant und legt initiale Admin-Zugangsdaten an.
|
||||||
|
```bash
|
||||||
|
./manage-tenant.sh add <tenant_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Bestimmten Tenant neu starten (Soft-Restart)
|
||||||
|
Erzwingt sofortigen Logout und einen Cache/Session-Reset für die Nutzer *eines spezifischen* Tenants, ohne andere laufende Instanzen zu beeinträchtigen. Ideal bei Konfigurationsänderungen oder feststeckenden Sessions.
|
||||||
|
```bash
|
||||||
|
./manage-tenant.sh restart-tenant <tenant_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Tenant sicher entfernen
|
||||||
|
Löscht die dedizierte MongoDB-Datenbank des gewählten Tenants vollständig (erfordert Bestätigung).
|
||||||
|
```bash
|
||||||
|
./manage-tenant.sh remove <tenant_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Globale Operationen
|
||||||
|
```bash
|
||||||
|
# Zeigt alle aktiven isolierten Tenant-Datenbanken an
|
||||||
|
./manage-tenant.sh list
|
||||||
|
|
||||||
|
# Führt einen Zero-Downtime Rolling-Restart aller Application-Container durch
|
||||||
|
./manage-tenant.sh restart-all
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aktuelle UI- & Funktionsoptimierungen (Release April 2026)
|
||||||
|
|
||||||
|
Neben der Docker-Auslagerung wurden spezifische Caching-, Parsing-, und DOM-Tricks integriert, die das Setup weiter entschlacken:
|
||||||
|
|
||||||
|
* **DOM Array Slicing für Bilder:** Bei großen Beständen (hunderte Artikel) rendert der Client im Listen/Kachel-Modus künftig nur noch das primäre Bild (`slice(0, 1)`), was den DOM-Memory-Footprint drastisch reduziert und das Einfrieren von Browsern verhindert.
|
||||||
|
* **Auto-Ingestion von Excel-Filtern:** Der Excel-Importer prüft nun dynamisch neue `categories/filter`, die noch nicht in der Datenbank existieren, und speichert sie direkt in die MongoDB `filter_presets`-Kollektion (Zero-Config für Administratoren).
|
||||||
|
* **Responsive UI Synchronisierung:** Die Standardansicht (`main.html`) der Smartphones wurde CSS-technisch exakt an das skalierungsfähigere Profil der Admin-Mobile-Ansicht (`main_admin.html`) angeglichen.
|
||||||
|
|||||||
@@ -322,6 +322,32 @@ INVENTAR_WORKER_CONNECTIONS=100
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Schul-Konfiguration pro Tenant
|
||||||
|
|
||||||
|
Die Datei `config.json` unterstützt jetzt einen `tenants`-Block. Damit kann jede Schule eigene Modul-Schalter bekommen, ohne dass das ganze System global umgestellt werden muss.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenants": {
|
||||||
|
"schule1": {
|
||||||
|
"modules": {
|
||||||
|
"library": { "enabled": true },
|
||||||
|
"student_cards": { "enabled": false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schule2": {
|
||||||
|
"modules": {
|
||||||
|
"library": { "enabled": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn ein Request über Subdomain oder `X-Tenant-ID` aufgelöst wird, liest die App diese Werte automatisch aus und blendet die Bibliothek bzw. andere Module nur für diesen Tenant ein oder aus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Support & Debugging
|
## Support & Debugging
|
||||||
|
|
||||||
**Fragen?**
|
**Fragen?**
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Multi-Tenant Python Management API
|
||||||
|
|
||||||
|
This document explains how the multi-tenant architecture isolates data within Python, what the return values are, and how developers can build internal administrative scripts using native Python instead of the Docker CLI.
|
||||||
|
|
||||||
|
## 1. Architectural Concept
|
||||||
|
|
||||||
|
In the system, each "tenant" is essentially a dedicated MongoDB database identified by a dynamically generated string based on a subdomain or header (`inventar_<tenant_id>`).
|
||||||
|
App containers share a connection pool using `pymongo.MongoClient`, and requests are routed to specific databases dynamically based on the current Flask `g.tenant_context`.
|
||||||
|
|
||||||
|
All MongoDB administrative tasks (creating tenants, restarting apps, fetching lists) are done via standard MongoDB Python drivers because the core multi-tenancy happens at the **database level**.
|
||||||
|
|
||||||
|
## 2. Managing Tenants via Python
|
||||||
|
|
||||||
|
If you want to perform multi-tenant administrative operations without traversing through `manage-tenant.sh`, you can execute native Python scripts connecting to the system's `MongoClient`.
|
||||||
|
|
||||||
|
### Basic Connection Boilerplate
|
||||||
|
Whenever automating an administrative task in Python, you simply need to connect to MongoDB using the properties defined in `settings.py`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Append Web folder so we can access configuration
|
||||||
|
sys.path.insert(0, '/app/Web')
|
||||||
|
import settings
|
||||||
|
from pymongo import MongoClient
|
||||||
|
|
||||||
|
# Establish connection pooling
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
```
|
||||||
|
|
||||||
|
### A. Adding a New Tenant (Database Initialization)
|
||||||
|
A new tenant database isn’t provisioned until the first actual data insert happens. We trigger this manually by creating an `admin` user for them.
|
||||||
|
|
||||||
|
**Operation:**
|
||||||
|
```python
|
||||||
|
def create_tenant(tenant_id, admin_password="hashed_password_here"):
|
||||||
|
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
|
||||||
|
db = client[db_name]
|
||||||
|
|
||||||
|
# MongoDB creates the DB automatically on first insert
|
||||||
|
result = db.users.insert_one({
|
||||||
|
'username': 'admin',
|
||||||
|
'password': admin_password,
|
||||||
|
'role': 'admin'
|
||||||
|
})
|
||||||
|
return result.inserted_id # Returns the BSON ObjectId of the new user
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. List Active Tenants
|
||||||
|
To find out how many isolated tenants have active databases, you query the raw `MongoClient` for all databases and search for your configured MongoDB prefix (default: `inventar_`).
|
||||||
|
|
||||||
|
**Operation:**
|
||||||
|
```python
|
||||||
|
def list_tenants():
|
||||||
|
prefix = f"{settings.MONGODB_DB}_"
|
||||||
|
|
||||||
|
# Returns a Python list of string database names
|
||||||
|
all_dbs = client.list_database_names()
|
||||||
|
|
||||||
|
# Filter and strip the prefix to return just the tenant_ids
|
||||||
|
active_tenants = [d.replace(prefix, "") for d in all_dbs if d.startswith(prefix)]
|
||||||
|
|
||||||
|
return active_tenants # e.g., ['schule1', 'schule2', 'test']
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. Soft-Restarting a Tenant (Invalidating Sessions)
|
||||||
|
"Restarting" a single tenant means signing out all of their users and forcing an application refresh. Because Session data is coupled to the tenant database, dropping their `sessions` collection achieves an instant sign-out.
|
||||||
|
|
||||||
|
**Operation:**
|
||||||
|
```python
|
||||||
|
def restart_tenant(tenant_id):
|
||||||
|
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
|
||||||
|
db = client[db_name]
|
||||||
|
|
||||||
|
# Drops the collection. All active user cookies immediately become invalid.
|
||||||
|
result = db.sessions.drop()
|
||||||
|
|
||||||
|
return result # Returns None. Raises PyMongoError if connection fails.
|
||||||
|
```
|
||||||
|
|
||||||
|
### D. Removing a Tenant Completely (Wipe Data)
|
||||||
|
If a tenant is removed from the service or their lease expires, you can permanently obliterate their data container footprint.
|
||||||
|
|
||||||
|
**Operation:**
|
||||||
|
```python
|
||||||
|
def remove_tenant(tenant_id):
|
||||||
|
db_name = f"{settings.MONGODB_DB}_{tenant_id}"
|
||||||
|
|
||||||
|
# Erases the isolated database. Can't be undone.
|
||||||
|
client.drop_database(db_name)
|
||||||
|
|
||||||
|
return True # Returns True. Raises PyMongoError if connection fails.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Resolving Context Inside Flask (app.py)
|
||||||
|
|
||||||
|
If you are building custom application endpoints inside `Web/app.py`, you shouldn't use the direct MongoDB `client` manually. Instead, you rely on the built-in Flask context manager (`Web/tenant.py`) to give you the correct isolated scope.
|
||||||
|
|
||||||
|
### The `get_tenant_db()` function
|
||||||
|
Every route must use `get_tenant_db(client)` to ensure users can only ever access their own school/domain's database.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pymongo import MongoClient
|
||||||
|
import settings
|
||||||
|
from tenant import get_tenant_db
|
||||||
|
|
||||||
|
# Example Route
|
||||||
|
@app.route('/api/items')
|
||||||
|
def get_items():
|
||||||
|
# 1. Establish/reuse pooling connection
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, settings.MONGODB_PORT)
|
||||||
|
|
||||||
|
# 2. Get the dynamically routed DB for THIS user
|
||||||
|
# (Based on Nginx Subdomain or X-Tenant-Id header)
|
||||||
|
db = get_tenant_db(client)
|
||||||
|
|
||||||
|
# 3. Runs query solely on `inventar_schule1.items`
|
||||||
|
items = list(db.items.find())
|
||||||
|
|
||||||
|
return items # List of BSON Dictionaries
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it returns internally:**
|
||||||
|
The `get_tenant_db` function queries `g.tenant_context` inside Flask, calculates the database name from the subdomain, and returns a live `pymongo.database.Database` object.
|
||||||
|
|
||||||
|
This ensures that scaling is extremely cheap on resources because 1 Application Container connects to 100 separate Tenant Databases using just 1 shared `MongoClient` pool.
|
||||||
|
|
||||||
+69
-4
@@ -339,6 +339,28 @@ def _enforce_user_permissions():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _enforce_active_session_user():
|
||||||
|
endpoint = request.endpoint or ''
|
||||||
|
if endpoint == 'static' or endpoint.startswith('static'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
username = session.get('username')
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = us.get_user(username)
|
||||||
|
if user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session.clear()
|
||||||
|
if request.path.startswith('/api/') or request.is_json:
|
||||||
|
return jsonify({'ok': False, 'message': 'Sitzung ungültig. Bitte erneut anmelden.'}), 401
|
||||||
|
|
||||||
|
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
def _get_asset_version():
|
def _get_asset_version():
|
||||||
"""Return a cache-busting asset version tied to deployment state."""
|
"""Return a cache-busting asset version tied to deployment state."""
|
||||||
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
|
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
|
||||||
@@ -1291,9 +1313,17 @@ def _initialize_scheduler():
|
|||||||
if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
|
if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
|
||||||
os.remove(scheduler_lock_path)
|
os.remove(scheduler_lock_path)
|
||||||
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
|
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # If we can't clean up, continue anyway
|
app.logger.warning(f"Could not clean up scheduler lock file: {e}")
|
||||||
|
|
||||||
|
# Always try to remove lock file on startup (extra safety)
|
||||||
|
try:
|
||||||
|
if os.path.exists(scheduler_lock_path):
|
||||||
|
os.remove(scheduler_lock_path)
|
||||||
|
app.logger.info("Scheduler lock file removed on startup.")
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.warning(f"Could not remove scheduler lock file on startup: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try to create the lock file - only succeeds if it doesn't exist
|
# Try to create the lock file - only succeeds if it doesn't exist
|
||||||
lock_fd = os.open(scheduler_lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
lock_fd = os.open(scheduler_lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||||
@@ -1302,7 +1332,7 @@ def _initialize_scheduler():
|
|||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
should_start = False
|
should_start = False
|
||||||
app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
|
app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
|
||||||
|
|
||||||
if should_start:
|
if should_start:
|
||||||
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
|
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
|
||||||
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
|
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
|
||||||
@@ -2063,6 +2093,11 @@ def _upload_excel_items(scope='inventory'):
|
|||||||
validation_warnings = []
|
validation_warnings = []
|
||||||
reserved_codes = set()
|
reserved_codes = set()
|
||||||
known_locations = set(it.get_predefined_locations())
|
known_locations = set(it.get_predefined_locations())
|
||||||
|
known_filters = {
|
||||||
|
1: set(it.get_predefined_filter_values(1)),
|
||||||
|
2: set(it.get_predefined_filter_values(2)),
|
||||||
|
3: set(it.get_predefined_filter_values(3))
|
||||||
|
}
|
||||||
|
|
||||||
def is_code_available(candidate_code):
|
def is_code_available(candidate_code):
|
||||||
return candidate_code not in reserved_codes and it.is_code_unique(candidate_code)
|
return candidate_code not in reserved_codes and it.is_code_unique(candidate_code)
|
||||||
@@ -2114,8 +2149,19 @@ def _upload_excel_items(scope='inventory'):
|
|||||||
validation_warnings.append((row_number, f'Ort "{ort}" wird neu angelegt'))
|
validation_warnings.append((row_number, f'Ort "{ort}" wird neu angelegt'))
|
||||||
|
|
||||||
filter1 = expand_filter_selection(_excel_list(val('filter1')), 1)
|
filter1 = expand_filter_selection(_excel_list(val('filter1')), 1)
|
||||||
|
for f in filter1:
|
||||||
|
if f and f not in known_filters[1]:
|
||||||
|
validation_warnings.append((row_number, f'Kategorie 1 "{f}" wird neu angelegt'))
|
||||||
|
|
||||||
filter2 = expand_filter_selection(_excel_list(val('filter2')), 2)
|
filter2 = expand_filter_selection(_excel_list(val('filter2')), 2)
|
||||||
|
for f in filter2:
|
||||||
|
if f and f not in known_filters[2]:
|
||||||
|
validation_warnings.append((row_number, f'Kategorie 2 "{f}" wird neu angelegt'))
|
||||||
|
|
||||||
filter3 = _excel_list(val('filter3'))
|
filter3 = _excel_list(val('filter3'))
|
||||||
|
for f in filter3:
|
||||||
|
if f and f not in known_filters[3]:
|
||||||
|
validation_warnings.append((row_number, f'Kategorie 3 "{f}" wird neu angelegt'))
|
||||||
|
|
||||||
anschaffungsjahr = _excel_int(val('anschaffungsjahr'))
|
anschaffungsjahr = _excel_int(val('anschaffungsjahr'))
|
||||||
raw_year = val('anschaffungsjahr')
|
raw_year = val('anschaffungsjahr')
|
||||||
@@ -2217,6 +2263,21 @@ def _upload_excel_items(scope='inventory'):
|
|||||||
it.add_predefined_location(row['ort'])
|
it.add_predefined_location(row['ort'])
|
||||||
known_locations.add(row['ort'])
|
known_locations.add(row['ort'])
|
||||||
|
|
||||||
|
for f in row.get('filter1', []):
|
||||||
|
if f and f not in known_filters[1]:
|
||||||
|
it.add_predefined_filter_value(1, f)
|
||||||
|
known_filters[1].add(f)
|
||||||
|
|
||||||
|
for f in row.get('filter2', []):
|
||||||
|
if f and f not in known_filters[2]:
|
||||||
|
it.add_predefined_filter_value(2, f)
|
||||||
|
known_filters[2].add(f)
|
||||||
|
|
||||||
|
for f in row.get('filter3', []):
|
||||||
|
if f and f not in known_filters[3]:
|
||||||
|
it.add_predefined_filter_value(3, f)
|
||||||
|
known_filters[3].add(f)
|
||||||
|
|
||||||
series_group_id = str(uuid.uuid4()) if row['count'] > 1 else None
|
series_group_id = str(uuid.uuid4()) if row['count'] > 1 else None
|
||||||
row_created_ids = []
|
row_created_ids = []
|
||||||
|
|
||||||
@@ -10753,6 +10814,10 @@ def get_optimal_image_quality(img, target_size_kb=80):
|
|||||||
# PUSH NOTIFICATION API ENDPOINTS
|
# PUSH NOTIFICATION API ENDPOINTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health_check():
|
||||||
|
return 'OK', 200
|
||||||
|
|
||||||
@app.route('/api/push/subscribe', methods=['POST'])
|
@app.route('/api/push/subscribe', methods=['POST'])
|
||||||
def subscribe_to_push():
|
def subscribe_to_push():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
[2026-04-19 20:20:39,502] ERROR in app: Error during scheduler shutdown: name '__file__' is not defined
|
|
||||||
@@ -13,5 +13,6 @@ redis
|
|||||||
reportlab
|
reportlab
|
||||||
python-barcode
|
python-barcode
|
||||||
openpyxl
|
openpyxl
|
||||||
cryptography
|
cryptography>=42.0.0
|
||||||
pywebpush
|
pywebpush
|
||||||
|
py-vapid>=1.9.0
|
||||||
|
|||||||
+30
-2
@@ -157,8 +157,36 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
|
|||||||
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
|
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
|
||||||
|
|
||||||
# Optional feature modules
|
# Optional feature modules
|
||||||
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
|
||||||
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
|
|
||||||
|
|
||||||
|
class _TenantAwareBool:
|
||||||
|
def __init__(self, module_name, default):
|
||||||
|
self.module_name = module_name
|
||||||
|
self.default = bool(default)
|
||||||
|
|
||||||
|
def resolve(self):
|
||||||
|
try:
|
||||||
|
from tenant import is_tenant_module_enabled
|
||||||
|
return bool(is_tenant_module_enabled(self.module_name, default=self.default))
|
||||||
|
except Exception:
|
||||||
|
return self.default
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
return self.resolve()
|
||||||
|
|
||||||
|
def __int__(self):
|
||||||
|
return int(self.resolve())
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'True' if self.resolve() else 'False'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
|
||||||
|
|
||||||
|
|
||||||
|
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||||
|
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
|
||||||
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
|
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
|
||||||
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
|
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
|
||||||
|
|
||||||
|
|||||||
+78
-19
@@ -21,28 +21,47 @@
|
|||||||
--ui-radius: 12px;
|
--ui-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root[data-theme="dark"] {
|
||||||
:root {
|
--ui-bg: #0f172a;
|
||||||
--ui-bg: #0f172a;
|
--ui-bg-accent: #1e293b;
|
||||||
--ui-bg-accent: #1e293b;
|
--ui-surface: #1e293b;
|
||||||
--ui-surface: #1e293b;
|
--ui-surface-soft: #25314a;
|
||||||
--ui-surface-soft: #25314a;
|
--ui-border: #334155;
|
||||||
--ui-border: #334155;
|
--ui-text: #f1f5f9;
|
||||||
--ui-text: #f1f5f9;
|
--ui-text-muted: #94a3b8;
|
||||||
--ui-text-muted: #94a3b8;
|
--ui-title: #ffffff;
|
||||||
--ui-title: #ffffff;
|
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
|
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
|
||||||
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
|
}
|
||||||
}
|
|
||||||
|
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
|
||||||
/* Make inputs visible in dark mode */
|
html {
|
||||||
input, select, textarea {
|
/* Ensure viewport-fit is respected */
|
||||||
background-color: var(--ui-surface-soft) !important;
|
viewport-fit: cover;
|
||||||
color: var(--ui-text) !important;
|
}
|
||||||
border-color: var(--ui-border) !important;
|
|
||||||
|
body {
|
||||||
|
/* Respect safe areas on mobile devices */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Future-proof: Support for various viewport and safe-area features */
|
||||||
|
@supports (padding: max(0px)) {
|
||||||
|
body {
|
||||||
|
/* Ensure body padding never conflicts with safe areas */
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Make inputs visible in dark mode */
|
||||||
|
:root[data-theme="dark"] input,
|
||||||
|
:root[data-theme="dark"] select,
|
||||||
|
:root[data-theme="dark"] textarea {
|
||||||
|
background-color: var(--ui-surface-soft) !important;
|
||||||
|
color: var(--ui-text) !important;
|
||||||
|
border-color: var(--ui-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
|
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
@@ -172,11 +191,16 @@ select:focus {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
/* Safe area insets for iPad notches and Dynamic Island */
|
||||||
|
padding-left: env(safe-area-inset-left, 0);
|
||||||
|
padding-right: env(safe-area-inset-right, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
|
/* Prevent navbar brand from extending into unsafe areas */
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -190,6 +214,41 @@ select:focus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile and tablet safe area handling for navbar */
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.navbar {
|
||||||
|
/* Ensure navbar spans full width but respects safe areas */
|
||||||
|
width: 100vw;
|
||||||
|
margin-left: calc(-50vw + 50%);
|
||||||
|
/* Safe area padding is already handled by navbar class */
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .container-fluid {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Ensure padding accounts for safe areas on small screens */
|
||||||
|
padding-left: max(env(safe-area-inset-left, 0), 12px);
|
||||||
|
padding-right: max(env(safe-area-inset-right, 0), 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-collapse {
|
||||||
|
/* Ensure collapsed navbar respects safe areas */
|
||||||
|
padding-left: env(safe-area-inset-left, 0);
|
||||||
|
padding-right: env(safe-area-inset-right, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iPad-specific landscape and portrait handling */
|
||||||
|
@media (max-height: 600px) and (orientation: landscape) {
|
||||||
|
.navbar {
|
||||||
|
padding-top: max(env(safe-area-inset-top, 0), 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .container-fluid {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Edit and generate buttons with theme-aware text for better visibility */
|
/* Edit and generate buttons with theme-aware text for better visibility */
|
||||||
.edit-button, .duplicate-button, .generate-qr-button {
|
.edit-button, .duplicate-button, .generate-qr-button {
|
||||||
color: var(--ui-text) !important; /* Override any existing color */
|
color: var(--ui-text) !important; /* Override any existing color */
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
|
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
|
||||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
|
||||||
<div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
|
<div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
|
||||||
{% if verify_result.ok %}
|
{% if verify_result.ok %}
|
||||||
<div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div>
|
<div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div>
|
||||||
@@ -19,15 +19,15 @@
|
|||||||
<div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div>
|
<div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
|
||||||
<div style="font-size:0.85rem; color:#64748b;">Einträge</div>
|
<div style="font-size:0.85rem; color:#64748b;">Einträge</div>
|
||||||
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div>
|
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
|
||||||
<div style="font-size:0.85rem; color:#64748b;">Letzter Index</div>
|
<div style="font-size:0.85rem; color:#64748b;">Letzter Index</div>
|
||||||
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div>
|
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
|
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
|
||||||
<div style="font-size:0.85rem; color:#64748b;">Mismatch Count</div>
|
<div style="font-size:0.85rem; color:#64748b;">Mismatch Count</div>
|
||||||
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.mismatches|length }}</div>
|
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.mismatches|length }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div style="border:1px solid #e2e8f0; border-radius:10px; background:#fff; padding:12px;">
|
<div style="border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface); padding:12px;">
|
||||||
<h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3>
|
<h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3>
|
||||||
<div style="max-height:560px; overflow:auto;">
|
<div style="max-height:560px; overflow:auto;">
|
||||||
<table class="table" style="width:100%; border-collapse:collapse;">
|
<table class="table" style="width:100%; border-collapse:collapse;">
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
|
<div id="invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
|
||||||
<div style="max-width:760px; margin:40px auto; background:#fff; border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
<div style="max-width:760px; margin:40px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin:0;">Rechnung erstellen</h2>
|
<h2 style="margin:0;">Rechnung erstellen</h2>
|
||||||
@@ -174,15 +174,15 @@
|
|||||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
|
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<label for="invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
|
<label for="invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
|
||||||
<input id="invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
<input id="invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
|
<label for="invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
|
||||||
<input id="invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
<input id="invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
|
<label for="invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
|
||||||
<input id="invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
<input id="invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
|
<label for="invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
|
||||||
|
|||||||
@@ -17,14 +17,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
|
<div style="background: var(--ui-surface); border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
|
||||||
<input id="damage-search" type="text" placeholder="Suche nach Name, Code, Typ, Benutzer oder Schaden..." style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px;">
|
<input id="damage-search" type="text" placeholder="Suche nach Name, Code, Typ, Benutzer oder Schaden..." style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:0; overflow:hidden;">
|
<div style="background: var(--ui-surface); border:1px solid #e2e8f0; border-radius:14px; padding:0; overflow:hidden;">
|
||||||
<table id="damage-table" style="width:100%; border-collapse:collapse;">
|
<table id="damage-table" style="width:100%; border-collapse:collapse;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background:#f8fafc;">
|
<tr style="background: var(--ui-surface-soft);">
|
||||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Item</th>
|
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Item</th>
|
||||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Status</th>
|
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Status</th>
|
||||||
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th>
|
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th>
|
||||||
|
|||||||
+267
-92
@@ -14,8 +14,20 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="theme-color" content="#2c3e50" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#2c3e50" id="meta-theme-color">
|
||||||
<meta name="theme-color" content="#1a252f" media="(prefers-color-scheme: dark)">
|
<script>
|
||||||
|
// Init theme early to prevent FOUC (Flash of Unstyled Content)
|
||||||
|
(function() {
|
||||||
|
var theme = localStorage.getItem('inventarsystem-theme');
|
||||||
|
if (theme === 'dark' || (!theme && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
document.getElementById('meta-theme-color').setAttribute('content', '#1a252f');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
document.getElementById('meta-theme-color').setAttribute('content', '#2c3e50');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
<title>{% block title %}Inventarsystem{% endblock %}</title>
|
<title>{% block title %}Inventarsystem{% endblock %}</title>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
@@ -117,21 +129,20 @@
|
|||||||
--module-brand-icon: "📚";
|
--module-brand-icon: "📚";
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root[data-theme="dark"] {
|
||||||
:root {
|
--module-primary-color: #1a252f;
|
||||||
--module-primary-color: #1a252f;
|
--module-primary-light: #2c3e50;
|
||||||
--module-primary-light: #2c3e50;
|
--module-accent-color: #2980b9;
|
||||||
--module-accent-color: #2980b9;
|
--module-brand-icon: "📦";
|
||||||
--module-brand-icon: "📦";
|
--page-bg-gradient: linear-gradient(180deg, #0f172a, #1e293b);
|
||||||
--page-bg-gradient: linear-gradient(180deg, #0f172a, #1e293b);
|
--page-bg-overlay1: rgba(56, 189, 248, 0.05);
|
||||||
--page-bg-overlay1: rgba(56, 189, 248, 0.05);
|
--page-bg-overlay2: rgba(59, 130, 246, 0.06);
|
||||||
--page-bg-overlay2: rgba(59, 130, 246, 0.06);
|
}
|
||||||
}
|
|
||||||
html[data-module="library"] {
|
html[data-theme="dark"][data-module="library"] {
|
||||||
--module-primary-color: #4a354d;
|
--module-primary-color: #4a354d;
|
||||||
--module-primary-light: #614666;
|
--module-primary-light: #614666;
|
||||||
--module-accent-color: #8e44ad;
|
--module-accent-color: #8e44ad;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global styles */
|
/* Global styles */
|
||||||
@@ -158,31 +169,87 @@
|
|||||||
background-color: var(--module-primary-color) !important;
|
background-color: var(--module-primary-color) !important;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
padding-top: env(safe-area-inset-top, 0); /* iOS Safe Area */
|
/* iOS Safe Area insets for notches and rounded corners */
|
||||||
|
padding-top: env(safe-area-inset-top, 0);
|
||||||
|
padding-left: env(safe-area-inset-left, 0);
|
||||||
|
padding-right: env(safe-area-inset-right, 0);
|
||||||
z-index: 1900;
|
z-index: 1900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .container-fluid {
|
.navbar .container-fluid {
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0); /* Fallback */
|
/* Ensure container respects safe areas on all sides */
|
||||||
|
padding-left: max(env(safe-area-inset-left, 0), 12px);
|
||||||
|
padding-right: max(env(safe-area-inset-right, 0), 12px);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.4rem;
|
font-size: 1.3rem;
|
||||||
|
padding-right: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand::before {
|
.navbar-brand::before {
|
||||||
content: attr(data-icon);
|
content: attr(data-icon);
|
||||||
margin-right: 8px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-nav .nav-link {
|
.navbar-nav .nav-link {
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.6rem 0.85rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
transition: padding .18s ease, font-size .18s ease;
|
transition: padding .18s ease, font-size .18s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iPad/Tablet responsive adjustments */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.navbar .container-fluid {
|
||||||
|
gap: 6px;
|
||||||
|
padding-left: max(env(safe-area-inset-left, 0), 10px);
|
||||||
|
padding-right: max(env(safe-area-inset-right, 0), 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand::before {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small phones responsive adjustments */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.navbar .container-fluid {
|
||||||
|
gap: 4px;
|
||||||
|
padding-left: max(env(safe-area-inset-left, 0), 8px);
|
||||||
|
padding-right: max(env(safe-area-inset-right, 0), 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar.nav-compact .navbar-brand {
|
.navbar.nav-compact .navbar-brand {
|
||||||
@@ -239,6 +306,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
width: min(420px, 42vw);
|
width: min(420px, 42vw);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-search-form {
|
.function-search-form {
|
||||||
@@ -246,10 +314,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-search-input {
|
.function-search-input {
|
||||||
width: 100%;
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
@@ -270,6 +341,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.function-search-btn {
|
.function-search-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||||
@@ -472,9 +544,48 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-collapse {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-collapse > .d-flex {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.function-search-wrap {
|
.function-search-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
margin: 8px 0 10px;
|
margin: 8px 0 10px;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-search-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-search-input {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-search-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-text {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-wrap {
|
||||||
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-nav .nav-item {
|
.navbar-nav .nav-item {
|
||||||
@@ -495,7 +606,7 @@
|
|||||||
|
|
||||||
/* Account dropdown stays light theme on mobile */
|
/* Account dropdown stays light theme on mobile */
|
||||||
.dropdown-menu-end {
|
.dropdown-menu-end {
|
||||||
background-color: #ffffff;
|
background-color: var(--ui-surface);
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,7 +615,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu-end .dropdown-item:hover {
|
.dropdown-menu-end .dropdown-item:hover {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
color: #007bff;
|
color: #007bff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -846,12 +957,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'username' in session %}
|
{% if 'username' in session %}
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||||
<li class="nav-item" data-nav-fixed="true">
|
<li class="nav-item">
|
||||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
|
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||||
<li class="nav-item" data-nav-fixed="true">
|
<li class="nav-item">
|
||||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -861,20 +972,18 @@
|
|||||||
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}">➕ Hochladen</a>
|
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}">➕ Hochladen</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li class="nav-item" data-nav-fixed="true">
|
||||||
|
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||||
|
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||||
|
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li class="nav-item dropdown ms-lg-auto">
|
<li class="nav-item dropdown ms-lg-auto">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
|
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
|
||||||
Mehr Optionen
|
Mehr Optionen
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
|
||||||
{% if 'username' in session %}
|
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
|
||||||
{% endif %}
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
||||||
<li><h6 class="dropdown-header">Verwaltung</h6></li>
|
<li><h6 class="dropdown-header">Verwaltung</h6></li>
|
||||||
{% if current_permissions.pages.get('manage_filters', True) %}
|
{% if current_permissions.pages.get('manage_filters', True) %}
|
||||||
@@ -967,12 +1076,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'username' in session %}
|
{% if 'username' in session %}
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||||
<li class="nav-item" data-nav-fixed="true">
|
<li class="nav-item">
|
||||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
|
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||||
<li class="nav-item" data-nav-fixed="true">
|
<li class="nav-item">
|
||||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -982,20 +1091,18 @@
|
|||||||
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
|
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li class="nav-item" data-nav-fixed="true">
|
||||||
|
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||||
|
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||||
|
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li class="nav-item dropdown ms-lg-auto">
|
<li class="nav-item dropdown ms-lg-auto">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
|
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
|
||||||
Mehr Optionen
|
Mehr Optionen
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
|
||||||
{% if 'username' in session %}
|
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
|
||||||
{% endif %}
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
||||||
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||||
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
||||||
@@ -1098,7 +1205,7 @@
|
|||||||
|
|
||||||
#onboarding-modal {
|
#onboarding-modal {
|
||||||
width: min(680px, 100%);
|
width: min(680px, 100%);
|
||||||
background: #ffffff;
|
background: var(--ui-surface);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid #dbe3ee;
|
border: 1px solid #dbe3ee;
|
||||||
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.25);
|
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.25);
|
||||||
@@ -1573,8 +1680,15 @@
|
|||||||
const libraryNavList = document.getElementById('libraryNavList');
|
const libraryNavList = document.getElementById('libraryNavList');
|
||||||
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
|
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
|
||||||
|
|
||||||
function initNavbarOverflow(navList, navOverflowAnchor) {
|
function initNavbarOverflow(navList, navOverflowAnchor, options) {
|
||||||
if (!navList || !navOverflowAnchor) return;
|
if (!navList) return;
|
||||||
|
|
||||||
|
const moreToggleId = options && options.moreToggleId ? options.moreToggleId : '';
|
||||||
|
const moreToggle = moreToggleId ? document.getElementById(moreToggleId) : null;
|
||||||
|
const moreControlItem = moreToggle ? moreToggle.closest('li.nav-item.dropdown') : null;
|
||||||
|
const moreMenu = moreControlItem ? moreControlItem.querySelector(':scope > ul.dropdown-menu') : null;
|
||||||
|
const moreLabelDefault = moreToggle ? moreToggle.textContent.trim() : 'Mehr Optionen';
|
||||||
|
const moreMenuOriginal = moreMenu ? moreMenu.innerHTML : '';
|
||||||
|
|
||||||
const navRoot = navList.closest('nav.navbar');
|
const navRoot = navList.closest('nav.navbar');
|
||||||
const navCollapse = navList.closest('.navbar-collapse');
|
const navCollapse = navList.closest('.navbar-collapse');
|
||||||
@@ -1602,45 +1716,35 @@
|
|||||||
return Array.from(navList.children).filter(function(li){
|
return Array.from(navList.children).filter(function(li){
|
||||||
if (!(li instanceof HTMLElement)) return false;
|
if (!(li instanceof HTMLElement)) return false;
|
||||||
if (!li.classList.contains('nav-item')) return false;
|
if (!li.classList.contains('nav-item')) return false;
|
||||||
if (li.id === navOverflowAnchor.id) return false;
|
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return false;
|
||||||
if (li.dataset.overflowControl === 'true') return false;
|
if (li.dataset.overflowControl === 'true') return false;
|
||||||
if (li.dataset.navFixed === 'true') return false;
|
if (li.dataset.navFixed === 'true') return false;
|
||||||
|
if (moreControlItem && li === moreControlItem) return false;
|
||||||
return li.style.display !== 'none';
|
return li.style.display !== 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearOverflowControls() {
|
function clearOverflowControls() {
|
||||||
if (!navList) return;
|
if (!moreMenu) return;
|
||||||
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){
|
moreMenu.innerHTML = moreMenuOriginal;
|
||||||
node.remove();
|
if (moreControlItem) {
|
||||||
});
|
moreControlItem.style.display = '';
|
||||||
|
}
|
||||||
|
if (moreToggle) {
|
||||||
|
moreToggle.textContent = moreLabelDefault;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreAllNavItems() {
|
function restoreAllNavItems() {
|
||||||
if (!navList) return;
|
if (!navList) return;
|
||||||
navList.querySelectorAll('li.nav-item').forEach(function(li){
|
navList.querySelectorAll('li.nav-item').forEach(function(li){
|
||||||
if (li.id === navOverflowAnchor.id) return;
|
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return;
|
||||||
if (li.dataset.overflowControl === 'true') return;
|
if (li.dataset.overflowControl === 'true') return;
|
||||||
li.style.display = '';
|
li.style.display = '';
|
||||||
});
|
});
|
||||||
clearOverflowControls();
|
clearOverflowControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOverflowControl() {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'nav-item dropdown';
|
|
||||||
li.dataset.overflowControl = 'true';
|
|
||||||
|
|
||||||
const toggleId = navOverflowAnchor.id + '-toggle';
|
|
||||||
const menuId = navOverflowAnchor.id + '-menu';
|
|
||||||
|
|
||||||
li.innerHTML =
|
|
||||||
'<a class="nav-link dropdown-toggle" href="#" id="' + toggleId + '" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-controls="' + menuId + '">⋮ Weitere</a>' +
|
|
||||||
'<ul class="dropdown-menu" aria-labelledby="' + toggleId + '" id="' + menuId + '"></ul>';
|
|
||||||
|
|
||||||
return li;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNavCandidatePriority(item) {
|
function getNavCandidatePriority(item) {
|
||||||
if (!(item instanceof HTMLElement)) {
|
if (!(item instanceof HTMLElement)) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -1723,33 +1827,70 @@
|
|||||||
|
|
||||||
function rebuildOverflowControl(hiddenSources) {
|
function rebuildOverflowControl(hiddenSources) {
|
||||||
clearOverflowControls();
|
clearOverflowControls();
|
||||||
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return;
|
if (!moreMenu || hiddenSources.length === 0) return;
|
||||||
|
|
||||||
const control = createOverflowControl();
|
|
||||||
const menu = control.querySelector('ul.dropdown-menu');
|
|
||||||
const controlLink = control.querySelector(':scope > a.nav-link');
|
|
||||||
if (controlLink) {
|
|
||||||
controlLink.textContent = '⋮ Weitere (' + hiddenSources.length + ')';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const overflowFragment = document.createDocumentFragment();
|
||||||
hiddenSources.forEach(function(source){
|
hiddenSources.forEach(function(source){
|
||||||
appendSourceToOverflowMenu(source, menu);
|
appendSourceToOverflowMenu(source, overflowFragment);
|
||||||
});
|
});
|
||||||
|
|
||||||
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider');
|
const overflowWrap = document.createElement('div');
|
||||||
if (trailingDivider) {
|
overflowWrap.appendChild(overflowFragment);
|
||||||
|
const trailingDivider = overflowWrap.querySelector('li:last-child .dropdown-divider');
|
||||||
|
if (trailingDivider && trailingDivider.parentElement) {
|
||||||
trailingDivider.parentElement.remove();
|
trailingDivider.parentElement.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
navList.insertBefore(control, navOverflowAnchor);
|
const overflowEntries = Array.from(overflowWrap.childNodes);
|
||||||
|
if (overflowEntries.length > 0) {
|
||||||
|
const marker = document.createElement('li');
|
||||||
|
marker.innerHTML = '<h6 class="dropdown-header">Ausgeblendete Navigation</h6>';
|
||||||
|
const firstExistingNode = moreMenu.firstChild;
|
||||||
|
moreMenu.insertBefore(marker, firstExistingNode);
|
||||||
|
|
||||||
|
let insertAfter = marker;
|
||||||
|
overflowEntries.forEach(function(node){
|
||||||
|
moreMenu.insertBefore(node, insertAfter.nextSibling);
|
||||||
|
insertAfter = node;
|
||||||
|
});
|
||||||
|
|
||||||
|
const divider = document.createElement('li');
|
||||||
|
divider.innerHTML = '<hr class="dropdown-divider">';
|
||||||
|
moreMenu.insertBefore(divider, insertAfter.nextSibling);
|
||||||
|
|
||||||
|
if (moreToggle) {
|
||||||
|
moreToggle.textContent = moreLabelDefault + ' (' + hiddenSources.length + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moreControlItem) {
|
||||||
|
moreControlItem.style.display = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function adaptNavbarByWidth() {
|
function adaptNavbarByWidth() {
|
||||||
if (!navList || !navOverflowAnchor) return;
|
if (!navList) return;
|
||||||
|
|
||||||
|
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
|
||||||
|
|
||||||
|
function isNavOverflowing(bufferPx) {
|
||||||
|
const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
|
||||||
|
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
|
||||||
|
const containerOverflow = navContainer ? (navContainer.scrollWidth > (navContainer.clientWidth - buffer)) : false;
|
||||||
|
const listOverflow = navList.scrollWidth > (navList.clientWidth - buffer);
|
||||||
|
return collapseOverflow || containerOverflow || listOverflow;
|
||||||
|
}
|
||||||
|
|
||||||
applyCompactMode();
|
applyCompactMode();
|
||||||
|
|
||||||
if (window.innerWidth < 992) {
|
// Desktop/tablet-large: keep complete navigation visible.
|
||||||
|
if (!isMobileViewport) {
|
||||||
|
restoreAllNavItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile menu is collapsed: avoid hiding links preemptively.
|
||||||
|
if (navCollapse && !navCollapse.classList.contains('show')) {
|
||||||
restoreAllNavItems();
|
restoreAllNavItems();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1758,8 +1899,9 @@
|
|||||||
|
|
||||||
const hiddenSources = [];
|
const hiddenSources = [];
|
||||||
let candidates = collectTopLevelNavSources();
|
let candidates = collectTopLevelNavSources();
|
||||||
|
const overflowBuffer = 12;
|
||||||
|
|
||||||
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
|
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
|
||||||
const toHide = pickNextNavItemToHide(candidates);
|
const toHide = pickNextNavItemToHide(candidates);
|
||||||
if (!toHide) {
|
if (!toHide) {
|
||||||
break;
|
break;
|
||||||
@@ -1771,8 +1913,9 @@
|
|||||||
|
|
||||||
rebuildOverflowControl(hiddenSources);
|
rebuildOverflowControl(hiddenSources);
|
||||||
|
|
||||||
|
// One final pass in case the overflow menu label/count itself changed row width.
|
||||||
candidates = collectTopLevelNavSources();
|
candidates = collectTopLevelNavSources();
|
||||||
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
|
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
|
||||||
const toHide = pickNextNavItemToHide(candidates);
|
const toHide = pickNextNavItemToHide(candidates);
|
||||||
if (!toHide) {
|
if (!toHide) {
|
||||||
break;
|
break;
|
||||||
@@ -1810,12 +1953,12 @@
|
|||||||
|
|
||||||
// Initialize overflow control for inventory navbar
|
// Initialize overflow control for inventory navbar
|
||||||
if (navList && navOverflowAnchor) {
|
if (navList && navOverflowAnchor) {
|
||||||
initNavbarOverflow(navList, navOverflowAnchor);
|
initNavbarOverflow(navList, navOverflowAnchor, { moreToggleId: 'invMoreDropdown' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize overflow control for library navbar
|
// Initialize overflow control for library navbar
|
||||||
if (libraryNavList && libNavOverflowAnchor) {
|
if (libraryNavList && libNavOverflowAnchor) {
|
||||||
initNavbarOverflow(libraryNavList, libNavOverflowAnchor);
|
initNavbarOverflow(libraryNavList, libNavOverflowAnchor, { moreToggleId: 'libMoreDropdown' });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -1825,5 +1968,37 @@
|
|||||||
|
|
||||||
<!-- Push Notifications Script -->
|
<!-- Push Notifications Script -->
|
||||||
<script src="{{ url_for('static', filename='js/push-notifications.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/push-notifications.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- Theme Toggle Script -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const toggleBtns = document.querySelectorAll('#themeToggleBtn');
|
||||||
|
if (toggleBtns.length === 0) return;
|
||||||
|
|
||||||
|
function updateIcons(theme) {
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
document.querySelectorAll('.theme-icon-light').forEach(icon => icon.style.display = isDark ? 'inline' : 'none');
|
||||||
|
document.querySelectorAll('.theme-icon-dark').forEach(icon => icon.style.display = isDark ? 'none' : 'inline');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current setup from initial script in head
|
||||||
|
let currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||||
|
updateIcons(currentTheme);
|
||||||
|
|
||||||
|
toggleBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
currentTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
document.documentElement.setAttribute('data-theme', currentTheme);
|
||||||
|
localStorage.setItem('inventarsystem-theme', currentTheme);
|
||||||
|
document.getElementById('meta-theme-color').setAttribute('content', currentTheme === 'dark' ? '#1a252f' : '#2c3e50');
|
||||||
|
|
||||||
|
updateIcons(currentTheme);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin: 50px auto;
|
margin: 50px auto;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|||||||
+237
-1
@@ -208,12 +208,23 @@
|
|||||||
<script>
|
<script>
|
||||||
// View mode persistence
|
// View mode persistence
|
||||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||||
|
const MOBILE_LIBRARY_MAX_WIDTH = 900;
|
||||||
|
const MOBILE_LIBRARY_MIN_ITEMS = 24;
|
||||||
|
|
||||||
|
function isMobileViewport() {
|
||||||
|
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
|
||||||
|
}
|
||||||
|
|
||||||
function setViewMode(mode) {
|
function setViewMode(mode) {
|
||||||
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
|
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
|
||||||
const container = document.getElementById('itemsContainer');
|
const container = document.getElementById('itemsContainer');
|
||||||
const tableHeader = document.getElementById('tableHeader');
|
const tableHeader = document.getElementById('tableHeader');
|
||||||
const items = container.querySelectorAll('.item-card');
|
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
|
||||||
|
const virtualizationState = window.mobileLibraryVirtualizationState || null;
|
||||||
|
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
|
||||||
|
? virtualizationState.items.filter(item => !container.contains(item))
|
||||||
|
: [];
|
||||||
|
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const cardContent = item.querySelector('.item-content.card-mode');
|
const cardContent = item.querySelector('.item-content.card-mode');
|
||||||
@@ -236,6 +247,229 @@ function setViewMode(mode) {
|
|||||||
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
|
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initMobileWindowedLibraryLoading() {
|
||||||
|
const container = document.getElementById('itemsContainer');
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.mobileLibraryVirtualizationState) {
|
||||||
|
window.mobileLibraryVirtualizationState = {
|
||||||
|
active: false,
|
||||||
|
items: [],
|
||||||
|
topSpacer: null,
|
||||||
|
bottomSpacer: null,
|
||||||
|
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
|
||||||
|
lastStart: -1,
|
||||||
|
lastEnd: -1,
|
||||||
|
framePending: false,
|
||||||
|
scrollListener: null,
|
||||||
|
resizeListener: null,
|
||||||
|
initialized: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const state = window.mobileLibraryVirtualizationState;
|
||||||
|
|
||||||
|
function restoreAllImages() {
|
||||||
|
state.items.forEach(item => {
|
||||||
|
const image = item.querySelector('img.item-image');
|
||||||
|
if (!image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSrc = image.dataset.mobileSrc || '';
|
||||||
|
if (!image.getAttribute('src') && originalSrc) {
|
||||||
|
image.setAttribute('src', originalSrc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImageMemory(startIndex, endIndex) {
|
||||||
|
const imageBuffer = 4;
|
||||||
|
state.items.forEach((item, index) => {
|
||||||
|
const image = item.querySelector('img.item-image');
|
||||||
|
if (!image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image.dataset.mobileSrc) {
|
||||||
|
image.dataset.mobileSrc = image.getAttribute('src') || '';
|
||||||
|
}
|
||||||
|
image.loading = 'lazy';
|
||||||
|
image.decoding = 'async';
|
||||||
|
|
||||||
|
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
|
||||||
|
if (shouldKeepLoaded) {
|
||||||
|
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
|
||||||
|
image.setAttribute('src', image.dataset.mobileSrc);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.getAttribute('src')) {
|
||||||
|
image.removeAttribute('src');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVisibleWindow() {
|
||||||
|
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportHeight = window.innerHeight || 800;
|
||||||
|
const scrollTop = window.scrollY || window.pageYOffset || 0;
|
||||||
|
const renderBuffer = viewportHeight * 1.5;
|
||||||
|
|
||||||
|
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
|
||||||
|
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
|
||||||
|
|
||||||
|
if (endIndex - startIndex < 14) {
|
||||||
|
endIndex = Math.min(state.items.length, startIndex + 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastStart = startIndex;
|
||||||
|
state.lastEnd = endIndex;
|
||||||
|
|
||||||
|
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
|
||||||
|
state.topSpacer.nextSibling.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let index = startIndex; index < endIndex; index += 1) {
|
||||||
|
fragment.appendChild(state.items[index]);
|
||||||
|
}
|
||||||
|
container.insertBefore(fragment, state.bottomSpacer);
|
||||||
|
|
||||||
|
let measuredHeightSum = 0;
|
||||||
|
let measuredCount = 0;
|
||||||
|
for (let index = startIndex; index < endIndex; index += 1) {
|
||||||
|
const height = state.items[index].getBoundingClientRect().height;
|
||||||
|
if (height > 0) {
|
||||||
|
measuredHeightSum += height;
|
||||||
|
measuredCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (measuredCount > 0) {
|
||||||
|
const measuredAverage = measuredHeightSum / measuredCount;
|
||||||
|
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
|
||||||
|
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
|
||||||
|
updateImageMemory(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWindowRender() {
|
||||||
|
if (!state.active || state.framePending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.framePending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
state.framePending = false;
|
||||||
|
renderVisibleWindow();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateVirtualization() {
|
||||||
|
if (state.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||||
|
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||||
|
}
|
||||||
|
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.topSpacer = document.createElement('div');
|
||||||
|
state.topSpacer.className = 'mobile-window-spacer top';
|
||||||
|
state.bottomSpacer = document.createElement('div');
|
||||||
|
state.bottomSpacer.className = 'mobile-window-spacer bottom';
|
||||||
|
|
||||||
|
state.items.forEach(item => item.remove());
|
||||||
|
container.appendChild(state.topSpacer);
|
||||||
|
container.appendChild(state.bottomSpacer);
|
||||||
|
|
||||||
|
state.lastStart = -1;
|
||||||
|
state.lastEnd = -1;
|
||||||
|
state.framePending = false;
|
||||||
|
state.active = true;
|
||||||
|
|
||||||
|
if (!state.scrollListener) {
|
||||||
|
state.scrollListener = () => scheduleWindowRender();
|
||||||
|
window.addEventListener('scroll', state.scrollListener, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleWindowRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivateVirtualization() {
|
||||||
|
if (!state.active) {
|
||||||
|
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||||
|
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.scrollListener) {
|
||||||
|
window.removeEventListener('scroll', state.scrollListener);
|
||||||
|
state.scrollListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.topSpacer && state.topSpacer.parentNode === container) {
|
||||||
|
state.topSpacer.remove();
|
||||||
|
}
|
||||||
|
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
|
||||||
|
state.bottomSpacer.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
state.items.forEach(item => fragment.appendChild(item));
|
||||||
|
container.appendChild(fragment);
|
||||||
|
|
||||||
|
restoreAllImages();
|
||||||
|
|
||||||
|
state.topSpacer = null;
|
||||||
|
state.bottomSpacer = null;
|
||||||
|
state.lastStart = -1;
|
||||||
|
state.lastEnd = -1;
|
||||||
|
state.framePending = false;
|
||||||
|
state.active = false;
|
||||||
|
|
||||||
|
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
||||||
|
setViewMode(currentMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVirtualizationWithViewport() {
|
||||||
|
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||||
|
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
|
||||||
|
if (shouldVirtualize) {
|
||||||
|
activateVirtualization();
|
||||||
|
scheduleWindowRender();
|
||||||
|
} else {
|
||||||
|
deactivateVirtualization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.initialized) {
|
||||||
|
state.resizeListener = () => syncVirtualizationWithViewport();
|
||||||
|
window.addEventListener('resize', state.resizeListener, { passive: true });
|
||||||
|
state.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncVirtualizationWithViewport();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize view mode
|
// Initialize view mode
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
||||||
@@ -311,6 +545,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (e.target === this) this.style.display = 'none';
|
if (e.target === this) this.style.display = 'none';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
initMobileWindowedLibraryLoading();
|
||||||
});
|
});
|
||||||
|
|
||||||
function displayLibraryItemDetail(item) {
|
function displayLibraryItemDetail(item) {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-card {
|
.summary-card {
|
||||||
background: #fff;
|
background: var(--ui-surface);
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
@@ -85,11 +85,11 @@
|
|||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
border: 1px solid #d6dde6;
|
border: 1px solid #d6dde6;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: var(--ui-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: #fff;
|
background: var(--ui-surface);
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-table tr:hover td {
|
.library-table tr:hover td {
|
||||||
@@ -408,7 +408,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="damage-invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
|
<div id="damage-invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
|
||||||
<div style="max-width:760px; margin:40px auto; background:#fff; border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
<div style="max-width:760px; margin:40px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin:0;">Rechnung erstellen</h2>
|
<h2 style="margin:0;">Rechnung erstellen</h2>
|
||||||
@@ -421,15 +421,15 @@
|
|||||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
|
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<label for="damage-invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
|
<label for="damage-invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
|
||||||
<input id="damage-invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
<input id="damage-invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="damage-invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
|
<label for="damage-invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
|
||||||
<input id="damage-invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
<input id="damage-invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="damage-invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
|
<label for="damage-invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
|
||||||
<input id="damage-invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
|
<input id="damage-invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background: var(--ui-surface-soft);">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="damage-invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
|
<label for="damage-invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.invoice-card {
|
.invoice-card {
|
||||||
background: #fff;
|
background: var(--ui-surface);
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-table tr:hover td {
|
.invoice-table tr:hover td {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
.library-header h1 {
|
.library-header h1 {
|
||||||
margin: 0 0 5px 0;
|
margin: 0 0 5px 0;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-header p {
|
.library-header p {
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
/* Scanner panel */
|
/* Scanner panel */
|
||||||
.library-scan-panel {
|
.library-scan-panel {
|
||||||
background: #ffffff;
|
background: var(--ui-surface);
|
||||||
border: 1px solid #d9dde4;
|
border: 1px solid #d9dde4;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
border: 1px solid #d0d7e2;
|
border: 1px solid #d0d7e2;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background: #fff;
|
background: var(--ui-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-scan-status {
|
.library-scan-status {
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
border: 1px solid #d9dde4;
|
border: 1px solid #d9dde4;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: var(--ui-surface);
|
||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
.filter-item label {
|
.filter-item label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.library-items-table thead {
|
.library-items-table thead {
|
||||||
background: #f8f9fa;
|
background: var(--ui-surface-soft);
|
||||||
border-bottom: 2px solid #ddd;
|
border-bottom: 2px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.library-items-table tbody tr:hover {
|
.library-items-table tbody tr:hover {
|
||||||
background: #f8f9fa;
|
background: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-items-table tbody tr:last-child td {
|
.library-items-table tbody tr:last-child td {
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
/* Table cells */
|
/* Table cells */
|
||||||
.table-title {
|
.table-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-status {
|
.table-status {
|
||||||
@@ -320,7 +320,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background-color: #fff;
|
background-color: var(--ui-surface);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
@@ -373,7 +373,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close:hover {
|
.close:hover {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsive */
|
/* Mobile responsive */
|
||||||
@@ -494,7 +494,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="filter-buttons">
|
<div class="filter-buttons">
|
||||||
<button id="applyFilterBtn" class="button" style="background: #4f46e5; color: white;">Filter anwenden</button>
|
<button id="applyFilterBtn" class="button" style="background: #4f46e5; color: white;">Filter anwenden</button>
|
||||||
<button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: #333;">Zurücksetzen</button>
|
<button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: var(--ui-text);">Zurücksetzen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -337,7 +337,7 @@
|
|||||||
color: #0d6efd;
|
color: #0d6efd;
|
||||||
}
|
}
|
||||||
.license-content {
|
.license-content {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
padding: 20px 30px;
|
padding: 20px 30px;
|
||||||
border-radius: 0 0 5px 5px;
|
border-radius: 0 0 5px 5px;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
|
|||||||
@@ -217,7 +217,7 @@
|
|||||||
h1 {
|
h1 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-controls {
|
.filter-controls {
|
||||||
|
|||||||
+92
-22
@@ -20,7 +20,7 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-header button {
|
.calendar-header button {
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-header button:hover {
|
.calendar-header button:hover {
|
||||||
background: #f1f5f9;
|
background: var(--ui-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-header span {
|
.calendar-header span {
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cal-cell:not(.header):not(.empty):hover {
|
.cal-cell:not(.header):not(.empty):hover {
|
||||||
background: #f1f5f9;
|
background: var(--ui-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cal-cell.empty {
|
.cal-cell.empty {
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
.calendar-day-details {
|
.calendar-day-details {
|
||||||
border-top: 1px solid #e7edf5;
|
border-top: 1px solid #e7edf5;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
border: 1px solid #dc3545 !important;
|
border: 1px solid #dc3545 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="container">
|
<div class="container admin-content-container">
|
||||||
<!-- Conflict warning banner (populated by JS) -->
|
<!-- Conflict warning banner (populated by JS) -->
|
||||||
<div id="conflict-banner" style="display:none; background:#fff3cd; border:1px solid #ffc107; border-left:4px solid #fd7e14; color:#856404; padding:12px 16px; border-radius:4px; margin-bottom:16px; position:relative;">
|
<div id="conflict-banner" style="display:none; background:#fff3cd; border:1px solid #ffc107; border-left:4px solid #fd7e14; color:#856404; padding:12px 16px; border-radius:4px; margin-bottom:16px; position:relative;">
|
||||||
<strong>⚠ Reservierungskonflikt</strong>
|
<strong>⚠ Reservierungskonflikt</strong>
|
||||||
@@ -904,7 +904,7 @@
|
|||||||
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
|
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
|
||||||
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.length - 1} mehr)` : '';
|
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.length - 1} mehr)` : '';
|
||||||
|
|
||||||
const imagesHtml = item.Images ? item.Images.map((file, index) => {
|
const imagesHtml = item.Images && item.Images.length > 0 ? item.Images.slice(0, 1).map((file, index) => {
|
||||||
// Get file with correct case handling
|
// Get file with correct case handling
|
||||||
const image = file;
|
const image = file;
|
||||||
|
|
||||||
@@ -1005,14 +1005,14 @@
|
|||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-content" data-item-id="${item._id}">
|
<div class="card-content" data-item-id="${item._id}">
|
||||||
<h3 class="item-name-cell">${item.Name}</h3>
|
<h3 class="item-col-name">${item.Name}</h3>
|
||||||
<p class="item-ort-cell"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||||
<p class="item-filter1-cell"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||||
<p class="item-filter2-cell"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||||
<p class="item-filter3-cell"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
||||||
<p class="item-code-cell"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||||
<p class="item-count-cell"><strong>Anzahl:</strong> ${groupedCount}</p>
|
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||||
${isGroupedItem ? `<p class="item-count-cell"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
||||||
<div class="image-container">
|
<div class="image-container">
|
||||||
${imagesHtml}
|
${imagesHtml}
|
||||||
</div>
|
</div>
|
||||||
@@ -1631,7 +1631,7 @@
|
|||||||
|
|
||||||
<div class="detail-group">
|
<div class="detail-group">
|
||||||
<div class="detail-label">Anschaffungskosten:</div>
|
<div class="detail-label">Anschaffungskosten:</div>
|
||||||
<div class="detail-value">${item.Anschaffungskosten || '-'}</div>
|
<div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-group full-width">
|
<div class="detail-group full-width">
|
||||||
@@ -1646,7 +1646,7 @@
|
|||||||
<span>🛠️</span>
|
<span>🛠️</span>
|
||||||
<span id="toggle-damage-history-text">Historie anzeigen</span>
|
<span id="toggle-damage-history-text">Historie anzeigen</span>
|
||||||
</button>
|
</button>
|
||||||
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background:#f8fafc;">
|
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background: var(--ui-surface-soft);">
|
||||||
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
|
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2847,7 +2847,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-toggle, .clear-filter {
|
.filter-toggle, .clear-filter {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
@@ -3028,7 +3028,7 @@
|
|||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
border: 1px solid #dbe3ee;
|
border: 1px solid #dbe3ee;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3859,7 +3859,7 @@
|
|||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Borrower and appointment info panels in modal */
|
/* Borrower and appointment info panels in modal */
|
||||||
@@ -3891,7 +3891,7 @@
|
|||||||
.detail-value { color: #212529; flex: 1; word-wrap: break-word; }
|
.detail-value { color: #212529; flex: 1; word-wrap: break-word; }
|
||||||
.detail-group.full-width .detail-label { margin-bottom: 5px; }
|
.detail-group.full-width .detail-label { margin-bottom: 5px; }
|
||||||
.detail-group.full-width .detail-value {
|
.detail-group.full-width .detail-value {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
@@ -4174,7 +4174,7 @@
|
|||||||
overflow-wrap: break-word !important;
|
overflow-wrap: break-word !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.table-view .item-card .card-content .item-name-cell {
|
body.table-view .item-card .card-content .item-col-name {
|
||||||
font-size: 0.95rem !important;
|
font-size: 0.95rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4598,4 +4598,74 @@ function openItemQuick(id){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
<style>
|
||||||
|
/* Admin/Main Content Container align */
|
||||||
|
.admin-content-container {
|
||||||
|
--table-columns: minmax(190px, 2fr) repeat(6, minmax(110px, 1fr));
|
||||||
|
}
|
||||||
|
@media screen and (min-width: 769px) and (max-width: 1024px) {
|
||||||
|
.admin-content-container {
|
||||||
|
width: 90% !important;
|
||||||
|
}
|
||||||
|
.items-container {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(2, 1fr) !important;
|
||||||
|
gap: 20px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.admin-content-container {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 12px 12px 18px !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
}
|
||||||
|
.item-card .item-image {
|
||||||
|
width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: 300px !important;
|
||||||
|
display: block !important;
|
||||||
|
object-fit: cover !important;
|
||||||
|
}
|
||||||
|
.item-card .image-container {
|
||||||
|
height: 200px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
position: relative !important;
|
||||||
|
margin-bottom: 15px !important;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
gap: 8px !important;
|
||||||
|
border-top: none !important;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: stretch !important;
|
||||||
|
}
|
||||||
|
.actions form,
|
||||||
|
.actions button,
|
||||||
|
.actions a {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.items-container, .item-card, .image-container, .item-image {
|
||||||
|
touch-action: pan-y !important;
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
.admin-content-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 12px 18px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
color: var(--ui-title);
|
color: var(--ui-title);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-toggle, .clear-filter {
|
.filter-toggle, .clear-filter {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
@@ -333,7 +333,7 @@
|
|||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
border: 1px solid #dbe3ee;
|
border: 1px solid #dbe3ee;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -706,7 +706,7 @@
|
|||||||
padding: 15px;
|
padding: 15px;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-inputs h3 {
|
.filter-inputs h3 {
|
||||||
@@ -1049,7 +1049,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-group.full-width .detail-value {
|
.detail-group.full-width .detail-value {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
@@ -1069,7 +1069,7 @@
|
|||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-image {
|
.modal-image {
|
||||||
@@ -2022,7 +2022,7 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-header button {
|
.calendar-header button {
|
||||||
@@ -2038,7 +2038,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-header button:hover {
|
.calendar-header button:hover {
|
||||||
background: #f1f5f9;
|
background: var(--ui-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-header span {
|
.calendar-header span {
|
||||||
@@ -2082,7 +2082,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cal-cell:not(.header):not(.empty):hover {
|
.cal-cell:not(.header):not(.empty):hover {
|
||||||
background: #f1f5f9;
|
background: var(--ui-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cal-cell.empty {
|
.cal-cell.empty {
|
||||||
@@ -2117,7 +2117,7 @@
|
|||||||
.calendar-day-details {
|
.calendar-day-details {
|
||||||
border-top: 1px solid #e7edf5;
|
border-top: 1px solid #e7edf5;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2584,7 +2584,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
<input type="date" id="edit-year" name="anschaffungsjahr">
|
<input type="date" id="edit-year" name="anschaffungsjahr">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-cost">Anschaffungskosten:</label>
|
<label for="edit-cost">Anschaffungskosten (€):</label>
|
||||||
<input id="edit-cost" name="anschaffungskosten">
|
<input id="edit-cost" name="anschaffungskosten">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -3530,7 +3530,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
|
const filter2More = filter2Array.length > 1 ? ` (+${filter2Array.length - 1} mehr)` : '';
|
||||||
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.length - 1} mehr)` : '';
|
const filter3More = filter3Array.length > 1 ? ` (+${filter3Array.length - 1} mehr)` : '';
|
||||||
|
|
||||||
const imagesHtml = item.Images ? item.Images.map((image, index) => {
|
const imagesHtml = item.Images && item.Images.length > 0 ? item.Images.slice(0, 1).map((image, index) => {
|
||||||
// Check if the image already has a full URL path or just the filename
|
// Check if the image already has a full URL path or just the filename
|
||||||
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http') ?
|
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http') ?
|
||||||
image :
|
image :
|
||||||
@@ -4441,7 +4441,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
|
|
||||||
<div class="detail-group">
|
<div class="detail-group">
|
||||||
<div class="detail-label">Anschaffungskosten:</div>
|
<div class="detail-label">Anschaffungskosten:</div>
|
||||||
<div class="detail-value">${item.Anschaffungskosten || '-'}</div>
|
<div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-group full-width">
|
<div class="detail-group full-width">
|
||||||
@@ -4456,7 +4456,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
<span>🛠️</span>
|
<span>🛠️</span>
|
||||||
<span id="toggle-damage-history-text">Historie anzeigen</span>
|
<span id="toggle-damage-history-text">Historie anzeigen</span>
|
||||||
</button>
|
</button>
|
||||||
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background:#f8fafc;">
|
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background: var(--ui-surface-soft);">
|
||||||
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
|
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,12 +78,12 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
background-color: #fff;
|
background-color: var(--ui-surface);
|
||||||
border: 1px solid #e9ecef;
|
border: 1px solid #e9ecef;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-bottom: 1px solid #e9ecef;
|
border-bottom: 1px solid #e9ecef;
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border-left: 3px solid #6c757d;
|
border-left: 3px solid #6c757d;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
bottom: 125%;
|
bottom: 125%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background-color: #333;
|
background-color: var(--ui-text);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -405,7 +405,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.borrowed-item {
|
.borrowed-item {
|
||||||
background-color: #fff;
|
background-color: var(--ui-surface);
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -457,7 +457,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
.exemplar-details {
|
.exemplar-details {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border-left: 3px solid #6c757d;
|
border-left: 3px solid #6c757d;
|
||||||
@@ -515,7 +515,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
.no-items-message {
|
.no-items-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div id="push-notification-settings" style="margin-bottom: 24px;"></div>
|
<div id="push-notification-settings" style="margin-bottom: 24px;"></div>
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns:1fr; gap:14px;">
|
<div style="display:grid; grid-template-columns:1fr; gap:14px;">
|
||||||
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
<section style="background: var(--ui-surface); border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
||||||
<h2 style="margin:0 0 12px; font-size:1.15rem;">Meine Benachrichtigungen</h2>
|
<h2 style="margin:0 0 12px; font-size:1.15rem;">Meine Benachrichtigungen</h2>
|
||||||
{% if user_notifications %}
|
{% if user_notifications %}
|
||||||
<div style="display:grid; gap:10px;">
|
<div style="display:grid; gap:10px;">
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if is_admin_user %}
|
{% if is_admin_user %}
|
||||||
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
<section style="background: var(--ui-surface); border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
|
||||||
<h2 style="margin:0 0 12px; font-size:1.15rem;">Admin-Benachrichtigungen</h2>
|
<h2 style="margin:0 0 12px; font-size:1.15rem;">Admin-Benachrichtigungen</h2>
|
||||||
{% if admin_notifications %}
|
{% if admin_notifications %}
|
||||||
<div style="display:grid; gap:10px;">
|
<div style="display:grid; gap:10px;">
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
--primary-dark: #2980b9;
|
--primary-dark: #2980b9;
|
||||||
--success-color: #2ecc71;
|
--success-color: #2ecc71;
|
||||||
--error-color: #e74c3c;
|
--error-color: #e74c3c;
|
||||||
--text-color: #333;
|
--text-color: var(--ui-text);
|
||||||
--light-bg: #f9f9f9;
|
--light-bg: #f9f9f9;
|
||||||
--border-radius: 8px;
|
--border-radius: 8px;
|
||||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
@@ -163,7 +163,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
background-color: #fff;
|
background-color: var(--ui-surface);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
@@ -345,7 +345,7 @@ input::placeholder {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-rules-title {
|
.password-rules-title {
|
||||||
@@ -406,7 +406,7 @@ input::placeholder {
|
|||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-panel h4 {
|
.permission-panel h4 {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function createLoadingIndicator(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reset-loading-message {
|
.reset-loading-message {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reset-item-info {
|
.reset-item-info {
|
||||||
background: #f8f9fa;
|
background: var(--ui-surface-soft);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 25px;
|
margin-bottom: 25px;
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
|
|
||||||
.reset-item-info h3 {
|
.reset-item-info h3 {
|
||||||
margin: 0 0 15px 0;
|
margin: 0 0 15px 0;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reset-item-details strong {
|
.reset-item-details strong {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-warning-section {
|
.reset-warning-section {
|
||||||
@@ -285,7 +285,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-action-desc {
|
.reset-action-desc {
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
.active-borrowings {
|
.active-borrowings {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #f8f9fa;
|
background: var(--ui-surface-soft);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
|
|
||||||
.reset-options-section h4 {
|
.reset-options-section h4 {
|
||||||
margin: 0 0 15px 0;
|
margin: 0 0 15px 0;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-option {
|
.reset-option {
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-reason-section textarea {
|
.reset-reason-section textarea {
|
||||||
@@ -391,7 +391,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
background: #f8f9fa;
|
background: var(--ui-surface-soft);
|
||||||
border-radius: 0 0 12px 12px;
|
border-radius: 0 0 12px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-box strong {
|
.info-box strong {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box p {
|
.info-box p {
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: #e0e0e0;
|
background: #e0e0e0;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.student-card-form {
|
.student-card-form {
|
||||||
background: #f5f5f5;
|
background: var(--ui-bg);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
.form-group label {
|
.form-group label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input,
|
||||||
|
|||||||
@@ -1601,7 +1601,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
.booking-summary {
|
.booking-summary {
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
|
|
||||||
.tutorial-side {
|
.tutorial-side {
|
||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
background: #ffffff;
|
background: var(--ui-surface);
|
||||||
border: 1px solid #cfdbe8;
|
border: 1px solid #cfdbe8;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
|
|
||||||
.workflow-nav button {
|
.workflow-nav button {
|
||||||
border: 1px solid #dbe3ee;
|
border: 1px solid #dbe3ee;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 11px 12px;
|
padding: 11px 12px;
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
|
|
||||||
.workflow-step {
|
.workflow-step {
|
||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
background: #ffffff;
|
background: var(--ui-surface);
|
||||||
border: 1px solid #cfdbe8;
|
border: 1px solid #cfdbe8;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@@ -331,17 +331,17 @@
|
|||||||
<h3>Tägliche Aufgaben</h3>
|
<h3>Tägliche Aufgaben</h3>
|
||||||
<p>Das ist der gewöhnliche Arbeitsablauf während eines Schultages.</p>
|
<p>Das ist der gewöhnliche Arbeitsablauf während eines Schultages.</p>
|
||||||
|
|
||||||
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
<div style="background: var(--ui-surface-soft); border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
||||||
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Morgens:</p>
|
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Morgens:</p>
|
||||||
<p style="margin: 0; color: #334155;">Öffnen Sie die Startseite und prüfen Sie offene Reservierungen oder Rückgabefristen.</p>
|
<p style="margin: 0; color: #334155;">Öffnen Sie die Startseite und prüfen Sie offene Reservierungen oder Rückgabefristen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
<div style="background: var(--ui-surface-soft); border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
||||||
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Mittags:</p>
|
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Mittags:</p>
|
||||||
<p style="margin: 0; color: #334155;">Neue Artikel oder Bücher hinzufügen oder Ausleihungen begründen.</p>
|
<p style="margin: 0; color: #334155;">Neue Artikel oder Bücher hinzufügen oder Ausleihungen begründen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
<div style="background: var(--ui-surface-soft); border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
|
||||||
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Nachmittags:</p>
|
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Nachmittags:</p>
|
||||||
<p style="margin: 0; color: #334155;">Rückgaben buchen und kurz prüfen, ob alles in Ordnung ist.</p>
|
<p style="margin: 0; color: #334155;">Rückgaben buchen und kurz prüfen, ob alles in Ordnung ist.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,14 +78,14 @@
|
|||||||
|
|
||||||
.book-info {
|
.book-info {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
background-color: #fff;
|
background-color: var(--ui-surface);
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-info h4 {
|
.book-info h4 {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.book-description h5 {
|
.book-description h5 {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
max-height: 150px;
|
max-height: 150px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
border: 1px solid #e9ecef;
|
border: 1px solid #e9ecef;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -334,7 +334,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.popup-content p {
|
.popup-content p {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
@@ -380,7 +380,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.popup-close-x:hover {
|
.popup-close-x:hover {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upload form styles */
|
/* Upload form styles */
|
||||||
@@ -388,13 +388,13 @@
|
|||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background-color: #fff;
|
background-color: var(--ui-surface);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-form h1 {
|
.upload-form h1 {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
@@ -408,7 +408,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input,
|
||||||
@@ -428,14 +428,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-inputs {
|
.filter-inputs {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-inputs h3 {
|
.filter-inputs h3 {
|
||||||
color: #333;
|
color: var(--ui-text);
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
}
|
}
|
||||||
@@ -863,7 +863,7 @@
|
|||||||
<input type="date" id="anschaffungsjahr" name="anschaffungsjahr">
|
<input type="date" id="anschaffungsjahr" name="anschaffungsjahr">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="anschaffungskosten">Anschaffungskosten</label>
|
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
|
||||||
<input id="anschaffungskosten" name="anschaffungskosten">
|
<input id="anschaffungskosten" name="anschaffungskosten">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -407,14 +407,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-requirements {
|
.password-requirements {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--ui-surface-soft);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -439,7 +439,7 @@
|
|||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #f8fafc;
|
background: var(--ui-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.permissions-group h6 {
|
.permissions-group h6 {
|
||||||
|
|||||||
+93
-4
@@ -10,28 +10,104 @@ Each tenant can support up to 20+ users with isolated data and resource pools.
|
|||||||
from flask import request, g, has_request_context
|
from flask import request, g, has_request_context
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import settings as cfg
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Tenant registry: maps subdomain/tenant_id to database name
|
# Tenant registry: maps subdomain/tenant_id to database name
|
||||||
TENANT_REGISTRY = {}
|
TENANT_REGISTRY = {}
|
||||||
|
if isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
|
||||||
|
TENANT_REGISTRY.update(cfg.TENANT_CONFIGS)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nested_value(source, path, default=None):
|
||||||
|
current = source
|
||||||
|
for key in path:
|
||||||
|
if isinstance(current, dict) and key in current:
|
||||||
|
current = current[key]
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_port_from_host(host):
|
||||||
|
"""Parse host header and return (hostname, port) when a numeric port is present."""
|
||||||
|
if host.startswith('['):
|
||||||
|
# IPv6 with port: [::1]:10000
|
||||||
|
if ']:' in host:
|
||||||
|
host_part, _, port_part = host.rpartition(']:')
|
||||||
|
return host_part + ']', port_part
|
||||||
|
return host, None
|
||||||
|
|
||||||
|
if host.count(':') == 1:
|
||||||
|
hostname, port = host.split(':', 1)
|
||||||
|
if port.isdigit():
|
||||||
|
return hostname, port
|
||||||
|
|
||||||
|
return host, None
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_id_for_port(port):
|
||||||
|
"""Map a host port to a registered tenant ID via tenant configs or env overrides."""
|
||||||
|
for tenant_id, config in TENANT_REGISTRY.items():
|
||||||
|
if isinstance(config, dict) and config.get('port') is not None:
|
||||||
|
try:
|
||||||
|
configured_port = str(int(config.get('port')))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if configured_port == str(port):
|
||||||
|
return tenant_id
|
||||||
|
|
||||||
|
port_map = os.getenv('INVENTAR_TENANT_PORT_MAP', '').strip()
|
||||||
|
if port_map:
|
||||||
|
for mapping in re.split(r'[;,\s]+', port_map):
|
||||||
|
if '=' not in mapping:
|
||||||
|
continue
|
||||||
|
key, value = mapping.split('=', 1)
|
||||||
|
if key.strip() == str(port):
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_config(tenant_id=None):
|
||||||
|
"""Return the registered config for a tenant, falling back to default."""
|
||||||
|
if tenant_id is None:
|
||||||
|
ctx = get_tenant_context()
|
||||||
|
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
|
||||||
|
|
||||||
|
if tenant_id in TENANT_REGISTRY:
|
||||||
|
return TENANT_REGISTRY[tenant_id] or {}
|
||||||
|
|
||||||
|
return TENANT_REGISTRY.get('default', {}) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
|
||||||
|
"""Resolve whether a feature module is enabled for the current tenant."""
|
||||||
|
config = get_tenant_config(tenant_id)
|
||||||
|
enabled = _get_nested_value(config, ['modules', module_name, 'enabled'], default)
|
||||||
|
return bool(enabled)
|
||||||
|
|
||||||
|
|
||||||
class TenantContext:
|
class TenantContext:
|
||||||
"""
|
"""
|
||||||
Manages current tenant context for request lifecycle.
|
Manages current tenant context for request lifecycle.
|
||||||
Automatically resolves tenant from subdomain or request header.
|
Automatically resolves tenant from port, header, or subdomain.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.tenant_id = None
|
self.tenant_id = None
|
||||||
self.db_name = None
|
self.db_name = None
|
||||||
self.subdomain = None
|
self.subdomain = None
|
||||||
|
self.port = None
|
||||||
|
self.config = {}
|
||||||
|
|
||||||
def resolve_tenant(self):
|
def resolve_tenant(self):
|
||||||
"""
|
"""
|
||||||
Resolve tenant from request context.
|
Resolve tenant from request context.
|
||||||
Priority: Header > Subdomain > Default
|
Priority: Header > Port mapping > Subdomain > Default
|
||||||
"""
|
"""
|
||||||
if not has_request_context():
|
if not has_request_context():
|
||||||
return None
|
return None
|
||||||
@@ -40,10 +116,21 @@ class TenantContext:
|
|||||||
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
|
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
|
||||||
if tenant_from_header:
|
if tenant_from_header:
|
||||||
self.tenant_id = tenant_from_header
|
self.tenant_id = tenant_from_header
|
||||||
|
self.config = get_tenant_config(tenant_from_header)
|
||||||
return self._get_db_name(tenant_from_header)
|
return self._get_db_name(tenant_from_header)
|
||||||
|
|
||||||
# Priority 2: Subdomain extraction
|
# Priority 2: Port-based tenant mapping
|
||||||
host = request.host.lower()
|
host = request.host.lower()
|
||||||
|
_, port = _parse_port_from_host(host)
|
||||||
|
self.port = port
|
||||||
|
if port:
|
||||||
|
tenant_from_port = _tenant_id_for_port(port)
|
||||||
|
if tenant_from_port:
|
||||||
|
self.tenant_id = tenant_from_port
|
||||||
|
self.config = get_tenant_config(tenant_from_port)
|
||||||
|
return self._get_db_name(tenant_from_port)
|
||||||
|
|
||||||
|
# Priority 3: Subdomain extraction
|
||||||
parts = host.split('.')
|
parts = host.split('.')
|
||||||
|
|
||||||
# Extract subdomain from host
|
# Extract subdomain from host
|
||||||
@@ -56,10 +143,12 @@ class TenantContext:
|
|||||||
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
||||||
self.subdomain = potential_subdomain
|
self.subdomain = potential_subdomain
|
||||||
self.tenant_id = potential_subdomain
|
self.tenant_id = potential_subdomain
|
||||||
|
self.config = get_tenant_config(potential_subdomain)
|
||||||
return self._get_db_name(potential_subdomain)
|
return self._get_db_name(potential_subdomain)
|
||||||
|
|
||||||
# Fallback to default tenant if no subdomain detected
|
# Fallback to default tenant if no tenant identifier found
|
||||||
self.tenant_id = 'default'
|
self.tenant_id = 'default'
|
||||||
|
self.config = get_tenant_config('default')
|
||||||
return self._get_db_name('default')
|
return self._get_db_name('default')
|
||||||
|
|
||||||
def _get_db_name(self, tenant_id):
|
def _get_db_name(self, tenant_id):
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
|
|||||||
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
|
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
|
||||||
KEEP_DAYS="${KEEP_DAYS:-7}"
|
KEEP_DAYS="${KEEP_DAYS:-7}"
|
||||||
MIN_KEEP="${MIN_KEEP:-7}"
|
MIN_KEEP="${MIN_KEEP:-7}"
|
||||||
DB_NAME="${DB_NAME:-Inventarsystem}"
|
DB_NAME="${DB_NAME:-inventar_default}"
|
||||||
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
|
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
|
||||||
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
|
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
|
||||||
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
|
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
|
||||||
BACKUP_MODE="${BACKUP_MODE:-auto}"
|
BACKUP_MODE="${BACKUP_MODE:-auto}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
|
||||||
DOCKER_AVAILABLE=0
|
DOCKER_AVAILABLE=0
|
||||||
DOCKER_COMPOSE_CMD=()
|
DOCKER_COMPOSE_CMD=()
|
||||||
USE_NULL_OUTPUT=false
|
USE_NULL_OUTPUT=false
|
||||||
@@ -58,6 +59,8 @@ Options:
|
|||||||
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
|
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
|
||||||
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
|
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
|
||||||
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
|
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
-h|--help Show this help and exit
|
-h|--help Show this help and exit
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MIN_KEEP="$2"; shift 2;;
|
MIN_KEEP="$2"; shift 2;;
|
||||||
--mode)
|
--mode)
|
||||||
BACKUP_MODE="$2"; shift 2;;
|
BACKUP_MODE="$2"; shift 2;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"; shift;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage; exit 0;;
|
usage; exit 0;;
|
||||||
*)
|
*)
|
||||||
@@ -102,12 +109,12 @@ log_message() {
|
|||||||
init_docker_compose() {
|
init_docker_compose() {
|
||||||
if docker compose version >/dev/null 2>&1; then
|
if docker compose version >/dev/null 2>&1; then
|
||||||
DOCKER_AVAILABLE=1
|
DOCKER_AVAILABLE=1
|
||||||
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml")
|
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if sudo docker compose version >/dev/null 2>&1; then
|
if sudo docker compose version >/dev/null 2>&1; then
|
||||||
DOCKER_AVAILABLE=1
|
DOCKER_AVAILABLE=1
|
||||||
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml")
|
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
-107
@@ -1,107 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
SUDO=""
|
|
||||||
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
|
||||||
SUDO="sudo"
|
|
||||||
fi
|
|
||||||
|
|
||||||
VENV_PYTHON="$SCRIPT_DIR/.venv/bin/python"
|
|
||||||
if [ ! -x "$VENV_PYTHON" ]; then
|
|
||||||
echo "Error: .venv python not found at $VENV_PYTHON"
|
|
||||||
echo "Create it first, e.g.: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$(id -u)" -ne 0 ] && [ ! -w "$SCRIPT_DIR/.venv" ]; then
|
|
||||||
echo "Fixing ownership of .venv for current user..."
|
|
||||||
if [ -n "$SUDO" ]; then
|
|
||||||
$SUDO chown -R "$(id -un):$(id -gn)" "$SCRIPT_DIR/.venv"
|
|
||||||
else
|
|
||||||
echo "Error: .venv is not writable and sudo is unavailable."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
for path in "$SCRIPT_DIR/dist" "$SCRIPT_DIR/build"; do
|
|
||||||
if [ -e "$path" ] && [ "$(id -u)" -ne 0 ]; then
|
|
||||||
echo "Ensuring ownership of $(basename "$path") for current user..."
|
|
||||||
if [ -n "$SUDO" ]; then
|
|
||||||
$SUDO chown -R "$(id -un):$(id -gn)" "$path"
|
|
||||||
else
|
|
||||||
if [ ! -w "$path" ]; then
|
|
||||||
echo "Error: $path is not writable and sudo is unavailable."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Installing Nuitka build dependencies in .venv..."
|
|
||||||
"$VENV_PYTHON" -m pip install --upgrade pip setuptools wheel
|
|
||||||
"$VENV_PYTHON" -m pip install --upgrade "nuitka==2.8.10" ordered-set zstandard
|
|
||||||
|
|
||||||
DIST_DIR="$SCRIPT_DIR/dist"
|
|
||||||
BUILD_DIR="$SCRIPT_DIR/build"
|
|
||||||
OUTPUT_NAME="inventarsystem"
|
|
||||||
|
|
||||||
mkdir -p "$DIST_DIR" "$BUILD_DIR"
|
|
||||||
|
|
||||||
NUITKA_DATA_ARGS=()
|
|
||||||
|
|
||||||
if [ -d "$SCRIPT_DIR/Web/templates" ]; then
|
|
||||||
NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/Web/templates=templates")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$SCRIPT_DIR/Web/static" ]; then
|
|
||||||
NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/Web/static=static")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$SCRIPT_DIR/uploads" ]; then
|
|
||||||
NUITKA_DATA_ARGS+=("--include-data-dir=$SCRIPT_DIR/uploads=uploads")
|
|
||||||
fi
|
|
||||||
|
|
||||||
ORIGINAL_PERF_PARANOID=""
|
|
||||||
if [ -r /proc/sys/kernel/perf_event_paranoid ]; then
|
|
||||||
ORIGINAL_PERF_PARANOID="$(cat /proc/sys/kernel/perf_event_paranoid)"
|
|
||||||
if [ "$ORIGINAL_PERF_PARANOID" -gt 1 ]; then
|
|
||||||
echo "Temporarily setting kernel.perf_event_paranoid=1 for Nuitka build compatibility..."
|
|
||||||
if ! $SUDO sh -c 'echo 1 > /proc/sys/kernel/perf_event_paranoid'; then
|
|
||||||
echo "Warning: Could not adjust kernel.perf_event_paranoid automatically."
|
|
||||||
echo "Run: sudo sh -c 'echo 1 > /proc/sys/kernel/perf_event_paranoid' and retry."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
restore_perf_setting() {
|
|
||||||
if [ -n "$ORIGINAL_PERF_PARANOID" ] && [ "$ORIGINAL_PERF_PARANOID" -gt 1 ]; then
|
|
||||||
$SUDO sh -c "echo $ORIGINAL_PERF_PARANOID > /proc/sys/kernel/perf_event_paranoid" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
trap restore_perf_setting EXIT
|
|
||||||
|
|
||||||
echo "Building standalone binary with Nuitka..."
|
|
||||||
"$VENV_PYTHON" -m nuitka \
|
|
||||||
--standalone \
|
|
||||||
--assume-yes-for-downloads \
|
|
||||||
--follow-imports \
|
|
||||||
--output-dir="$DIST_DIR" \
|
|
||||||
--output-filename="$OUTPUT_NAME" \
|
|
||||||
"${NUITKA_DATA_ARGS[@]}" \
|
|
||||||
--remove-output \
|
|
||||||
"$SCRIPT_DIR/Web/app.py"
|
|
||||||
|
|
||||||
APP_DIST_DIR="$DIST_DIR/app.dist"
|
|
||||||
if [ -d "$APP_DIST_DIR" ]; then
|
|
||||||
echo "Nuitka build complete."
|
|
||||||
echo "Run with: $APP_DIST_DIR/$OUTPUT_NAME"
|
|
||||||
else
|
|
||||||
echo "Build finished, but expected output directory not found: $APP_DIST_DIR"
|
|
||||||
echo "Check Nuitka output above."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
+4
-2
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"dbg": false,
|
"dbg": false,
|
||||||
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
|
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
|
||||||
"ver": "0.0.2",
|
"ver": "0.6.44",
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 443,
|
"port": 8000,
|
||||||
|
|
||||||
"mongodb": {
|
"mongodb": {
|
||||||
"host": "localhost",
|
"host": "localhost",
|
||||||
@@ -50,6 +50,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"tenants": {},
|
||||||
|
|
||||||
"allowed_extensions": [
|
"allowed_extensions": [
|
||||||
"png", "jpg", "jpeg", "gif",
|
"png", "jpg", "jpeg", "gif",
|
||||||
"hevc", "heif",
|
"hevc", "heif",
|
||||||
|
|||||||
@@ -1,42 +1,25 @@
|
|||||||
# Multi-Tenant Optimized Docker Compose
|
# Multi-Tenant Optimized Docker Compose
|
||||||
# Supports running multiple isolated app instances per subdomain
|
# Supports running multiple isolated app instances with direct Docker host port binding
|
||||||
# Each instance: ~50MB base + 20-30MB per 20 users
|
# Each instance: ~50MB base + 20-30MB per 20 users
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# docker-compose -f docker-compose-multitenant.yml up -d
|
# docker-compose -f docker-compose-multitenant.yml up -d
|
||||||
#
|
#
|
||||||
# Scale example: To support 10 tenants with 20 users each:
|
# Example: Start the stack and expose the app on host port 10000
|
||||||
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
|
# INVENTAR_HTTP_PORT=10000 docker-compose -f docker-compose-multitenant.yml up -d
|
||||||
|
|
||||||
version: '3.9'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nginx:
|
# Management Container for multi-tenant scripts
|
||||||
image: nginx:1.27-alpine
|
tenant-manager:
|
||||||
container_name: inventarsystem-nginx
|
image: docker:cli
|
||||||
restart: unless-stopped
|
container_name: tenant-manager
|
||||||
depends_on:
|
restart: "no"
|
||||||
app:
|
profiles:
|
||||||
condition: service_started
|
- tools
|
||||||
redis:
|
|
||||||
condition: service_started
|
|
||||||
ports:
|
|
||||||
- "${INVENTAR_HTTP_PORT:-80}:80"
|
|
||||||
- "${INVENTAR_HTTPS_PORT:-443}:443"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/nginx/multitenant.conf:/etc/nginx/conf.d/default.conf:ro
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./certs:/etc/nginx/certs:ro
|
- .:/workspace
|
||||||
- ./docker/nginx/acme:/etc/nginx/acme:ro
|
entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
|
||||||
networks:
|
|
||||||
- inventar-net
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-q", "-O-", "http://localhost/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
environment:
|
|
||||||
NGINX_WORKER_PROCESSES: auto
|
|
||||||
NGINX_WORKER_CONNECTIONS: 1024
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -59,9 +42,8 @@ services:
|
|||||||
image: mongo:7.0
|
image: mongo:7.0
|
||||||
container_name: inventarsystem-mongodb
|
container_name: inventarsystem-mongodb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: mongod --wiredTigerCacheSizeGB 2 --journal --dbPath /data/db
|
expose:
|
||||||
ports:
|
- "27017"
|
||||||
- "27017:27017"
|
|
||||||
volumes:
|
volumes:
|
||||||
- mongodb_data:/data/db
|
- mongodb_data:/data/db
|
||||||
- ./docker/mongo:/docker-entrypoint-initdb.d:ro
|
- ./docker/mongo:/docker-entrypoint-initdb.d:ro
|
||||||
@@ -82,6 +64,8 @@ services:
|
|||||||
args:
|
args:
|
||||||
PYTHON_VERSION: "3.13"
|
PYTHON_VERSION: "3.13"
|
||||||
OPTIMIZATION_LEVEL: 2
|
OPTIMIZATION_LEVEL: 2
|
||||||
|
working_dir: /app/Web
|
||||||
|
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
@@ -90,13 +74,14 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||||
networks:
|
networks:
|
||||||
- inventar-net
|
- inventar-net
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.json:/app/config.json:ro
|
- ./config.json:/app/config.json:ro
|
||||||
- ./Web:/app/Web:ro
|
|
||||||
- app_uploads:/app/Web/uploads:cached
|
- app_uploads:/app/Web/uploads:cached
|
||||||
- app_thumbnails:/app/Web/thumbnails:cached
|
- app_thumbnails:/app/Web/thumbnails:cached
|
||||||
- app_previews:/app/Web/previews:cached
|
- app_previews:/app/Web/previews:cached
|
||||||
@@ -181,9 +166,6 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
inventar-net:
|
inventar-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 172.20.0.0/16
|
|
||||||
|
|
||||||
# Scale guidance:
|
# Scale guidance:
|
||||||
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
|
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
|
||||||
@@ -196,3 +178,4 @@ networks:
|
|||||||
# - Medium (5-10 tenants): 4GB RAM, 2-4 CPU cores
|
# - Medium (5-10 tenants): 4GB RAM, 2-4 CPU cores
|
||||||
# - Large (10-20 tenants): 8GB RAM, 4-8 CPU cores
|
# - Large (10-20 tenants): 8GB RAM, 4-8 CPU cores
|
||||||
# - Jumbo (20+ tenants): 16GB+ RAM, 8+ CPU cores with clustering
|
# - Jumbo (20+ tenants): 16GB+ RAM, 8+ CPU cores with clustering
|
||||||
|
|
||||||
|
|||||||
+26
-72
@@ -1,87 +1,41 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nginx:
|
app:
|
||||||
image: nginx:1.27-alpine
|
build: .
|
||||||
container_name: inventarsystem-nginx
|
container_name: inventory-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MONGO_URL=mongodb://mongodb:27017/inventar
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
|
||||||
depends_on:
|
depends_on:
|
||||||
app:
|
- mongodb
|
||||||
condition: service_started
|
- redis
|
||||||
ports:
|
|
||||||
- "${INVENTAR_HTTP_PORT:-80}:80"
|
|
||||||
- "${INVENTAR_HTTPS_PORT:-443}:443"
|
|
||||||
volumes:
|
|
||||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
|
||||||
- ./certs:/etc/nginx/certs:ro
|
|
||||||
|
|
||||||
mongodb:
|
mongodb:
|
||||||
image: mongo:7.0
|
image: mongo:latest
|
||||||
container_name: inventarsystem-mongodb
|
container_name: mongodb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- mongodb_data:/data/db
|
- mongo_data:/data/db
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:alpine
|
||||||
container_name: inventarsystem-redis
|
container_name: redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
app:
|
cloudflared:
|
||||||
build:
|
image: cloudflare/cloudflared:latest
|
||||||
context: .
|
container_name: cloudflared
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: inventarsystem-app
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
|
||||||
- no-new-privileges:true
|
command: tunnel run homeserver
|
||||||
depends_on:
|
|
||||||
mongodb:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
INVENTAR_MONGODB_HOST: mongodb
|
|
||||||
INVENTAR_MONGODB_PORT: "27017"
|
|
||||||
INVENTAR_MONGODB_DB: Inventarsystem
|
|
||||||
INVENTAR_REDIS_HOST: redis
|
|
||||||
INVENTAR_REDIS_PORT: "6379"
|
|
||||||
INVENTAR_REDIS_CACHE_DB: "1"
|
|
||||||
INVENTAR_NOTIFICATION_STATUS_CACHE_TTL: "8"
|
|
||||||
INVENTAR_BACKUP_FOLDER: /data/backups
|
|
||||||
INVENTAR_LOGS_FOLDER: /data/logs
|
|
||||||
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
|
|
||||||
expose:
|
|
||||||
- "8000"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.json:/app/config.json:ro
|
- ./config.yml:/etc/cloudflared/config.yml
|
||||||
- ./Web:/app/Web:ro
|
- ./credentials.json:/etc/cloudflared/credentials.json
|
||||||
- app_uploads:/app/Web/uploads
|
depends_on:
|
||||||
- app_thumbnails:/app/Web/thumbnails
|
- app
|
||||||
- app_previews:/app/Web/previews
|
|
||||||
- app_qrcodes:/app/Web/QRCodes
|
|
||||||
- app_backups:/data/backups
|
|
||||||
- app_logs:/data/logs
|
|
||||||
- app_deleted_archives:/data/deleted-archives
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongodb_data:
|
mongo_data:
|
||||||
app_uploads:
|
|
||||||
app_thumbnails:
|
|
||||||
app_previews:
|
|
||||||
app_qrcodes:
|
|
||||||
app_backups:
|
|
||||||
app_logs:
|
|
||||||
app_deleted_archives:
|
|
||||||
redis_data:
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
tunnel: homeserver
|
||||||
|
credentials-file: /etc/cloudflared/credentials.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
- service: https://nginx:443
|
||||||
|
originRequest:
|
||||||
|
noTLSVerify: true
|
||||||
|
- service: http_status:404
|
||||||
@@ -37,7 +37,8 @@ upstream inventar_backend {
|
|||||||
|
|
||||||
# Main HTTPS server block
|
# Main HTTPS server block
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
|
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
|
||||||
|
|
||||||
# SSL Configuration (wildcard certificate)
|
# SSL Configuration (wildcard certificate)
|
||||||
@@ -75,9 +76,9 @@ server {
|
|||||||
application/x-javascript application/xml+rss application/json;
|
application/x-javascript application/xml+rss application/json;
|
||||||
gzip_comp_level 5;
|
gzip_comp_level 5;
|
||||||
|
|
||||||
# Access/Error logs with tenant in filename
|
# Access/Error logs
|
||||||
access_log /var/log/nginx/inventar_access_${tenant}.log combined buffer=32k;
|
access_log /var/log/nginx/inventar_access.log combined buffer=32k;
|
||||||
error_log /var/log/nginx/inventar_error_${tenant}.log warn;
|
error_log /var/log/nginx/inventar_error.log warn;
|
||||||
|
|
||||||
# Root location - proxy to app with tenant context
|
# Root location - proxy to app with tenant context
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
+29
-10
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
|
|||||||
CLEANUP_OLD_SERVICES=true
|
CLEANUP_OLD_SERVICES=true
|
||||||
CLEANUP_OLD_REMOVE_CRON=false
|
CLEANUP_OLD_REMOVE_CRON=false
|
||||||
TMP_DIR=""
|
TMP_DIR=""
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
cleanup_tmp_dir() {
|
cleanup_tmp_dir() {
|
||||||
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
|
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
|
||||||
@@ -50,14 +51,13 @@ refresh_start_script_from_main() {
|
|||||||
pin_compose_app_image() {
|
pin_compose_app_image() {
|
||||||
local tag="$1"
|
local tag="$1"
|
||||||
local compose_file
|
local compose_file
|
||||||
compose_file="$PROJECT_DIR/docker-compose.yml"
|
|
||||||
|
|
||||||
if [ ! -f "$compose_file" ]; then
|
for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
|
||||||
echo "Warning: $compose_file not found; cannot pin app image"
|
if [ ! -f "$compose_file" ]; then
|
||||||
return 0
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 - <<'PY' "$compose_file" "$tag"
|
python3 - <<'PY' "$compose_file" "$tag"
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -151,6 +151,8 @@ if in_app_service and build_found and app_service_indent is not None:
|
|||||||
with open(compose_file, "w", encoding="utf-8") as f:
|
with open(compose_file, "w", encoding="utf-8") as f:
|
||||||
f.writelines(out)
|
f.writelines(out)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
install_docker_if_missing() {
|
install_docker_if_missing() {
|
||||||
@@ -173,6 +175,8 @@ Options:
|
|||||||
--remove-legacy-system Remove old host MongoDB/system after successful import
|
--remove-legacy-system Remove old host MongoDB/system after successful import
|
||||||
--skip-cleanup-old Do not run cleanup-old.sh after install
|
--skip-cleanup-old Do not run cleanup-old.sh after install
|
||||||
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
|
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
|
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
|
||||||
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
|
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
|
||||||
--legacy-system-dir <path> Optional old system directory to remove after migration
|
--legacy-system-dir <path> Optional old system directory to remove after migration
|
||||||
@@ -199,6 +203,14 @@ parse_args() {
|
|||||||
CLEANUP_OLD_REMOVE_CRON=true
|
CLEANUP_OLD_REMOVE_CRON=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--legacy-db-name)
|
--legacy-db-name)
|
||||||
LEGACY_DB_NAME="$2"
|
LEGACY_DB_NAME="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -280,14 +292,21 @@ backup_legacy_database() {
|
|||||||
|
|
||||||
restore_legacy_backup_into_docker() {
|
restore_legacy_backup_into_docker() {
|
||||||
local i
|
local i
|
||||||
|
local compose_path
|
||||||
|
|
||||||
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
|
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||||
|
if [ ! -f "$compose_path" ]; then
|
||||||
|
echo "Error: compose file not found: $compose_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Waiting for Docker MongoDB to become ready..."
|
echo "Waiting for Docker MongoDB to become ready..."
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
|
if sudo docker compose -f "$compose_path" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -299,7 +318,7 @@ restore_legacy_backup_into_docker() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Importing legacy backup into Docker MongoDB..."
|
echo "Importing legacy backup into Docker MongoDB..."
|
||||||
sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
|
sudo docker compose -f "$compose_path" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
|
||||||
echo "Legacy DB import completed."
|
echo "Legacy DB import completed."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,8 +473,8 @@ EOF
|
|||||||
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
|
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
|
||||||
cat > "$TMP_DIR/.docker-build.env" <<EOF
|
cat > "$TMP_DIR/.docker-build.env" <<EOF
|
||||||
NUITKA_BUILD=0
|
NUITKA_BUILD=0
|
||||||
INVENTAR_HTTP_PORT=80
|
INVENTAR_HTTP_PORT=10000
|
||||||
INVENTAR_HTTPS_PORT=443
|
INVENTAR_HTTPS_PORT=10001
|
||||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
|
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
|
||||||
EOF
|
EOF
|
||||||
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
|
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
|
||||||
|
|||||||
Executable
+252
@@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
IFS=$'\n\t'
|
||||||
|
# Script to manage multitenant deployment
|
||||||
|
# Allows adding, removing, and restarting tenants without downtime for others
|
||||||
|
|
||||||
|
if [ ! -f "docker-compose-multitenant.yml" ]; then
|
||||||
|
echo "Error: docker-compose-multitenant.yml not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONFIG_FILE="$PWD/config.json"
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
|
||||||
|
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
|
||||||
|
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
|
||||||
|
echo " restart-all Restart all application containers (zero-downtime reload)"
|
||||||
|
echo " list List active tenants"
|
||||||
|
echo " -h, --help Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " ./manage-tenant.sh add school_a 10001"
|
||||||
|
echo " ./manage-tenant.sh remove test_tenant"
|
||||||
|
echo " ./manage-tenant.sh restart-all"
|
||||||
|
echo " ./manage-tenant.sh -h"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
register_tenant_port() {
|
||||||
|
local tenant_id="$1"
|
||||||
|
local port="$2"
|
||||||
|
|
||||||
|
if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port"
|
||||||
|
import json, sys, os
|
||||||
|
path, tenant_id, port_str = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
print(f"Error: config file not found: {path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
tenants = cfg.get('tenants')
|
||||||
|
if tenants is None or not isinstance(tenants, dict):
|
||||||
|
tenants = {}
|
||||||
|
for tid, conf in tenants.items():
|
||||||
|
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid != tenant_id:
|
||||||
|
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
existing = tenants.get(tenant_id)
|
||||||
|
if existing is None or not isinstance(existing, dict):
|
||||||
|
existing = {}
|
||||||
|
existing['port'] = int(port_str)
|
||||||
|
tenants[tenant_id] = existing
|
||||||
|
cfg['tenants'] = tenants
|
||||||
|
with open(path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||||
|
print(f"Registered tenant port {port_str} for {tenant_id}")
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
echo "Tenant $tenant_id port $port registered in config.json"
|
||||||
|
else
|
||||||
|
echo "Failed to register tenant port $port for $tenant_id"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
update_runtime_ports() {
|
||||||
|
local new_port="$1"
|
||||||
|
local env_file="$PWD/.docker-build.env"
|
||||||
|
local current_ports=""
|
||||||
|
local port_list=()
|
||||||
|
local unique_ports=()
|
||||||
|
local first_port=""
|
||||||
|
if [ -n "$new_port" ]; then
|
||||||
|
if [ -f "$env_file" ]; then
|
||||||
|
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||||
|
if [ -z "$current_ports" ]; then
|
||||||
|
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$current_ports" ]; then
|
||||||
|
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
||||||
|
fi
|
||||||
|
port_list+=("$new_port")
|
||||||
|
|
||||||
|
for port in "${port_list[@]}"; do
|
||||||
|
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
|
||||||
|
unique_ports+=("$port")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#unique_ports[@]} -eq 0 ]; then
|
||||||
|
unique_ports=("$new_port")
|
||||||
|
fi
|
||||||
|
|
||||||
|
first_port="${unique_ports[0]}"
|
||||||
|
local ports_csv
|
||||||
|
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
|
||||||
|
|
||||||
|
if [ ! -f "$env_file" ]; then
|
||||||
|
cat > "$env_file" <<EOF
|
||||||
|
NUITKA_BUILD=0
|
||||||
|
INVENTAR_HTTP_PORT=$first_port
|
||||||
|
INVENTAR_HTTP_PORTS=$ports_csv
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
||||||
|
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
||||||
|
else
|
||||||
|
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
||||||
|
fi
|
||||||
|
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
||||||
|
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
||||||
|
else
|
||||||
|
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Updated runtime env ports: $ports_csv"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "${1:-}" ]; then
|
||||||
|
show_help
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMMAND="$1"
|
||||||
|
TENANT_ID="${2:-}"
|
||||||
|
|
||||||
|
case "$COMMAND" in
|
||||||
|
-h|--help)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
add)
|
||||||
|
if [ -z "$TENANT_ID" ]; then
|
||||||
|
echo "Error: Please provide a tenant_id."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PORT_ARG="$3"
|
||||||
|
if [ -n "$PORT_ARG" ]; then
|
||||||
|
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
||||||
|
echo "Error: Port must be a numeric value."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
register_tenant_port "$TENANT_ID" "$PORT_ARG"
|
||||||
|
update_runtime_ports "$PORT_ARG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Adding new tenant '$TENANT_ID'..."
|
||||||
|
# Initialize tenant database via Python inside container
|
||||||
|
echo "Initializing database for $TENANT_ID..."
|
||||||
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
|
docker exec $APP_CONTAINER python3 -c "
|
||||||
|
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||||
|
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
|
||||||
|
db.users.insert_one({'Username': 'admin', 'Password': hashed_pw, 'Role': 'admin', 'Name': 'Admin', 'Nachname': 'User'})
|
||||||
|
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
|
||||||
|
" "$TENANT_ID"
|
||||||
|
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||||
|
else
|
||||||
|
echo "Warning: Application container is not running. Please start the multi-tenant system first."
|
||||||
|
echo "Data will be initialized upon first access by the tenant."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
remove)
|
||||||
|
if [ -z "$TENANT_ID" ]; then
|
||||||
|
echo "Error: Please provide a tenant_id to remove."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||||
|
read confirm
|
||||||
|
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
||||||
|
echo "Removing tenant '$TENANT_ID'..."
|
||||||
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
|
docker exec $APP_CONTAINER python3 -c "
|
||||||
|
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
client.drop_database(f'{settings.MONGODB_DB}_{sys.argv[1]}')
|
||||||
|
print(f'Database for tenant {sys.argv[1]} dropped.')
|
||||||
|
" "$TENANT_ID"
|
||||||
|
echo "Tenant '$TENANT_ID' removed successfully."
|
||||||
|
else
|
||||||
|
echo "Error: Application container not running."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Removal canceled."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
restart-tenant)
|
||||||
|
if [ -z "$TENANT_ID" ]; then
|
||||||
|
echo "Error: Please provide a tenant_id."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..."
|
||||||
|
# To restart a single tenant without restarting the global python processes,
|
||||||
|
# we can invalidate their cache or drop their sessions collection to sign everyone out
|
||||||
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
|
docker exec $APP_CONTAINER python3 -c "
|
||||||
|
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||||
|
db.sessions.drop() # Force sign-out / session clear
|
||||||
|
print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
|
||||||
|
" "$TENANT_ID"
|
||||||
|
echo "Tenant '$TENANT_ID' has been refreshed without impacting others."
|
||||||
|
else
|
||||||
|
echo "Error: Application container not running."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
restart-all)
|
||||||
|
echo "Restarting all application instances with zero-downtime rolling restart..."
|
||||||
|
docker restart $(docker ps -qf "name=app")
|
||||||
|
echo "Global restart complete."
|
||||||
|
;;
|
||||||
|
|
||||||
|
list)
|
||||||
|
echo "Listing active tenants (Databases):"
|
||||||
|
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||||
|
if [ -n "$APP_CONTAINER" ]; then
|
||||||
|
docker exec $APP_CONTAINER python3 -c "
|
||||||
|
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
||||||
|
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||||
|
prefix = f'{settings.MONGODB_DB}_'
|
||||||
|
dbs = [d for d in client.list_database_names() if d.startswith(prefix)]
|
||||||
|
for db in dbs:
|
||||||
|
print(f'- {db.replace(prefix, \"\")}')
|
||||||
|
"
|
||||||
|
else
|
||||||
|
echo "Error: Application container not running."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Unknown command: $COMMAND"
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
+2
-1
@@ -12,5 +12,6 @@ redis
|
|||||||
reportlab
|
reportlab
|
||||||
python-barcode
|
python-barcode
|
||||||
openpyxl
|
openpyxl
|
||||||
cryptography
|
cryptography>=42.0.0
|
||||||
pywebpush
|
pywebpush
|
||||||
|
py-vapid>=1.9.0
|
||||||
|
|||||||
+51
-4
@@ -1,7 +1,54 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
"$SCRIPT_DIR/stop.sh"
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
"$SCRIPT_DIR/start.sh"
|
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
||||||
|
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Error: docker command not found. Install Docker first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Rebuilding and/or restarting app container using $COMPOSE_FILE..."
|
||||||
|
compose_args=(-f "$COMPOSE_FILE")
|
||||||
|
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||||
|
compose_args+=( -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" )
|
||||||
|
fi
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
compose_args+=( --env-file "$ENV_FILE" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$SCRIPT_DIR/Dockerfile" ]; then
|
||||||
|
docker compose "${compose_args[@]}" up -d --build app
|
||||||
|
else
|
||||||
|
echo "Warning: Dockerfile not found in $SCRIPT_DIR. Skipping build and restarting existing app container."
|
||||||
|
if ! docker compose "${compose_args[@]}" up -d --no-build app; then
|
||||||
|
echo "Warning: app image not found or not available locally. Attempting docker compose pull app..."
|
||||||
|
docker compose "${compose_args[@]}" pull app
|
||||||
|
docker compose "${compose_args[@]}" up -d --no-build app
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cleaning up unused Docker images..."
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo "App restart complete."
|
||||||
|
|||||||
+14
-3
@@ -6,12 +6,13 @@ LOG_DIR="$SCRIPT_DIR/logs"
|
|||||||
LOG_FILE="$LOG_DIR/restore.log"
|
LOG_FILE="$LOG_DIR/restore.log"
|
||||||
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
|
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
|
||||||
|
|
||||||
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}"
|
DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
|
||||||
SOURCE_PATH=""
|
SOURCE_PATH=""
|
||||||
BACKUP_DATE=""
|
BACKUP_DATE=""
|
||||||
DROP_DATABASE=false
|
DROP_DATABASE=false
|
||||||
RESTART_SERVICES=false
|
RESTART_SERVICES=false
|
||||||
STAGED_PATH=""
|
STAGED_PATH=""
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
|
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
|
||||||
BACKUP_ROOT_SYSTEM="/var/backups"
|
BACKUP_ROOT_SYSTEM="/var/backups"
|
||||||
@@ -56,6 +57,8 @@ Options:
|
|||||||
- latest: newest from local/system backup roots
|
- latest: newest from local/system backup roots
|
||||||
--drop-database Drop target DB before import (recommended for full restore)
|
--drop-database Drop target DB before import (recommended for full restore)
|
||||||
--restart-services Restart stack after restore
|
--restart-services Restart stack after restore
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
--list List detected backup candidates
|
--list List detected backup candidates
|
||||||
--help Show this help
|
--help Show this help
|
||||||
|
|
||||||
@@ -68,12 +71,12 @@ EOF
|
|||||||
|
|
||||||
setup_compose() {
|
setup_compose() {
|
||||||
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||||
DOCKER_COMPOSE=(docker compose)
|
DOCKER_COMPOSE=(docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
|
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
|
||||||
DOCKER_COMPOSE=($SUDO docker compose)
|
DOCKER_COMPOSE=($SUDO docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -459,6 +462,14 @@ parse_args() {
|
|||||||
RESTART_SERVICES=true
|
RESTART_SERVICES=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--list)
|
--list)
|
||||||
list_backups
|
list_backups
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Wrapper to run tenant management fully containerized via docker-compose
|
||||||
|
docker compose -f docker-compose-multitenant.yml --profile tools run --rm tenant-manager "$@"
|
||||||
@@ -7,17 +7,27 @@ cd "$SCRIPT_DIR"
|
|||||||
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
||||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||||
DIST_DIR="$SCRIPT_DIR/dist"
|
DIST_DIR="$SCRIPT_DIR/dist"
|
||||||
|
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
||||||
|
|
||||||
SUDO=""
|
SUDO=""
|
||||||
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||||
SUDO="sudo"
|
SUDO="sudo"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
IS_ROOT="false"
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
IS_ROOT="true"
|
||||||
|
fi
|
||||||
|
|
||||||
NUITKA_BUILD_VALUE="0"
|
NUITKA_BUILD_VALUE="0"
|
||||||
HTTP_PORT_VALUE="80"
|
HTTP_PORT_VALUE="10000"
|
||||||
HTTPS_PORT_VALUE="443"
|
HTTP_PORTS_VALUE=""
|
||||||
|
DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}"
|
||||||
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
|
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
|
||||||
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
|
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
COMPOSE_PROFILES_VALUE=""
|
||||||
|
MIN_DOCKER_FREE_MB="${INVENTAR_MIN_DOCKER_FREE_MB:-1024}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -26,6 +36,8 @@ Usage: $0 [options]
|
|||||||
Options:
|
Options:
|
||||||
--no-cron Do not create or update cron jobs
|
--no-cron Do not create or update cron jobs
|
||||||
--with-cron Create/update cron jobs (default)
|
--with-cron Create/update cron jobs (default)
|
||||||
|
--multitenant Use the multi-tenant architecture deployment
|
||||||
|
--singletenant Use the legacy single-tenant compose deployment
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -41,6 +53,14 @@ parse_args() {
|
|||||||
CRON_SETUP_VALUE="1"
|
CRON_SETUP_VALUE="1"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
@@ -100,7 +120,11 @@ ensure_runtime_dependencies() {
|
|||||||
local missing=()
|
local missing=()
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
install_docker_engine
|
if [ "$IS_ROOT" = "true" ]; then
|
||||||
|
install_docker_engine
|
||||||
|
else
|
||||||
|
missing+=(docker)
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! docker compose version >/dev/null 2>&1; then
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
@@ -124,14 +148,20 @@ ensure_runtime_dependencies() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${#missing[@]}" -gt 0 ]; then
|
if [ "${#missing[@]}" -gt 0 ]; then
|
||||||
echo "Installing missing dependencies: ${missing[*]}"
|
if [ "$IS_ROOT" = "true" ]; then
|
||||||
apt_install "${missing[@]}"
|
echo "Installing missing dependencies: ${missing[*]}"
|
||||||
|
apt_install "${missing[@]}"
|
||||||
|
else
|
||||||
|
echo "ERROR: Missing dependencies: ${missing[*]}"
|
||||||
|
echo "Please install the missing tools or run this script as root."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
|
||||||
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
|
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||||
if cron_setup_enabled; then
|
if cron_setup_enabled; then
|
||||||
$SUDO systemctl enable --now cron >/dev/null 2>&1 || true
|
systemctl enable --now cron >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -144,6 +174,11 @@ setup_boot_autostart_service() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$IS_ROOT" != "true" ]; then
|
||||||
|
echo "Skipping systemd autostart setup when not running as root."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v systemctl >/dev/null 2>&1; then
|
if ! command -v systemctl >/dev/null 2>&1; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -185,6 +220,11 @@ setup_scheduled_jobs() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$IS_ROOT" != "true" ]; then
|
||||||
|
echo "Skipping cron job setup when not running as root."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v crontab >/dev/null 2>&1; then
|
if ! command -v crontab >/dev/null 2>&1; then
|
||||||
echo "Warning: crontab not available, skipping nightly update setup"
|
echo "Warning: crontab not available, skipping nightly update setup"
|
||||||
return 0
|
return 0
|
||||||
@@ -195,101 +235,17 @@ setup_scheduled_jobs() {
|
|||||||
backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
|
backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
|
||||||
|
|
||||||
local existing_cron
|
local existing_cron
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
existing_cron="$(crontab -l 2>/dev/null || true)"
|
||||||
existing_cron="$(crontab -l 2>/dev/null || true)"
|
{
|
||||||
{
|
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
|
||||||
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
|
echo "$backup_line"
|
||||||
echo "$backup_line"
|
echo "$update_line"
|
||||||
echo "$update_line"
|
} | crontab -
|
||||||
} | crontab -
|
|
||||||
else
|
|
||||||
existing_cron="$($SUDO crontab -l 2>/dev/null || true)"
|
|
||||||
{
|
|
||||||
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
|
|
||||||
echo "$backup_line"
|
|
||||||
echo "$update_line"
|
|
||||||
} | $SUDO crontab -
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Nightly backup scheduled at 02:30"
|
echo "Nightly backup scheduled at 02:30"
|
||||||
echo "Nightly auto-update scheduled at 03:00"
|
echo "Nightly auto-update scheduled at 03:00"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_tls_certificates() {
|
|
||||||
local cert_dir cert_path key_path cn
|
|
||||||
cert_dir="$SCRIPT_DIR/certs"
|
|
||||||
cert_path="$cert_dir/inventarsystem.crt"
|
|
||||||
key_path="$cert_dir/inventarsystem.key"
|
|
||||||
|
|
||||||
mkdir -p "$cert_dir"
|
|
||||||
|
|
||||||
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
cn="${TLS_CN:-localhost}"
|
|
||||||
echo "No TLS certificates found. Generating self-signed certificate for CN=$cn"
|
|
||||||
|
|
||||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
|
||||||
-keyout "$key_path" \
|
|
||||||
-out "$cert_path" \
|
|
||||||
-subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1
|
|
||||||
|
|
||||||
chmod 600 "$key_path"
|
|
||||||
chmod 644 "$cert_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_nginx_config_mount_source() {
|
|
||||||
local nginx_dir config_path backup_path
|
|
||||||
nginx_dir="$SCRIPT_DIR/docker/nginx"
|
|
||||||
config_path="$nginx_dir/default.conf"
|
|
||||||
|
|
||||||
mkdir -p "$nginx_dir"
|
|
||||||
|
|
||||||
if [ -d "$config_path" ]; then
|
|
||||||
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
|
|
||||||
mv "$config_path" "$backup_path"
|
|
||||||
echo "Warning: moved unexpected directory $config_path to $backup_path"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$config_path" ]; then
|
|
||||||
cat > "$config_path" <<'EOF'
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
|
|
||||||
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
|
|
||||||
|
|
||||||
client_max_body_size 50M;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://app:8000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
location = /50x.html {
|
|
||||||
default_type text/html;
|
|
||||||
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
echo "Recreated missing nginx config at $config_path"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_runtime_config_json() {
|
ensure_runtime_config_json() {
|
||||||
local config_path backup_path
|
local config_path backup_path
|
||||||
config_path="$SCRIPT_DIR/config.json"
|
config_path="$SCRIPT_DIR/config.json"
|
||||||
@@ -306,7 +262,7 @@ ensure_runtime_config_json() {
|
|||||||
"ver": "2.6.5",
|
"ver": "2.6.5",
|
||||||
"dbg": false,
|
"dbg": false,
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 443,
|
"port": 8000,
|
||||||
"mongodb": {
|
"mongodb": {
|
||||||
"host": "mongodb",
|
"host": "mongodb",
|
||||||
"port": 27017,
|
"port": 27017,
|
||||||
@@ -445,97 +401,106 @@ find_free_port() {
|
|||||||
echo "$port"
|
echo "$port"
|
||||||
}
|
}
|
||||||
|
|
||||||
stack_owns_host_port() {
|
parse_port_list() {
|
||||||
local requested_port="$1"
|
local raw="$1"
|
||||||
local container_port="$2"
|
local port
|
||||||
local mapped_port
|
local ports=()
|
||||||
|
raw="${raw//,/ }"
|
||||||
mapped_port="$(docker compose --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
|
for port in $raw; do
|
||||||
if [ -z "$mapped_port" ]; then
|
port="${port//[[:space:]]/}"
|
||||||
return 1
|
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
|
||||||
fi
|
ports+=("$port")
|
||||||
|
fi
|
||||||
mapped_port="${mapped_port##*:}"
|
done
|
||||||
[ "$mapped_port" = "$requested_port" ]
|
printf '%s\n' "${ports[@]}"
|
||||||
}
|
|
||||||
|
|
||||||
stop_host_nginx_services() {
|
|
||||||
local stopped_any=false
|
|
||||||
local service_name
|
|
||||||
|
|
||||||
if ! command -v systemctl >/dev/null 2>&1; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
while IFS= read -r service_name; do
|
|
||||||
[ -z "$service_name" ] && continue
|
|
||||||
echo "Stopping host service $service_name to free web ports..."
|
|
||||||
$SUDO systemctl stop "$service_name" >/dev/null 2>&1 || true
|
|
||||||
stopped_any=true
|
|
||||||
done < <(systemctl list-units --type=service --state=active --no-pager 2>/dev/null | awk '{print $1}' | grep -E '(^nginx\.service$|nginx)' || true)
|
|
||||||
|
|
||||||
if [ "$stopped_any" = true ]; then
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$stopped_any" = true ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
configure_host_ports() {
|
configure_host_ports() {
|
||||||
local requested_http requested_https
|
local requested_http
|
||||||
|
local requested_ports
|
||||||
|
local ports=()
|
||||||
|
local occupied_ports=()
|
||||||
|
|
||||||
requested_http=""
|
requested_http=""
|
||||||
|
requested_ports=""
|
||||||
if [ -f "$ENV_FILE" ]; then
|
if [ -f "$ENV_FILE" ]; then
|
||||||
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
|
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||||
fi
|
requested_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||||
if [ -z "$requested_http" ]; then
|
|
||||||
requested_http="80"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if stack_owns_host_port "$requested_http" "80"; then
|
if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
|
||||||
HTTP_PORT_VALUE="$requested_http"
|
requested_ports="$INVENTAR_HTTP_PORTS"
|
||||||
elif port_in_use "$requested_http"; then
|
fi
|
||||||
stop_host_nginx_services || true
|
|
||||||
|
|
||||||
if ! port_in_use "$requested_http"; then
|
if [ -n "${INVENTAR_HTTP_PORT:-}" ] && [ -z "$requested_ports" ]; then
|
||||||
HTTP_PORT_VALUE="$requested_http"
|
requested_ports="$INVENTAR_HTTP_PORT"
|
||||||
echo "Freed HTTP port $requested_http by stopping host nginx service"
|
fi
|
||||||
else
|
|
||||||
HTTP_PORT_VALUE="$(find_free_port 8080)"
|
if [ -n "$requested_ports" ]; then
|
||||||
echo "HTTP port 80 is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
|
mapfile -t ports < <(parse_port_list "$requested_ports")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#ports[@]} -gt 0 ]; then
|
||||||
|
HTTP_PORTS_VALUE="${ports[*]}"
|
||||||
|
HTTP_PORT_VALUE="${ports[0]}"
|
||||||
|
|
||||||
|
for port in "${ports[@]}"; do
|
||||||
|
if port_in_use "$port"; then
|
||||||
|
occupied_ports+=("$port")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#occupied_ports[@]} -gt 0 ]; then
|
||||||
|
if [ ${#ports[@]} -eq 1 ]; then
|
||||||
|
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
|
||||||
|
echo "Host port ${ports[0]} is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
|
||||||
|
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
|
||||||
|
else
|
||||||
|
echo "Error: requested host port(s) already in use: ${occupied_ports[*]}"
|
||||||
|
echo "Remove the occupied port(s) from INVENTAR_HTTP_PORTS or stop the conflicting service."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
HTTP_PORT_VALUE="$requested_http"
|
HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
|
||||||
fi
|
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
|
||||||
|
|
||||||
requested_https=""
|
if port_in_use "$HTTP_PORT_VALUE"; then
|
||||||
if [ -f "$ENV_FILE" ]; then
|
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
|
||||||
requested_https="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
|
echo "Host port $DEFAULT_TENANT_PORT_START is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
|
||||||
fi
|
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
|
||||||
|
|
||||||
if [ -z "$requested_https" ]; then
|
|
||||||
requested_https="443"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if stack_owns_host_port "$requested_https" "443"; then
|
|
||||||
HTTPS_PORT_VALUE="$requested_https"
|
|
||||||
elif port_in_use "$requested_https"; then
|
|
||||||
stop_host_nginx_services || true
|
|
||||||
|
|
||||||
if ! port_in_use "$requested_https"; then
|
|
||||||
HTTPS_PORT_VALUE="$requested_https"
|
|
||||||
echo "Freed HTTPS port $requested_https by stopping host nginx service"
|
|
||||||
return
|
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
HTTPS_PORT_VALUE="$(find_free_port 8443)"
|
ensure_min_docker_disk_space() {
|
||||||
echo "HTTPS port 443 is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
|
local docker_root available_kb available_mb
|
||||||
else
|
|
||||||
HTTPS_PORT_VALUE="$requested_https"
|
if ! command -v df >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
|
||||||
|
if [ -z "$docker_root" ]; then
|
||||||
|
docker_root="/var/lib/docker"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$docker_root" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
available_kb="$(df -Pk "$docker_root" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
|
||||||
|
if [ -z "$available_kb" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
available_mb=$((available_kb / 1024))
|
||||||
|
|
||||||
|
if [ "$available_mb" -lt "$MIN_DOCKER_FREE_MB" ]; then
|
||||||
|
echo "Error: low disk space in Docker data root ($docker_root)."
|
||||||
|
echo "Available: ${available_mb} MB; required minimum: ${MIN_DOCKER_FREE_MB} MB"
|
||||||
|
echo "MongoDB may fail with 'No space left on device'. Free space and retry."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,14 +508,44 @@ write_env_file() {
|
|||||||
cat > "$ENV_FILE" <<EOF
|
cat > "$ENV_FILE" <<EOF
|
||||||
NUITKA_BUILD=$NUITKA_BUILD_VALUE
|
NUITKA_BUILD=$NUITKA_BUILD_VALUE
|
||||||
INVENTAR_HTTP_PORT=$HTTP_PORT_VALUE
|
INVENTAR_HTTP_PORT=$HTTP_PORT_VALUE
|
||||||
INVENTAR_HTTPS_PORT=$HTTPS_PORT_VALUE
|
INVENTAR_HTTP_PORTS=${HTTP_PORTS_VALUE// /,}
|
||||||
INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
|
INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
write_runtime_compose_override() {
|
||||||
|
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
working_dir: /app/Web
|
||||||
|
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||||
|
image: ${APP_IMAGE_VALUE}
|
||||||
|
build: null
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ -n "$HTTP_PORTS_VALUE" ]; then
|
||||||
|
local ports_array
|
||||||
|
read -r -a ports_array <<<"$HTTP_PORTS_VALUE"
|
||||||
|
if [ "${#ports_array[@]}" -gt 1 ]; then
|
||||||
|
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||||
|
ports:
|
||||||
|
EOF
|
||||||
|
for port in "${ports_array[@]}"; do
|
||||||
|
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||||
|
- "$port:8000"
|
||||||
|
EOF
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
verify_stack_health() {
|
verify_stack_health() {
|
||||||
local compose_args running_services retry_count=0
|
local compose_args running_services retry_count=0
|
||||||
compose_args=(--env-file "$ENV_FILE")
|
compose_args=(-f "$COMPOSE_FILE")
|
||||||
|
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||||
|
compose_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||||
|
fi
|
||||||
|
compose_args+=(--env-file "$ENV_FILE")
|
||||||
|
|
||||||
# Try health check with optional restart on first failure
|
# Try health check with optional restart on first failure
|
||||||
while [[ $retry_count -lt 2 ]]; do
|
while [[ $retry_count -lt 2 ]]; do
|
||||||
@@ -558,10 +553,10 @@ verify_stack_health() {
|
|||||||
for _ in $(seq 1 60); do
|
for _ in $(seq 1 60); do
|
||||||
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
|
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
|
||||||
if printf '%s\n' "$running_services" | grep -Fxq app && \
|
if printf '%s\n' "$running_services" | grep -Fxq app && \
|
||||||
printf '%s\n' "$running_services" | grep -Fxq nginx && \
|
printf '%s\n' "$running_services" | grep -Fxq redis && \
|
||||||
printf '%s\n' "$running_services" | grep -Fxq mongodb; then
|
printf '%s\n' "$running_services" | grep -Fxq mongodb; then
|
||||||
if docker compose "${compose_args[@]}" exec -T app python3 -c "import flask, pymongo" >/dev/null 2>&1; then
|
if docker compose "${compose_args[@]}" exec -T app python3 -c "import flask, pymongo" >/dev/null 2>&1; then
|
||||||
if curl -kfsS "https://127.0.0.1:$HTTPS_PORT_VALUE" >/dev/null 2>&1; then
|
if curl -fsS "http://127.0.0.1:$HTTP_PORT_VALUE/health" >/dev/null 2>&1; then
|
||||||
echo "Health check passed."
|
echo "Health check passed."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -574,8 +569,8 @@ verify_stack_health() {
|
|||||||
if [[ $retry_count -eq 0 ]]; then
|
if [[ $retry_count -eq 0 ]]; then
|
||||||
echo "Health check failed. Attempting to restart containers..."
|
echo "Health check failed. Attempting to restart containers..."
|
||||||
docker compose "${compose_args[@]}" ps || true
|
docker compose "${compose_args[@]}" ps || true
|
||||||
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true
|
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
|
||||||
docker compose "${compose_args[@]}" restart app nginx mongodb
|
docker compose "${compose_args[@]}" restart app redis mongodb
|
||||||
sleep 3
|
sleep 3
|
||||||
((retry_count++))
|
((retry_count++))
|
||||||
else
|
else
|
||||||
@@ -586,7 +581,7 @@ verify_stack_health() {
|
|||||||
# Final failure
|
# Final failure
|
||||||
echo "Error: stack health check failed after restart attempt."
|
echo "Error: stack health check failed after restart attempt."
|
||||||
docker compose "${compose_args[@]}" ps || true
|
docker compose "${compose_args[@]}" ps || true
|
||||||
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true
|
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,20 +589,32 @@ parse_args "$@"
|
|||||||
|
|
||||||
ensure_runtime_dependencies
|
ensure_runtime_dependencies
|
||||||
setup_boot_autostart_service
|
setup_boot_autostart_service
|
||||||
ensure_tls_certificates
|
|
||||||
ensure_nginx_config_mount_source
|
|
||||||
ensure_runtime_config_json
|
ensure_runtime_config_json
|
||||||
setup_scheduled_jobs
|
setup_scheduled_jobs
|
||||||
configure_nuitka_mode
|
configure_nuitka_mode
|
||||||
resolve_app_image
|
resolve_app_image
|
||||||
configure_host_ports
|
configure_host_ports
|
||||||
|
ensure_min_docker_disk_space
|
||||||
ensure_app_image_loaded
|
ensure_app_image_loaded
|
||||||
write_env_file
|
write_env_file
|
||||||
|
write_runtime_compose_override
|
||||||
|
|
||||||
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
|
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
|
||||||
docker compose --env-file "$ENV_FILE" up -d --remove-orphans
|
compose_up_args=(-f "$COMPOSE_FILE")
|
||||||
|
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||||
|
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||||
|
fi
|
||||||
|
compose_up_args+=(--env-file "$ENV_FILE")
|
||||||
|
if [ -n "$COMPOSE_PROFILES_VALUE" ]; then
|
||||||
|
export COMPOSE_PROFILES="$COMPOSE_PROFILES_VALUE"
|
||||||
|
fi
|
||||||
|
if ! docker compose "${compose_up_args[@]}" up -d --remove-orphans; then
|
||||||
|
echo "Docker Compose startup failed once. Waiting briefly and retrying..."
|
||||||
|
sleep 5
|
||||||
|
docker compose "${compose_up_args[@]}" up -d --remove-orphans
|
||||||
|
fi
|
||||||
|
|
||||||
verify_stack_health
|
verify_stack_health
|
||||||
|
|
||||||
echo "Stack started."
|
echo "Stack started."
|
||||||
echo "Open: https://<server-ip>:$HTTPS_PORT_VALUE"
|
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
|
||||||
|
|||||||
@@ -4,12 +4,30 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
echo "Error: docker command not found. Install Docker first."
|
echo "Error: docker command not found. Install Docker first."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Stopping Inventarsystem Docker stack..."
|
echo "Stopping Inventarsystem Docker stack..."
|
||||||
docker compose down
|
docker compose -f "$COMPOSE_FILE" down
|
||||||
|
|
||||||
echo "Stack stopped."
|
echo "Stack stopped."
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
|||||||
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
||||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||||
DIST_DIR="$PROJECT_DIR/dist"
|
DIST_DIR="$PROJECT_DIR/dist"
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
|
||||||
|
DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}"
|
||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
||||||
@@ -35,6 +38,11 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
|||||||
SUDO="sudo"
|
SUDO="sudo"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
IS_ROOT="false"
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
IS_ROOT="true"
|
||||||
|
fi
|
||||||
|
|
||||||
apt_install() {
|
apt_install() {
|
||||||
$SUDO apt-get update -y
|
$SUDO apt-get update -y
|
||||||
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
|
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
|
||||||
@@ -44,7 +52,7 @@ ensure_runtime_dependencies() {
|
|||||||
local missing=()
|
local missing=()
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
missing+=(docker.io)
|
missing+=(docker)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! docker compose version >/dev/null 2>&1; then
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
@@ -64,88 +72,113 @@ ensure_runtime_dependencies() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${#missing[@]}" -gt 0 ]; then
|
if [ "${#missing[@]}" -gt 0 ]; then
|
||||||
log_message "Installing missing dependencies: ${missing[*]}"
|
if [ "$IS_ROOT" = "true" ]; then
|
||||||
apt_install "${missing[@]}"
|
log_message "Installing missing dependencies: ${missing[*]}"
|
||||||
|
apt_install "${missing[@]}"
|
||||||
|
else
|
||||||
|
log_message "ERROR: Missing dependencies: ${missing[*]}"
|
||||||
|
log_message "Install the missing tools manually or re-run as root."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
|
||||||
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
|
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_tls_certificates() {
|
ensure_min_root_disk_space() {
|
||||||
local cert_dir cert_path key_path cn
|
local available_kb available_mb
|
||||||
cert_dir="$PROJECT_DIR/certs"
|
|
||||||
cert_path="$cert_dir/inventarsystem.crt"
|
|
||||||
key_path="$cert_dir/inventarsystem.key"
|
|
||||||
|
|
||||||
mkdir -p "$cert_dir"
|
if ! command -v df >/dev/null 2>&1; then
|
||||||
|
|
||||||
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cn="${TLS_CN:-localhost}"
|
available_kb="$(df -Pk "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
|
||||||
log_message "No TLS certificates found. Generating self-signed certificate for CN=$cn"
|
if [ -z "$available_kb" ]; then
|
||||||
|
return 0
|
||||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
|
||||||
-keyout "$key_path" \
|
|
||||||
-out "$cert_path" \
|
|
||||||
-subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1
|
|
||||||
|
|
||||||
chmod 600 "$key_path"
|
|
||||||
chmod 644 "$cert_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_nginx_config_mount_source() {
|
|
||||||
local nginx_dir config_path backup_path
|
|
||||||
nginx_dir="$PROJECT_DIR/docker/nginx"
|
|
||||||
config_path="$nginx_dir/default.conf"
|
|
||||||
|
|
||||||
mkdir -p "$nginx_dir"
|
|
||||||
|
|
||||||
if [ -d "$config_path" ]; then
|
|
||||||
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
|
|
||||||
mv "$config_path" "$backup_path"
|
|
||||||
log_message "WARNING: Moved unexpected directory $config_path to $backup_path"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f "$config_path" ]; then
|
available_mb=$((available_kb / 1024))
|
||||||
cat > "$config_path" <<'EOF'
|
if [ "$available_mb" -lt "$MIN_ROOT_FREE_MB" ]; then
|
||||||
server {
|
log_message "ERROR: Low disk space on filesystem containing $PROJECT_DIR"
|
||||||
listen 80;
|
log_message "Available: ${available_mb} MB; required minimum: ${MIN_ROOT_FREE_MB} MB"
|
||||||
server_name _;
|
log_message "Free disk space and rerun update."
|
||||||
return 301 https://$host$request_uri;
|
exit 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
cleanup_old_dist_artifacts() {
|
||||||
listen 443 ssl;
|
local keep_count
|
||||||
server_name _;
|
keep_count="$DIST_KEEP_COUNT"
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
|
if [ ! -d "$DIST_DIR" ]; then
|
||||||
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
client_max_body_size 50M;
|
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
|
||||||
|
keep_count=2
|
||||||
|
fi
|
||||||
|
|
||||||
location / {
|
mapfile -t archives < <(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) -printf '%T@ %p\n' | sort -nr | awk '{print $2}')
|
||||||
proxy_pass http://app:8000;
|
if [ "${#archives[@]}" -le "$keep_count" ]; then
|
||||||
proxy_http_version 1.1;
|
return 0
|
||||||
proxy_set_header Host $host;
|
fi
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
local index old_archive deleted=0
|
||||||
location = /50x.html {
|
for (( index=keep_count; index<${#archives[@]}; index++ )); do
|
||||||
default_type text/html;
|
old_archive="${archives[$index]}"
|
||||||
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
|
if rm -f "$old_archive"; then
|
||||||
}
|
deleted=$((deleted + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$deleted" -gt 0 ]; then
|
||||||
|
log_message "Cleaned up $deleted old dist image archive(s)"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup_docker_dangling_images() {
|
||||||
|
if docker image prune -f >> "$LOG_FILE" 2>&1; then
|
||||||
|
log_message "Cleaned up dangling Docker images"
|
||||||
|
else
|
||||||
|
log_message "WARNING: Could not prune dangling Docker images"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
|
-h, --help Show this help message
|
||||||
EOF
|
EOF
|
||||||
log_message "Recreated missing nginx config at $config_path"
|
}
|
||||||
fi
|
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_message "ERROR: Unknown option: $1"
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
archive_logs() {
|
archive_logs() {
|
||||||
@@ -311,9 +344,7 @@ download_and_extract_bundle() {
|
|||||||
tar -xzf "$archive" -C "$tmp_dir"
|
tar -xzf "$archive" -C "$tmp_dir"
|
||||||
|
|
||||||
# The bundle must contain docker deployment files only.
|
# The bundle must contain docker deployment files only.
|
||||||
mkdir -p "$PROJECT_DIR/docker/nginx"
|
|
||||||
cp -f "$tmp_dir/docker-compose.yml" "$PROJECT_DIR/docker-compose.yml"
|
cp -f "$tmp_dir/docker-compose.yml" "$PROJECT_DIR/docker-compose.yml"
|
||||||
cp -f "$tmp_dir/docker/nginx/default.conf" "$PROJECT_DIR/docker/nginx/default.conf"
|
|
||||||
cp -f "$tmp_dir/start.sh" "$PROJECT_DIR/start.sh"
|
cp -f "$tmp_dir/start.sh" "$PROJECT_DIR/start.sh"
|
||||||
cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh"
|
cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh"
|
||||||
|
|
||||||
@@ -325,25 +356,51 @@ download_and_extract_bundle() {
|
|||||||
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
|
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Neu: Multi-Tenant Ressourcen kopieren, falls vorhanden
|
||||||
|
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
|
||||||
|
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
|
||||||
|
fi
|
||||||
|
if [ -f "$tmp_dir/manage-tenant.sh" ]; then
|
||||||
|
cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh"
|
||||||
|
fi
|
||||||
|
if [ -f "$tmp_dir/run-tenant-cmd.sh" ]; then
|
||||||
|
cp -f "$tmp_dir/run-tenant-cmd.sh" "$PROJECT_DIR/run-tenant-cmd.sh"
|
||||||
|
fi
|
||||||
|
for file in "MULTITENANT_DEPLOYMENT.md" "MULTITENANT_PYTHON_API.md"; do
|
||||||
|
if [ -f "$tmp_dir/$file" ]; then
|
||||||
|
cp -f "$tmp_dir/$file" "$PROJECT_DIR/$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Ensure executable permissions on all copied scripts
|
||||||
|
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/init-admin.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true 2>/dev/null || true
|
||||||
|
chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true
|
||||||
|
|
||||||
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
|
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
|
||||||
cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json"
|
cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json"
|
||||||
log_message "Installed default config.json from release bundle"
|
log_message "Installed default config.json from release bundle"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh"
|
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/init-admin.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
deploy() {
|
deploy() {
|
||||||
local tag="$1"
|
local tag="$1"
|
||||||
local meta_file="$2"
|
local meta_file="$2"
|
||||||
local app_image="${APP_IMAGE_REPO}:${tag}"
|
local app_image="${APP_IMAGE_REPO}:${tag}"
|
||||||
|
local compose_path
|
||||||
|
|
||||||
|
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||||
|
if [ ! -f "$compose_path" ]; then
|
||||||
|
log_message "ERROR: compose file not found: $compose_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
cat > "$ENV_FILE" <<EOF
|
cat > "$ENV_FILE" <<EOF
|
||||||
NUITKA_BUILD=0
|
NUITKA_BUILD=0
|
||||||
INVENTAR_HTTP_PORT=80
|
INVENTAR_HTTP_PORT=10000
|
||||||
INVENTAR_HTTPS_PORT=443
|
|
||||||
INVENTAR_APP_IMAGE=$app_image
|
INVENTAR_APP_IMAGE=$app_image
|
||||||
EOF
|
EOF
|
||||||
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
|
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
|
||||||
@@ -362,27 +419,27 @@ EOF
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
|
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1
|
||||||
docker compose --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||||
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
|
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
verify_stack_health() {
|
verify_stack_health() {
|
||||||
local compose_args running_services
|
local compose_args running_services
|
||||||
local https_port
|
local http_port
|
||||||
compose_args=(--env-file "$ENV_FILE")
|
compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
|
||||||
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
|
http_port="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
|
||||||
if [ -z "$https_port" ]; then
|
if [ -z "$http_port" ]; then
|
||||||
https_port="443"
|
http_port="10000"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for _ in $(seq 1 60); do
|
for _ in $(seq 1 60); do
|
||||||
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
|
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
|
||||||
if printf '%s\n' "$running_services" | grep -Fxq app && \
|
if printf '%s\n' "$running_services" | grep -Fxq app && \
|
||||||
printf '%s\n' "$running_services" | grep -Fxq nginx && \
|
printf '%s\n' "$running_services" | grep -Fxq redis && \
|
||||||
printf '%s\n' "$running_services" | grep -Fxq mongodb; then
|
printf '%s\n' "$running_services" | grep -Fxq mongodb; then
|
||||||
# Primary check: HTTP endpoint responds (most reliable)
|
# Primary check: health endpoint responds (most reliable)
|
||||||
if curl -kfsS "https://127.0.0.1:$https_port" >/dev/null 2>&1; then
|
if curl -fsS "http://127.0.0.1:$http_port/health" >/dev/null 2>&1; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -390,20 +447,41 @@ verify_stack_health() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
docker compose "${compose_args[@]}" ps >> "$LOG_FILE" 2>&1 || true
|
docker compose "${compose_args[@]}" ps >> "$LOG_FILE" 2>&1 || true
|
||||||
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb >> "$LOG_FILE" 2>&1 || true
|
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb >> "$LOG_FILE" 2>&1 || true
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup_server_space() {
|
||||||
|
log_message "Running server cleanup before update..."
|
||||||
|
# Remove unused Docker objects
|
||||||
|
if docker system prune -af --volumes >> "$LOG_FILE" 2>&1; then
|
||||||
|
log_message "Docker system pruned (all unused images, containers, volumes, networks)"
|
||||||
|
else
|
||||||
|
log_message "WARNING: Docker system prune failed"
|
||||||
|
fi
|
||||||
|
# Clean up old dist artifacts
|
||||||
|
cleanup_old_dist_artifacts
|
||||||
|
# Clean up log files older than 7 days
|
||||||
|
if find "$LOG_DIR" -type f -name '*.log' -mtime +7 -exec rm -f {} +; then
|
||||||
|
log_message "Old log files (older than 7 days) cleaned up"
|
||||||
|
else
|
||||||
|
log_message "WARNING: Failed to clean up old log files"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
cleanup_server_space
|
||||||
|
|
||||||
ensure_runtime_dependencies
|
ensure_runtime_dependencies
|
||||||
ensure_tls_certificates
|
|
||||||
ensure_nginx_config_mount_source
|
|
||||||
|
|
||||||
require_cmd curl
|
require_cmd curl
|
||||||
require_cmd tar
|
require_cmd tar
|
||||||
require_cmd docker
|
require_cmd docker
|
||||||
require_cmd python3
|
require_cmd python3
|
||||||
|
|
||||||
|
ensure_min_root_disk_space
|
||||||
archive_logs
|
archive_logs
|
||||||
create_backup
|
create_backup
|
||||||
|
|
||||||
@@ -491,6 +569,8 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$latest_tag" > "$STATE_FILE"
|
echo "$latest_tag" > "$STATE_FILE"
|
||||||
|
cleanup_old_dist_artifacts
|
||||||
|
cleanup_docker_dangling_images
|
||||||
log_message "Update completed successfully to release $latest_tag"
|
log_message "Update completed successfully to release $latest_tag"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user