Compare commits

...

51 Commits

Author SHA1 Message Date
Aiirondev_dev f26ab5c5fb Enhance user retrieval: implement tenant-aware user search with fallback to default and tenant-specific databases 2026-04-28 22:59:14 +02:00
Aiirondev_dev abb09fbdb5 Enhance user authentication: improve tenant database handling and implement fallback user search across tenant databases 2026-04-28 22:26:02 +02:00
Aiirondev_dev 96f5b9e3cd Enhance tutorial functionality: add tooltip toggle feature and improve tooltip display across the system 2026-04-28 21:59:26 +02:00
Aiirondev_dev 3ab2b075da Enhance tenant database management: integrate TenantContext for dynamic database naming and improve database drop functionality 2026-04-28 21:29:06 +02:00
Aiirondev_dev 821d4d0ad8 Enhance user authentication: improve password validation and tenant context handling 2026-04-28 21:21:48 +02:00
Aiirondev_dev 6cc8dabf72 Enhance runtime override generation: add app image retrieval and improve port handling 2026-04-28 20:55:58 +02:00
Aiirondev_dev 0d7fa2c511 Enhance tenant management script: improve port parsing and runtime override generation 2026-04-28 20:38:19 +02:00
Aiirondev_dev 4a18460178 Enhance tenant management script: add functionality to remove tenant ports and update runtime port configuration 2026-04-28 19:00:08 +02:00
Aiirondev_dev d2b3d4f6ea Enhance tenant management script: improve tenant listing functionality and handle missing config file 2026-04-28 18:44:47 +02:00
Aiirondev_dev f3d327f28f Enhance tenant management scripts: add runtime port update functionality and handle occupied ports 2026-04-28 18:18:04 +02:00
Aiirondev_dev 2b05d8c952 Refactor manage-tenant script: improve parameter handling and ensure default values 2026-04-28 17:54:43 +02:00
Aiirondev_dev 6cb41c1277 Enhance tenant management script: add help option to display usage instructions 2026-04-28 17:49:44 +02:00
Aiirondev_dev f0d02d6af1 Refactor restart script: streamline Docker compose argument handling and improve readability 2026-04-28 17:20:10 +02:00
Aiirondev_dev 6f5e24104b Refactor restart script: improve Docker container restart logic and update completion messages 2026-04-28 17:06:37 +02:00
Aiirondev_dev 43406c29d1 Enhance tenant management script: switch to bash, enforce strict mode, and validate port input 2026-04-28 16:54:42 +02:00
Aiirondev_dev 7873c45cfc Refactor multi-tenant deployment: update port handling in scripts, add tenant port registration, and enhance Docker configurations
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 16:36:55 +02:00
Aiirondev_dev 14c4192306 Refactor Docker setup: remove Nginx and TLS configurations, update service dependencies, and adjust health check endpoints
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 15:41:10 +02:00
Aiirondev_dev 1efc7e01be Update HTTP and HTTPS port configuration in .docker-build.env
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:38:44 +02:00
Aiirondev_dev d567ba583b Update port configuration for multi-tenant deployment to avoid conflicts
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 13:21:34 +02:00
Aiirondev_dev 5a5af5375d Add missing Nginx configuration for SSL and proxy settings
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 11:17:33 +02:00
Aiirondev_dev a60eb6eebf Update health check endpoint in verify_stack_health function in start.sh and update.sh
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 10:55:29 +02:00
Aiirondev_dev 23b7a4cd2f Remove obsolete configuration file config.yml 2026-04-26 21:38:02 +02:00
Aiirondev_dev e6fd485049 Update service configuration in config.yml and simplify cloudflared command in docker-compose 2026-04-26 17:01:20 +02:00
Aiirondev_dev 15d9eba987 Fix server block syntax in Nginx configuration in update.sh 2026-04-26 16:34:42 +02:00
Aiirondev_dev 05d6d299da Enhance scheduler lock file handling and add health check endpoint 2026-04-26 16:17:29 +02:00
Aiirondev_dev ab9db1211c Add server cleanup function to update.sh for Docker and log management
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 15:51:14 +02:00
Aiirondev_dev 45b69e5ddb Update HTTPS port configuration and add cloudflared setup in Docker Compose 2026-04-26 15:35:29 +02:00
Aiirondev_dev 4971bc859b Add disk space checks and cleanup functions in start.sh and update.sh 2026-04-24 21:29:39 +02:00
Aiirondev_dev b7f55b0de0 Remove custom command for MongoDB service in Docker Compose 2026-04-24 21:18:53 +02:00
Aiirondev_dev 9c24e79bba Add retry mechanism for Docker Compose startup in start.sh 2026-04-24 21:13:31 +02:00
Aiirondev_dev 79c325329c Refactor cloudflared configuration to use external config file and update command syntax 2026-04-24 21:05:21 +02:00
Aiirondev_dev 8f81ffb4c5 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-24 20:58:09 +02:00
Aiirondev_dev 1d7692ea01 Add cloudflared service and configure tunnel profile in Docker Compose 2026-04-24 20:56:15 +02:00
Aiirondev_dev 038390b8cd Add multi-tenant configuration support with dynamic module enabling 2026-04-24 09:16:04 +02:00
Aiirondev_dev f2c1dc2ba5 Implement session validation for active users before request processing
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:00:22 +02:00
Aiirondev_dev 3eeae76e6c Remove Web directory volume mount from Docker Compose files for app service 2026-04-23 22:48:23 +02:00
Aiirondev_dev 0228e6cb1d Add working directory and command configuration for app service in Docker Compose files 2026-04-23 22:41:52 +02:00
Aiirondev_dev 4c59cca1a4 Add missing nginx configuration for SSL support and error handling 2026-04-23 22:36:01 +02:00
Aiirondev_dev 8412ae76ee Fix volume mount for Web directory in Docker Compose files: remove read-only flag for better accessibility 2026-04-23 22:32:01 +02:00
Aiirondev_dev ec4483c415 Enhance backup and restore scripts: update database name and add support for multi-tenant configurations 2026-04-23 22:26:54 +02:00
Aiirondev_dev 52656c715e Enhance mobile library loading: implement virtualization for improved performance and memory management on mobile devices
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 22:05:52 +02:00
Aiirondev_dev f6755aad42 Enhance mobile library loading: implement dynamic item rendering and lazy loading for improved performance on mobile devices 2026-04-23 21:50:49 +02:00
Aiirondev_dev cbbcc09fc2 Refactor navbar and search form styles for improved layout: adjust flex properties and widths for better responsiveness 2026-04-22 19:37:39 +02:00
Aiirondev_dev aa8912e8b7 Enhance navbar functionality: add data attribute for fixed navigation items and improve overflow detection logic 2026-04-22 17:34:19 +02:00
Aiirondev_dev 68488598af Enhance navigation links for borrowed items and tutorials: add conditional rendering for 'Meine Ausleihen' and 'Tutorial' links based on user permissions 2026-04-22 17:26:44 +02:00
Aiirondev_dev 6e8bb8236e Enhance navbar overflow functionality: refactor initNavbarOverflow to accept options for more toggle ID and improve handling of hidden navigation items 2026-04-22 16:50:46 +02:00
Aiirondev_dev bd92d60d08 Refine navbar styles for improved responsiveness: adjust padding, gap, and font sizes for various screen sizes 2026-04-22 16:39:29 +02:00
Aiirondev_dev 4991587d99 Enhance safe area support for iOS devices: update CSS for navbar and body to respect safe area insets and improve layout on mobile screens 2026-04-22 16:25:45 +02:00
Aiirondev_dev 2b8bf0b7d5 Update Docker configuration and scripts for multi-tenant support: change app image, adjust port settings, and clean up logs 2026-04-21 23:20:42 +02:00
Aiirondev_dev 9ae316cdd9 Enhance multi-tenant support: update scripts to handle multi-tenant Docker compose files and secure admin credentials 2026-04-21 16:57:43 +02:00
Aiirondev_dev 12f62e5146 Change Docker image for tenant-manager service and update entrypoint to use sh; modify shebang in manage-tenant.sh to use sh 2026-04-21 16:26:48 +02:00
29 changed files with 1839 additions and 588 deletions
+2 -3
View File
@@ -1,4 +1,3 @@
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:latest
INVENTAR_HTTP_PORT=10000
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
+6
View File
@@ -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
+33 -32
View File
@@ -158,42 +158,17 @@ jobs:
- name: Create release-only docker bundle
run: |
mkdir -p release-bundle
mkdir -p release-bundle/docker/nginx
cat > release-bundle/docker-compose.yml <<EOF
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:
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:latest}
pull_policy: never
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
container_name: inventarsystem-app
restart: unless-stopped
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
depends_on:
mongodb:
condition: service_healthy
- mongodb
- redis
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
@@ -211,6 +186,33 @@ jobs:
- app_backups:/data/backups
- 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:
mongodb_data:
app_uploads:
@@ -219,9 +221,9 @@ jobs:
app_qrcodes:
app_backups:
app_logs:
redis_data:
EOF
cp docker/nginx/default.conf release-bundle/docker/nginx/default.conf
cp start.sh release-bundle/start.sh
cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh
@@ -232,7 +234,6 @@ jobs:
# Multitenant scripts & docs
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
cp docker/nginx/multitenant.conf release-bundle/docker/nginx/multitenant.conf
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
+1 -1
View File
@@ -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 │
│ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │
│ 20 Users │ │ 20 Users │ │ 20 Users │
+26
View File
@@ -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
**Fragen?**
+38 -4
View File
@@ -339,6 +339,28 @@ def _enforce_user_permissions():
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():
"""Return a cache-busting asset version tied to deployment state."""
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
os.remove(scheduler_lock_path)
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
except Exception:
pass # If we can't clean up, continue anyway
except Exception as e:
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 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)
@@ -1302,7 +1332,7 @@ def _initialize_scheduler():
except FileExistsError:
should_start = False
app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
if should_start:
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)
@@ -10784,6 +10814,10 @@ def get_optimal_image_quality(img, target_size_kb=80):
# PUSH NOTIFICATION API ENDPOINTS
# ============================================================================
@app.route('/health')
def health_check():
return 'OK', 200
@app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push():
"""
-1
View File
@@ -1 +0,0 @@
[2026-04-19 20:20:39,502] ERROR in app: Error during scheduler shutdown: name '__file__' is not defined
+2 -1
View File
@@ -13,5 +13,6 @@ redis
reportlab
python-barcode
openpyxl
cryptography
cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
+30 -2
View File
@@ -157,8 +157,36 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
# Optional feature modules
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
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_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
+59
View File
@@ -34,6 +34,25 @@
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
}
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
html {
/* Ensure viewport-fit is respected */
viewport-fit: cover;
}
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,
@@ -172,11 +191,16 @@ select:focus {
box-shadow: none;
-webkit-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 {
font-weight: 800;
letter-spacing: 0.02em;
/* Prevent navbar brand from extending into unsafe areas */
word-break: break-word;
}
@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-button, .duplicate-button, .generate-qr-button {
color: var(--ui-text) !important; /* Override any existing color */
+298 -86
View File
@@ -169,31 +169,163 @@
background-color: var(--module-primary-color) !important;
position: sticky;
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;
}
.navbar .container-fluid {
gap: 10px;
padding-bottom: env(safe-area-inset-bottom, 0); /* Fallback */
gap: 8px;
/* 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 {
font-weight: bold;
font-size: 1.4rem;
font-size: 1.3rem;
padding-right: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.navbar-brand::before {
content: attr(data-icon);
margin-right: 8px;
margin-right: 6px;
}
.navbar-nav .nav-link {
font-size: 1rem;
font-size: 0.95rem;
font-weight: 600;
padding: 0.6rem 0.85rem;
border-radius: 8px;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: padding .18s ease, font-size .18s ease;
white-space: nowrap;
}
body.tutorial-tooltips-active [data-tutorial-tip] {
position: relative;
border-radius: 10px;
outline: 1px dashed rgba(59, 130, 246, 0.35);
outline-offset: 2px;
cursor: help;
}
body.tutorial-tooltips-active [data-tutorial-tip]::before {
content: '💡';
position: absolute;
top: 50%;
right: -26px;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(59, 130, 246, 0.15);
color: #0f172a;
font-size: 0.75rem;
pointer-events: none;
}
body.tutorial-tooltips-active [data-tutorial-tip]::after {
content: attr(data-tutorial-tip);
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(6px);
min-width: 220px;
max-width: 280px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(15, 23, 42, 0.92);
color: #f8fafc;
font-size: 0.9rem;
line-height: 1.4;
text-align: left;
opacity: 0;
visibility: hidden;
pointer-events: none;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.22);
z-index: 9999;
white-space: normal;
}
body.tutorial-tooltips-active [data-tutorial-tip]:hover::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(10px);
}
.tutorial-tooltip-indicator {
position: fixed;
top: 92px;
right: 16px;
z-index: 2000;
display: none;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.25);
color: #0f172a;
font-size: 0.95rem;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
}
body.tutorial-tooltips-active .tutorial-tooltip-indicator {
display: inline-flex;
}
/* 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 {
@@ -250,6 +382,7 @@
align-items: center;
margin-right: 10px;
width: min(420px, 42vw);
min-width: 0;
}
.function-search-form {
@@ -257,10 +390,13 @@
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
}
.function-search-input {
width: 100%;
flex: 1 1 auto;
width: auto;
min-width: 0;
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
@@ -281,6 +417,7 @@
}
.function-search-btn {
flex: 0 0 auto;
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.45);
@@ -483,9 +620,48 @@
margin-top: 4px;
}
.navbar-collapse {
width: 100%;
}
.navbar-collapse > .d-flex {
width: 100%;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.function-search-wrap {
width: 100%;
max-width: 100%;
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 {
@@ -852,29 +1028,27 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
{% if current_permissions.pages.get('home', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}" data-tutorial-tip="Starten Sie hier mit dem Materialbestand und suchen Sie nach Artikeln.">Artikel</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true">
<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 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') }}" data-tutorial-tip="Ihre aktuellen Ausleihen und Rückgaben finden Sie hier.">Meine Ausleihen</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true">
<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 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') }}" data-tutorial-tip="Öffnen Sie das Tutorial, um das System Schritt für Schritt kennenzulernen.">Tutorial</a>
</li>
{% endif %}
{% endif %}
{% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item">
<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') }}" data-tutorial-tip="Hier können Sie neue Artikel ins Inventar einpflegen."> Hochladen</a>
</li>
{% endif %}
<li class="nav-item">
<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>
@@ -886,15 +1060,6 @@
Mehr Optionen
</a>
<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) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %}
@@ -982,29 +1147,27 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
{% if current_permissions.pages.get('library_view', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Medien</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true">
<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 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') }}" data-tutorial-tip="Hier sehen Sie Ihre bibliotheksbezogenen Ausleihen.">Meine Medien</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true">
<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 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') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
</li>
{% endif %}
{% endif %}
{% if 'username' in session and current_permissions.actions.get('can_insert', True) and current_permissions.pages.get('library_admin', True) %}
<li class="nav-item">
<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') }}" data-tutorial-tip="Neue Bibliotheksmedien können hier aufgenommen werden.">📖 Hochladen</a>
</li>
{% endif %}
<li class="nav-item">
<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>
@@ -1016,15 +1179,6 @@
Mehr Optionen
</a>
<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) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', True) %}
@@ -1602,8 +1756,15 @@
const libraryNavList = document.getElementById('libraryNavList');
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
function initNavbarOverflow(navList, navOverflowAnchor) {
if (!navList || !navOverflowAnchor) return;
function initNavbarOverflow(navList, navOverflowAnchor, options) {
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 navCollapse = navList.closest('.navbar-collapse');
@@ -1631,45 +1792,35 @@
return Array.from(navList.children).filter(function(li){
if (!(li instanceof HTMLElement)) 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.navFixed === 'true') return false;
if (moreControlItem && li === moreControlItem) return false;
return li.style.display !== 'none';
});
}
function clearOverflowControls() {
if (!navList) return;
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){
node.remove();
});
if (!moreMenu) return;
moreMenu.innerHTML = moreMenuOriginal;
if (moreControlItem) {
moreControlItem.style.display = '';
}
if (moreToggle) {
moreToggle.textContent = moreLabelDefault;
}
}
function restoreAllNavItems() {
if (!navList) return;
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;
li.style.display = '';
});
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) {
if (!(item instanceof HTMLElement)) {
return -1;
@@ -1752,33 +1903,70 @@
function rebuildOverflowControl(hiddenSources) {
clearOverflowControls();
if (!navList || !navOverflowAnchor || 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 + ')';
}
if (!moreMenu || hiddenSources.length === 0) return;
const overflowFragment = document.createDocumentFragment();
hiddenSources.forEach(function(source){
appendSourceToOverflowMenu(source, menu);
appendSourceToOverflowMenu(source, overflowFragment);
});
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider');
if (trailingDivider) {
const overflowWrap = document.createElement('div');
overflowWrap.appendChild(overflowFragment);
const trailingDivider = overflowWrap.querySelector('li:last-child .dropdown-divider');
if (trailingDivider && trailingDivider.parentElement) {
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() {
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();
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();
return;
}
@@ -1787,8 +1975,9 @@
const hiddenSources = [];
let candidates = collectTopLevelNavSources();
const overflowBuffer = 12;
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
@@ -1800,8 +1989,9 @@
rebuildOverflowControl(hiddenSources);
// One final pass in case the overflow menu label/count itself changed row width.
candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
@@ -1839,12 +2029,12 @@
// Initialize overflow control for inventory navbar
if (navList && navOverflowAnchor) {
initNavbarOverflow(navList, navOverflowAnchor);
initNavbarOverflow(navList, navOverflowAnchor, { moreToggleId: 'invMoreDropdown' });
}
// Initialize overflow control for library navbar
if (libraryNavList && libNavOverflowAnchor) {
initNavbarOverflow(libraryNavList, libNavOverflowAnchor);
initNavbarOverflow(libraryNavList, libNavOverflowAnchor, { moreToggleId: 'libMoreDropdown' });
}
})();
</script>
@@ -1885,6 +2075,28 @@
});
});
</script>
<script>
(function () {
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled';
const enabled = localStorage.getItem(tooltipModeKey) === '1';
function applyTooltipMode(active) {
document.body.classList.toggle('tutorial-tooltips-active', active);
let indicator = document.getElementById('tutorialTooltipIndicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'tutorialTooltipIndicator';
indicator.className = 'tutorial-tooltip-indicator';
indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet';
document.body.appendChild(indicator);
}
indicator.style.display = active ? 'inline-flex' : 'none';
}
applyTooltipMode(enabled);
})();
</script>
</body>
</html>
+237 -1
View File
@@ -208,12 +208,23 @@
<script>
// View mode persistence
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) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer');
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 => {
const cardContent = item.querySelector('.item-content.card-mode');
@@ -236,6 +247,229 @@ function setViewMode(mode) {
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
document.addEventListener('DOMContentLoaded', function() {
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';
});
});
initMobileWindowedLibraryLoading();
});
function displayLibraryItemDetail(item) {
+63
View File
@@ -189,6 +189,38 @@
font-size: 1rem;
}
.tutorial-tooltip-toggle {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 20px;
padding: 16px;
border-radius: 18px;
background: rgba(224, 242, 254, 0.9);
border: 1px solid #bae6fd;
}
.tutorial-tooltip-toggle label {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
color: #0f172a;
}
.tutorial-tooltip-toggle input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: #2563eb;
}
.tooltip-description {
margin: 0;
color: #475569;
font-size: 0.95rem;
line-height: 1.5;
}
.workflow-controls {
display: flex;
gap: 8px;
@@ -230,6 +262,13 @@
<span class="tutorial-pill">Format: Ruhig und geführt</span>
<span class="tutorial-pill">Benutzer: {{ username }}</span>
</div>
<div class="tutorial-tooltip-toggle">
<label for="toggleTutorialTooltips">
<input type="checkbox" id="toggleTutorialTooltips" />
Tutorial-Tooltips für das gesamte System aktivieren
</label>
<p class="tooltip-description">Wenn aktiviert, erscheinen auf allen Seiten erklärende Hinweise und Icons für die wichtigsten Navigationselemente.</p>
</div>
</section>
<div class="tutorial-grid">
@@ -438,8 +477,32 @@
renderStep();
});
const tooltipToggle = document.getElementById('toggleTutorialTooltips');
const tooltipModeKey = 'inventarsystem_tutorial_tooltips_enabled_{{ username }}';
function setTooltipMode(enabled) {
localStorage.setItem(tooltipModeKey, enabled ? '1' : '0');
document.body.classList.toggle('tutorial-tooltips-active', enabled);
}
function loadTooltipMode() {
const saved = localStorage.getItem(tooltipModeKey);
const enabled = saved === '1';
if (tooltipToggle) {
tooltipToggle.checked = enabled;
}
setTooltipMode(enabled);
}
if (tooltipToggle) {
tooltipToggle.addEventListener('change', function () {
setTooltipMode(this.checked);
});
}
loadState();
renderStep();
loadTooltipMode();
})();
</script>
{% endblock %}
+93 -4
View File
@@ -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 functools import wraps
import logging
import os
import re
import settings as cfg
logger = logging.getLogger(__name__)
# Tenant registry: maps subdomain/tenant_id to database name
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:
"""
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):
self.tenant_id = None
self.db_name = None
self.subdomain = None
self.port = None
self.config = {}
def resolve_tenant(self):
"""
Resolve tenant from request context.
Priority: Header > Subdomain > Default
Priority: Header > Port mapping > Subdomain > Default
"""
if not has_request_context():
return None
@@ -40,10 +116,21 @@ class TenantContext:
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if 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)
# Priority 2: Subdomain extraction
# Priority 2: Port-based tenant mapping
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('.')
# Extract subdomain from host
@@ -56,10 +143,12 @@ class TenantContext:
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain
self.config = get_tenant_config(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.config = get_tenant_config('default')
return self._get_db_name('default')
def _get_db_name(self, tenant_id):
+68 -12
View File
@@ -417,13 +417,41 @@ def check_nm_pwd(username, password):
Returns:
dict: User document if credentials are valid, None otherwise
"""
db_name = cfg.MONGODB_DB
tenant_db = None
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.tenant_id:
tenant_db = ctx.db_name or ctx.resolve_tenant()
db_name = tenant_db
except Exception:
pass
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
hashed_password = hashlib.sha512(password.encode()).hexdigest()
user = users.find_one({'Username': username, 'Password': hashed_password})
client.close()
return user
try:
hashed_password = hashing(password)
def find_user_in_db(database_name):
db = client[database_name]
users = db['users']
return users.find_one({'Username': username, 'Password': hashed_password}) or users.find_one({'username': username, 'Password': hashed_password})
user = find_user_in_db(db_name)
if user:
return user
# Fallback: tenant context may not be available yet, so search all tenant databases.
for database_name in client.list_database_names():
if database_name == db_name or not database_name.startswith('inventar_'):
continue
user = find_user_in_db(database_name)
if user:
return user
finally:
client.close()
return None
def add_user(
@@ -471,7 +499,7 @@ def add_user(
'Admin': False,
'active_ausleihung': None,
'name': name_alias,
'last_name': '',
'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
@@ -564,11 +592,39 @@ def get_user(username):
dict: User document or None if not found
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
users_return = users.find_one({'Username': username})
client.close()
return users_return
try:
def find_in_db(database_name):
db = client[database_name]
users = db['users']
return users.find_one({'Username': username}) or users.find_one({'username': username})
# Try current tenant first when available
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
user = find_in_db(ctx.db_name)
if user:
return user
except Exception:
pass
# Fallback to default configured database
user = find_in_db(cfg.MONGODB_DB)
if user:
return user
# Last fallback: search all tenant databases if the user belongs to a tenant-specific DB
for database_name in client.list_database_names():
if database_name == cfg.MONGODB_DB or not database_name.startswith('inventar_'):
continue
user = find_in_db(database_name)
if user:
return user
return None
finally:
client.close()
def check_admin(username):
+10 -3
View File
@@ -33,11 +33,12 @@ LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
KEEP_DAYS="${KEEP_DAYS:-7}"
MIN_KEEP="${MIN_KEEP:-7}"
DB_NAME="${DB_NAME:-Inventarsystem}"
DB_NAME="${DB_NAME:-inventar_default}"
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
BACKUP_MODE="${BACKUP_MODE:-auto}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=()
USE_NULL_OUTPUT=false
@@ -58,6 +59,8 @@ Options:
--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)
--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
EOF
}
@@ -87,6 +90,10 @@ while [[ $# -gt 0 ]]; do
MIN_KEEP="$2"; shift 2;;
--mode)
BACKUP_MODE="$2"; shift 2;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"; shift;;
-h|--help)
usage; exit 0;;
*)
@@ -102,12 +109,12 @@ log_message() {
init_docker_compose() {
if docker compose version >/dev/null 2>&1; then
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
fi
if sudo docker compose version >/dev/null 2>&1; then
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
fi
+4 -2
View File
@@ -1,9 +1,9 @@
{
"dbg": false,
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
"ver": "0.0.2",
"ver": "0.6.44",
"host": "0.0.0.0",
"port": 443,
"port": 8000,
"mongodb": {
"host": "localhost",
@@ -50,6 +50,8 @@
}
},
"tenants": {},
"allowed_extensions": [
"png", "jpg", "jpeg", "gif",
"hevc", "heif",
+11 -41
View File
@@ -1,19 +1,17 @@
# 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
#
# Usage:
# docker-compose -f docker-compose-multitenant.yml up -d
#
# Scale example: To support 10 tenants with 20 users each:
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
version: '3.9'
# Example: Start the stack and expose the app on host port 10000
# INVENTAR_HTTP_PORT=10000 docker-compose -f docker-compose-multitenant.yml up -d
services:
# Management Container for multi-tenant scripts
tenant-manager:
image: bash
image: docker:cli
container_name: tenant-manager
restart: "no"
profiles:
@@ -21,34 +19,7 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/workspace
entrypoint: ["bash", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
app:
condition: service_started
redis:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/multitenant.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
- ./docker/nginx/acme:/etc/nginx/acme:ro
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
entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
redis:
image: redis:7-alpine
@@ -71,9 +42,8 @@ services:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
command: mongod --wiredTigerCacheSizeGB 2 --journal --dbPath /data/db
ports:
- "27017:27017"
expose:
- "27017"
volumes:
- mongodb_data:/data/db
- ./docker/mongo:/docker-entrypoint-initdb.d:ro
@@ -94,6 +64,8 @@ services:
args:
PYTHON_VERSION: "3.13"
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
security_opt:
- no-new-privileges:true
@@ -102,13 +74,14 @@ services:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
networks:
- inventar-net
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads:cached
- app_thumbnails:/app/Web/thumbnails:cached
- app_previews:/app/Web/previews:cached
@@ -193,9 +166,6 @@ volumes:
networks:
inventar-net:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
# Scale guidance:
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
+26 -72
View File
@@ -1,87 +1,41 @@
version: "3.8"
services:
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
app:
build: .
container_name: inventory-app
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:
app:
condition: service_started
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
- redis
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
image: mongo:latest
container_name: 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
- mongo_data:/data/db
redis:
image: redis:7-alpine
container_name: inventarsystem-redis
image: redis:alpine
container_name: redis
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:
build:
context: .
dockerfile: Dockerfile
container_name: inventarsystem-app
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
security_opt:
- no-new-privileges:true
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"
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
command: tunnel run homeserver
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
- app_deleted_archives:/data/deleted-archives
- ./config.yml:/etc/cloudflared/config.yml
- ./credentials.json:/etc/cloudflared/credentials.json
depends_on:
- app
volumes:
mongodb_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
app_deleted_archives:
redis_data:
mongo_data:
+8
View File
@@ -0,0 +1,8 @@
tunnel: homeserver
credentials-file: /etc/cloudflared/credentials.json
ingress:
- service: https://nginx:443
originRequest:
noTLSVerify: true
- service: http_status:404
+5 -4
View File
@@ -37,7 +37,8 @@ upstream inventar_backend {
# Main HTTPS server block
server {
listen 443 ssl http2;
listen 443 ssl;
http2 on;
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
# SSL Configuration (wildcard certificate)
@@ -75,9 +76,9 @@ server {
application/x-javascript application/xml+rss application/json;
gzip_comp_level 5;
# Access/Error logs with tenant in filename
access_log /var/log/nginx/inventar_access_${tenant}.log combined buffer=32k;
error_log /var/log/nginx/inventar_error_${tenant}.log warn;
# Access/Error logs
access_log /var/log/nginx/inventar_access.log combined buffer=32k;
error_log /var/log/nginx/inventar_error.log warn;
# Root location - proxy to app with tenant context
location / {
+29 -10
View File
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
CLEANUP_OLD_SERVICES=true
CLEANUP_OLD_REMOVE_CRON=false
TMP_DIR=""
COMPOSE_FILE="docker-compose-multitenant.yml"
cleanup_tmp_dir() {
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
@@ -50,14 +51,13 @@ refresh_start_script_from_main() {
pin_compose_app_image() {
local tag="$1"
local compose_file
compose_file="$PROJECT_DIR/docker-compose.yml"
if [ ! -f "$compose_file" ]; then
echo "Warning: $compose_file not found; cannot pin app image"
return 0
fi
for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
if [ ! -f "$compose_file" ]; then
continue
fi
python3 - <<'PY' "$compose_file" "$tag"
python3 - <<'PY' "$compose_file" "$tag"
import re
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:
f.writelines(out)
PY
done
}
install_docker_if_missing() {
@@ -173,6 +175,8 @@ Options:
--remove-legacy-system Remove old host MongoDB/system after successful import
--skip-cleanup-old Do not run cleanup-old.sh after install
--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-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--legacy-system-dir <path> Optional old system directory to remove after migration
@@ -199,6 +203,14 @@ parse_args() {
CLEANUP_OLD_REMOVE_CRON=true
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--legacy-db-name)
LEGACY_DB_NAME="$2"
shift 2
@@ -280,14 +292,21 @@ backup_legacy_database() {
restore_legacy_backup_into_docker() {
local i
local compose_path
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
return 0
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..."
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
fi
sleep 2
@@ -299,7 +318,7 @@ restore_legacy_backup_into_docker() {
fi
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."
}
@@ -454,8 +473,8 @@ EOF
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
cat > "$TMP_DIR/.docker-build.env" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=10001
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
EOF
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
+315 -30
View File
@@ -1,4 +1,6 @@
#!/bin/bash
#!/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
@@ -7,52 +9,292 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
exit 1
fi
CONFIG_FILE="$PWD/config.json"
show_help() {
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
echo ""
echo "Commands:"
echo " add <tenant_id> 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 " 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"
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
}
if [ -z "$1" ]; then
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
}
remove_tenant_port() {
local tenant_id="$1"
local config_path="$CONFIG_FILE"
local port
port="$(python3 - "$config_path" "$tenant_id" <<'PY'
import json, sys, os
path, tenant_id = sys.argv[1], sys.argv[2]
if not os.path.isfile(path):
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict) or tenant_id not in tenants or not isinstance(tenants[tenant_id], dict):
sys.exit(2)
removed = tenants.pop(tenant_id, None)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
if removed is not None:
port = removed.get('port')
if port is not None:
print(port)
PY
)"
local status=$?
if [ $status -eq 1 ]; then
echo "Error: config file not found: $config_path"
return 1
fi
if [ $status -eq 2 ]; then
return 2
fi
if [ -n "$port" ]; then
echo "$port"
fi
return 0
}
remove_runtime_port() {
local removed_port="$1"
local env_file="$PWD/.docker-build.env"
local current_ports=""
local port_list=()
local new_ports=()
local first_port=""
if [ ! -f "$env_file" ]; then
return 0
fi
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
if [ -z "$current_ports" ]; then
return 0
fi
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
for port in "${port_list[@]}"; do
if [ -n "$port" ] && [ "$port" != "$removed_port" ]; then
new_ports+=("$port")
fi
done
if [ ${#new_ports[@]} -eq 0 ]; then
sed -i '/^INVENTAR_HTTP_PORTS=/d;/^INVENTAR_HTTP_PORT=/d' "$env_file"
return 0
fi
first_port="${new_ports[0]}"
local ports_csv="$(IFS=,; echo "${new_ports[*]}")"
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
}
if [ -z "${1:-}" ]; then
show_help
fi
COMMAND=$1
TENANT_ID=$2
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
echo "Adding new tenant '$TENANT_ID'..."
# Add Nginx configuration
if [ -f "docker/nginx/multitenant.conf" ]; then
echo "Assuming dynamic routing based on subdomain ($TENANT_ID)..."
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 sys, re; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
tenant_id = sys.argv[1].lower()
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
db_name = f'inventar_{sanitized}'
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db.users.insert_one({'username': 'admin', 'password': 'hashed_password_here', 'role': 'admin'})
print(f'Tenant {sys.argv[1]} database initialized.')
db = client[db_name]
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
if db.users.count_documents({'Username': 'admin'}) == 0:
db.users.insert_one({
'Username': 'admin',
'Password': hashed_pw,
'Admin': True,
'active_ausleihung': None,
'name': 'Admin',
'last_name': 'User',
'IsStudent': False,
'PermissionPreset': 'full_access',
'ActionPermissions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
'can_delete': True,
'can_manage_users': True,
'can_manage_settings': True,
'can_view_logs': True,
},
'PagePermissions': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': True,
'upload_admin': True,
'library_admin': True,
'admin_borrowings': True,
'library_loans_admin': True,
'admin_damaged_items': True,
'admin_audit_dashboard': True,
'logs': True,
'manage_filters': True,
'manage_locations': True,
},
})
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
else
@@ -73,14 +315,28 @@ print(f'Tenant {sys.argv[1]} database initialized.')
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 sys; sys.path.insert(0, '/app/Web'); from tenant import TenantContext; import settings; from pymongo import MongoClient
ctx = TenantContext()
db_name = ctx._get_db_name(sys.argv[1])
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
client.drop_database(f'{settings.MONGODB_DB}_{sys.argv[1]}')
client.drop_database(db_name)
print(f'Database for tenant {sys.argv[1]} dropped.')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' removed successfully."
echo "Tenant '$TENANT_ID' database removed."
else
echo "Error: Application container not running."
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
fi
port_to_remove=""
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove"
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
else
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
fi
else
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi
else
echo "Removal canceled."
@@ -117,17 +373,46 @@ print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
;;
list)
echo "Listing active tenants (Databases):"
echo "Listing configured tenants (config.json):"
if [ -f "$CONFIG_FILE" ]; then
python3 - "$CONFIG_FILE" <<'PY'
import json, sys
path = sys.argv[1]
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if isinstance(tenants, dict) and tenants:
for tid, conf in tenants.items():
port = conf.get('port') if isinstance(conf, dict) else None
if port is not None:
print(f'- {tid} (port {port})')
else:
print(f'- {tid}')
else:
print(' (no tenants configured)')
PY
else
echo " (config.json not found)"
fi
echo ""
echo "Listing active tenant databases (MongoDB):"
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
docker exec -i "$APP_CONTAINER" python3 - <<'PY'
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}_'
prefix = 'inventar_'
dbs = [d for d in client.list_database_names() if d.startswith(prefix)]
for db in dbs:
print(f'- {db.replace(prefix, \"\")}')
"
if dbs:
for db in dbs:
print(f'- {db.replace(prefix, "")}')
else:
print(' (no tenant databases found)')
PY
else
echo "Error: Application container not running."
fi
+2 -1
View File
@@ -12,5 +12,6 @@ redis
reportlab
python-barcode
openpyxl
cryptography
cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
+106 -4
View File
@@ -1,7 +1,109 @@
#!/bin/bash
#!/usr/bin/env bash
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"
"$SCRIPT_DIR/start.sh"
COMPOSE_FILE="docker-compose-multitenant.yml"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
parse_port_list() {
local raw="$1"
local port
local ports=()
raw="${raw//,/ }"
for port in $raw; do
port="${port//[[:space:]]/}"
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
ports+=("$port")
fi
done
printf '%s\n' "${ports[@]}"
}
write_runtime_override() {
local current_ports=""
local app_image=""
local ports=()
if [ -f "$ENV_FILE" ]; then
app_image="$(awk -F= '/^INVENTAR_APP_IMAGE=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
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 "$app_image" ]; then
if [ -n "$current_ports" ]; then
mapfile -t ports < <(parse_port_list "$current_ports")
fi
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
services:
app:
image: $app_image
build: null
EOF
if [ ${#ports[@]} -gt 1 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports:
EOF
for port in "${ports[@]}"; do
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000"
EOF
done
fi
else
rm -f "$RUNTIME_COMPOSE_OVERRIDE_FILE"
fi
}
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..."
write_runtime_override
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
View File
@@ -6,12 +6,13 @@ LOG_DIR="$SCRIPT_DIR/logs"
LOG_FILE="$LOG_DIR/restore.log"
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}"
DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
SOURCE_PATH=""
BACKUP_DATE=""
DROP_DATABASE=false
RESTART_SERVICES=false
STAGED_PATH=""
COMPOSE_FILE="docker-compose-multitenant.yml"
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
BACKUP_ROOT_SYSTEM="/var/backups"
@@ -56,6 +57,8 @@ Options:
- latest: newest from local/system backup roots
--drop-database Drop target DB before import (recommended for full restore)
--restart-services Restart stack after restore
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--list List detected backup candidates
--help Show this help
@@ -68,12 +71,12 @@ EOF
setup_compose() {
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
fi
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
fi
@@ -459,6 +462,14 @@ parse_args() {
RESTART_SERVICES=true
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--list)
list_backups
exit 0
+193 -186
View File
@@ -7,17 +7,27 @@ cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$SCRIPT_DIR/dist"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="80"
HTTPS_PORT_VALUE="443"
HTTP_PORT_VALUE="10000"
HTTP_PORTS_VALUE=""
DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
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() {
cat <<EOF
@@ -26,6 +36,8 @@ Usage: $0 [options]
Options:
--no-cron Do not create or update cron jobs
--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
EOF
}
@@ -41,6 +53,14 @@ parse_args() {
CRON_SETUP_VALUE="1"
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
-h|--help)
usage
exit 0
@@ -100,7 +120,11 @@ ensure_runtime_dependencies() {
local missing=()
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
if ! docker compose version >/dev/null 2>&1; then
@@ -124,14 +148,20 @@ ensure_runtime_dependencies() {
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
if [ "$IS_ROOT" = "true" ]; then
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
if command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || true
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
}
@@ -144,6 +174,11 @@ setup_boot_autostart_service() {
return 0
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
return 0
fi
@@ -185,6 +220,11 @@ setup_scheduled_jobs() {
return 0
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
echo "Warning: crontab not available, skipping nightly update setup"
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"
local existing_cron
if [ "$(id -u)" -eq 0 ]; then
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
echo "$backup_line"
echo "$update_line"
} | 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
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
echo "$backup_line"
echo "$update_line"
} | crontab -
echo "Nightly backup scheduled at 02:30"
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() {
local config_path backup_path
config_path="$SCRIPT_DIR/config.json"
@@ -306,7 +262,7 @@ ensure_runtime_config_json() {
"ver": "2.6.5",
"dbg": false,
"host": "0.0.0.0",
"port": 443,
"port": 8000,
"mongodb": {
"host": "mongodb",
"port": 27017,
@@ -445,97 +401,106 @@ find_free_port() {
echo "$port"
}
stack_owns_host_port() {
local requested_port="$1"
local container_port="$2"
local mapped_port
mapped_port="$(docker compose --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
if [ -z "$mapped_port" ]; then
return 1
fi
mapped_port="${mapped_port##*:}"
[ "$mapped_port" = "$requested_port" ]
}
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
parse_port_list() {
local raw="$1"
local port
local ports=()
raw="${raw//,/ }"
for port in $raw; do
port="${port//[[:space:]]/}"
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
ports+=("$port")
fi
done
printf '%s\n' "${ports[@]}"
}
configure_host_ports() {
local requested_http requested_https
local requested_http
local requested_ports
local ports=()
local occupied_ports=()
requested_http=""
requested_ports=""
if [ -f "$ENV_FILE" ]; then
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
if [ -z "$requested_http" ]; then
requested_http="80"
requested_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
if stack_owns_host_port "$requested_http" "80"; then
HTTP_PORT_VALUE="$requested_http"
elif port_in_use "$requested_http"; then
stop_host_nginx_services || true
if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
requested_ports="$INVENTAR_HTTP_PORTS"
fi
if ! port_in_use "$requested_http"; then
HTTP_PORT_VALUE="$requested_http"
echo "Freed HTTP port $requested_http by stopping host nginx service"
else
HTTP_PORT_VALUE="$(find_free_port 8080)"
echo "HTTP port 80 is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
if [ -n "${INVENTAR_HTTP_PORT:-}" ] && [ -z "$requested_ports" ]; then
requested_ports="$INVENTAR_HTTP_PORT"
fi
if [ -n "$requested_ports" ]; then
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
else
HTTP_PORT_VALUE="$requested_http"
fi
HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
requested_https=""
if [ -f "$ENV_FILE" ]; then
requested_https="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
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
if port_in_use "$HTTP_PORT_VALUE"; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port $DEFAULT_TENANT_PORT_START is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
fi
fi
}
HTTPS_PORT_VALUE="$(find_free_port 8443)"
echo "HTTPS port 443 is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
else
HTTPS_PORT_VALUE="$requested_https"
ensure_min_docker_disk_space() {
local docker_root available_kb available_mb
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
}
@@ -543,14 +508,44 @@ write_env_file() {
cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=$NUITKA_BUILD_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
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() {
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
while [[ $retry_count -lt 2 ]]; do
@@ -558,10 +553,10 @@ verify_stack_health() {
for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
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
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."
return 0
fi
@@ -574,8 +569,8 @@ verify_stack_health() {
if [[ $retry_count -eq 0 ]]; then
echo "Health check failed. Attempting to restart containers..."
docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true
docker compose "${compose_args[@]}" restart app nginx mongodb
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
docker compose "${compose_args[@]}" restart app redis mongodb
sleep 3
((retry_count++))
else
@@ -586,7 +581,7 @@ verify_stack_health() {
# Final failure
echo "Error: stack health check failed after restart attempt."
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
}
@@ -594,20 +589,32 @@ parse_args "$@"
ensure_runtime_dependencies
setup_boot_autostart_service
ensure_tls_certificates
ensure_nginx_config_mount_source
ensure_runtime_config_json
setup_scheduled_jobs
configure_nuitka_mode
resolve_app_image
configure_host_ports
ensure_min_docker_disk_space
ensure_app_image_loaded
write_env_file
write_runtime_compose_override
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
echo "Stack started."
echo "Open: https://<server-ip>:$HTTPS_PORT_VALUE"
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
+19 -1
View File
@@ -4,12 +4,30 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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
echo "Error: docker command not found. Install Docker first."
exit 1
fi
echo "Stopping Inventarsystem Docker stack..."
docker compose down
docker compose -f "$COMPOSE_FILE" down
echo "Stack stopped."
+141 -84
View File
@@ -15,6 +15,9 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
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"
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"
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
apt_install() {
$SUDO apt-get update -y
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
@@ -44,7 +52,7 @@ ensure_runtime_dependencies() {
local missing=()
if ! command -v docker >/dev/null 2>&1; then
missing+=(docker.io)
missing+=(docker)
fi
if ! docker compose version >/dev/null 2>&1; then
@@ -64,88 +72,113 @@ ensure_runtime_dependencies() {
fi
if [ "${#missing[@]}" -gt 0 ]; then
log_message "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
if [ "$IS_ROOT" = "true" ]; then
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
if command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || true
fi
}
ensure_tls_certificates() {
local cert_dir cert_path key_path cn
cert_dir="$PROJECT_DIR/certs"
cert_path="$cert_dir/inventarsystem.crt"
key_path="$cert_dir/inventarsystem.key"
ensure_min_root_disk_space() {
local available_kb available_mb
mkdir -p "$cert_dir"
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
if ! command -v df >/dev/null 2>&1; then
return 0
fi
cn="${TLS_CN:-localhost}"
log_message "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="$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"
available_kb="$(df -Pk "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
if [ -z "$available_kb" ]; then
return 0
fi
if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF'
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
available_mb=$((available_kb / 1024))
if [ "$available_mb" -lt "$MIN_ROOT_FREE_MB" ]; then
log_message "ERROR: Low disk space on filesystem containing $PROJECT_DIR"
log_message "Available: ${available_mb} MB; required minimum: ${MIN_ROOT_FREE_MB} MB"
log_message "Free disk space and rerun update."
exit 1
fi
}
server {
listen 443 ssl;
server_name _;
cleanup_old_dist_artifacts() {
local keep_count
keep_count="$DIST_KEEP_COUNT"
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
if [ ! -d "$DIST_DIR" ]; then
return 0
fi
client_max_body_size 50M;
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
keep_count=2
fi
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;
}
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}')
if [ "${#archives[@]}" -le "$keep_count" ]; then
return 0
fi
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>';
}
local index old_archive deleted=0
for (( index=keep_count; index<${#archives[@]}; index++ )); do
old_archive="${archives[$index]}"
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
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() {
@@ -311,9 +344,7 @@ download_and_extract_bundle() {
tar -xzf "$archive" -C "$tmp_dir"
# 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/nginx/default.conf" "$PROJECT_DIR/docker/nginx/default.conf"
cp -f "$tmp_dir/start.sh" "$PROJECT_DIR/start.sh"
cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh"
@@ -329,9 +360,6 @@ download_and_extract_bundle() {
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/docker/nginx/multitenant.conf" ]; then
cp -f "$tmp_dir/docker/nginx/multitenant.conf" "$PROJECT_DIR/docker/nginx/multitenant.conf"
fi
if [ -f "$tmp_dir/manage-tenant.sh" ]; then
cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh"
fi
@@ -360,13 +388,19 @@ deploy() {
local tag="$1"
local meta_file="$2"
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"
if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_HTTP_PORT=10000
INVENTAR_APP_IMAGE=$app_image
EOF
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
@@ -385,27 +419,27 @@ EOF
fi
fi
docker compose --env-file "$ENV_FILE" pull nginx 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" pull app mongodb >> "$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
}
verify_stack_health() {
local compose_args running_services
local https_port
compose_args=(--env-file "$ENV_FILE")
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
if [ -z "$https_port" ]; then
https_port="443"
local http_port
compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
http_port="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
if [ -z "$http_port" ]; then
http_port="10000"
fi
for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
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
# Primary check: HTTP endpoint responds (most reliable)
if curl -kfsS "https://127.0.0.1:$https_port" >/dev/null 2>&1; then
# Primary check: health endpoint responds (most reliable)
if curl -fsS "http://127.0.0.1:$http_port/health" >/dev/null 2>&1; then
return 0
fi
fi
@@ -413,20 +447,41 @@ verify_stack_health() {
done
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
}
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() {
parse_args "$@"
cleanup_server_space
ensure_runtime_dependencies
ensure_tls_certificates
ensure_nginx_config_mount_source
require_cmd curl
require_cmd tar
require_cmd docker
require_cmd python3
ensure_min_root_disk_space
archive_logs
create_backup
@@ -514,6 +569,8 @@ main() {
fi
echo "$latest_tag" > "$STATE_FILE"
cleanup_old_dist_artifacts
cleanup_docker_dangling_images
log_message "Update completed successfully to release $latest_tag"
}