Compare commits

...

12 Commits

Author SHA1 Message Date
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
17 changed files with 668 additions and 130 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80 INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443 INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:latest INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
-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
+1
View File
@@ -15,3 +15,4 @@ python-barcode
openpyxl openpyxl
cryptography cryptography
pywebpush pywebpush
py-vapid==1.9.0
+59
View File
@@ -34,6 +34,25 @@
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5); --ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
} }
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
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 */ /* Make inputs visible in dark mode */
:root[data-theme="dark"] input, :root[data-theme="dark"] input,
:root[data-theme="dark"] select, :root[data-theme="dark"] select,
@@ -172,11 +191,16 @@ select:focus {
box-shadow: none; box-shadow: none;
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
/* Safe area insets for iPad notches and Dynamic Island */
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
} }
.navbar-brand { .navbar-brand {
font-weight: 800; font-weight: 800;
letter-spacing: 0.02em; letter-spacing: 0.02em;
/* Prevent navbar brand from extending into unsafe areas */
word-break: break-word;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -190,6 +214,41 @@ select:focus {
} }
} }
/* Mobile and tablet safe area handling for navbar */
@media (max-width: 992px) {
.navbar {
/* Ensure navbar spans full width but respects safe areas */
width: 100vw;
margin-left: calc(-50vw + 50%);
/* Safe area padding is already handled by navbar class */
}
.navbar .container-fluid {
width: 100%;
box-sizing: border-box;
/* Ensure padding accounts for safe areas on small screens */
padding-left: max(env(safe-area-inset-left, 0), 12px);
padding-right: max(env(safe-area-inset-right, 0), 12px);
}
.navbar-collapse {
/* Ensure collapsed navbar respects safe areas */
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
}
}
/* iPad-specific landscape and portrait handling */
@media (max-height: 600px) and (orientation: landscape) {
.navbar {
padding-top: max(env(safe-area-inset-top, 0), 4px);
}
.navbar .container-fluid {
padding-bottom: 4px;
}
}
/* Edit and generate buttons with theme-aware text for better visibility */ /* Edit and generate buttons with theme-aware text for better visibility */
.edit-button, .duplicate-button, .generate-qr-button { .edit-button, .duplicate-button, .generate-qr-button {
color: var(--ui-text) !important; /* Override any existing color */ color: var(--ui-text) !important; /* Override any existing color */
+192 -78
View File
@@ -169,31 +169,87 @@
background-color: var(--module-primary-color) !important; background-color: var(--module-primary-color) !important;
position: sticky; position: sticky;
top: 0; top: 0;
padding-top: env(safe-area-inset-top, 0); /* iOS Safe Area */ /* iOS Safe Area insets for notches and rounded corners */
padding-top: env(safe-area-inset-top, 0);
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
z-index: 1900; z-index: 1900;
} }
.navbar .container-fluid { .navbar .container-fluid {
gap: 10px; gap: 8px;
padding-bottom: env(safe-area-inset-bottom, 0); /* Fallback */ /* Ensure container respects safe areas on all sides */
padding-left: max(env(safe-area-inset-left, 0), 12px);
padding-right: max(env(safe-area-inset-right, 0), 12px);
padding-bottom: env(safe-area-inset-bottom, 0);
} }
.navbar-brand { .navbar-brand {
font-weight: bold; font-weight: bold;
font-size: 1.4rem; font-size: 1.3rem;
padding-right: 10px;
white-space: nowrap;
flex-shrink: 0;
} }
.navbar-brand::before { .navbar-brand::before {
content: attr(data-icon); content: attr(data-icon);
margin-right: 8px; margin-right: 6px;
} }
.navbar-nav .nav-link { .navbar-nav .nav-link {
font-size: 1rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
padding: 0.6rem 0.85rem; padding: 0.5rem 0.75rem;
border-radius: 8px; border-radius: 6px;
transition: padding .18s ease, font-size .18s ease; transition: padding .18s ease, font-size .18s ease;
white-space: nowrap;
}
/* iPad/Tablet responsive adjustments */
@media (max-width: 1024px) {
.navbar .container-fluid {
gap: 6px;
padding-left: max(env(safe-area-inset-left, 0), 10px);
padding-right: max(env(safe-area-inset-right, 0), 10px);
}
.navbar-brand {
font-size: 1.1rem;
padding-right: 8px;
}
.navbar-brand::before {
margin-right: 4px;
}
.navbar-nav .nav-link {
font-size: 0.85rem;
padding: 0.45rem 0.6rem;
}
}
/* Small phones responsive adjustments */
@media (max-width: 576px) {
.navbar .container-fluid {
gap: 4px;
padding-left: max(env(safe-area-inset-left, 0), 8px);
padding-right: max(env(safe-area-inset-right, 0), 8px);
}
.navbar-brand {
font-size: 0.95rem;
padding-right: 6px;
}
.navbar-brand::before {
display: none;
}
.navbar-nav .nav-link {
font-size: 0.75rem;
padding: 0.4rem 0.5rem;
}
} }
.navbar.nav-compact .navbar-brand { .navbar.nav-compact .navbar-brand {
@@ -250,6 +306,7 @@
align-items: center; align-items: center;
margin-right: 10px; margin-right: 10px;
width: min(420px, 42vw); width: min(420px, 42vw);
min-width: 0;
} }
.function-search-form { .function-search-form {
@@ -257,10 +314,13 @@
display: flex; display: flex;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
min-width: 0;
} }
.function-search-input { .function-search-input {
width: 100%; flex: 1 1 auto;
width: auto;
min-width: 0;
min-height: 38px; min-height: 38px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.3);
@@ -281,6 +341,7 @@
} }
.function-search-btn { .function-search-btn {
flex: 0 0 auto;
min-height: 38px; min-height: 38px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.45); border: 1px solid rgba(255, 255, 255, 0.45);
@@ -483,9 +544,48 @@
margin-top: 4px; margin-top: 4px;
} }
.navbar-collapse {
width: 100%;
}
.navbar-collapse > .d-flex {
width: 100%;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.function-search-wrap { .function-search-wrap {
width: 100%; width: 100%;
max-width: 100%;
margin: 8px 0 10px; margin: 8px 0 10px;
flex: 1 1 100%;
}
.function-search-form {
width: 100%;
max-width: 100%;
flex-wrap: nowrap;
}
.function-search-input {
width: auto;
min-width: 0;
flex: 1 1 auto;
}
.function-search-btn {
flex: 0 0 auto;
white-space: nowrap;
}
.navbar-text {
margin-right: 0 !important;
width: 100%;
}
.user-menu-wrap {
align-self: flex-start;
} }
.navbar-nav .nav-item { .navbar-nav .nav-item {
@@ -857,12 +957,12 @@
{% endif %} {% endif %}
{% if 'username' in session %} {% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %} {% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a> <a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
</li> </li>
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %} {% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a> <a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
</li> </li>
{% endif %} {% endif %}
@@ -872,9 +972,7 @@
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a> <a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a>
</li> </li>
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true">
<li class="nav-item">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten"> <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-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span> <span class="theme-icon-dark" style="display: none;">🌙</span>
@@ -886,15 +984,6 @@
Mehr Optionen Mehr Optionen
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %} {% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li> <li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %} {% if current_permissions.pages.get('manage_filters', True) %}
@@ -987,12 +1076,12 @@
{% endif %} {% endif %}
{% if 'username' in session %} {% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %} {% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a> <a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
</li> </li>
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %} {% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a> <a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
</li> </li>
{% endif %} {% endif %}
@@ -1002,9 +1091,7 @@
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a> <a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
</li> </li>
{% endif %} {% endif %}
<li class="nav-item" data-nav-fixed="true">
<li class="nav-item">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten"> <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-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span> <span class="theme-icon-dark" style="display: none;">🌙</span>
@@ -1016,15 +1103,6 @@
Mehr Optionen Mehr Optionen
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %} {% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li> <li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', True) %} {% if current_permissions.pages.get('library_loans_admin', True) %}
@@ -1602,8 +1680,15 @@
const libraryNavList = document.getElementById('libraryNavList'); const libraryNavList = document.getElementById('libraryNavList');
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor'); const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
function initNavbarOverflow(navList, navOverflowAnchor) { function initNavbarOverflow(navList, navOverflowAnchor, options) {
if (!navList || !navOverflowAnchor) return; if (!navList) return;
const moreToggleId = options && options.moreToggleId ? options.moreToggleId : '';
const moreToggle = moreToggleId ? document.getElementById(moreToggleId) : null;
const moreControlItem = moreToggle ? moreToggle.closest('li.nav-item.dropdown') : null;
const moreMenu = moreControlItem ? moreControlItem.querySelector(':scope > ul.dropdown-menu') : null;
const moreLabelDefault = moreToggle ? moreToggle.textContent.trim() : 'Mehr Optionen';
const moreMenuOriginal = moreMenu ? moreMenu.innerHTML : '';
const navRoot = navList.closest('nav.navbar'); const navRoot = navList.closest('nav.navbar');
const navCollapse = navList.closest('.navbar-collapse'); const navCollapse = navList.closest('.navbar-collapse');
@@ -1631,45 +1716,35 @@
return Array.from(navList.children).filter(function(li){ return Array.from(navList.children).filter(function(li){
if (!(li instanceof HTMLElement)) return false; if (!(li instanceof HTMLElement)) return false;
if (!li.classList.contains('nav-item')) return false; if (!li.classList.contains('nav-item')) return false;
if (li.id === navOverflowAnchor.id) return false; if (navOverflowAnchor && li.id === navOverflowAnchor.id) return false;
if (li.dataset.overflowControl === 'true') return false; if (li.dataset.overflowControl === 'true') return false;
if (li.dataset.navFixed === 'true') return false; if (li.dataset.navFixed === 'true') return false;
if (moreControlItem && li === moreControlItem) return false;
return li.style.display !== 'none'; return li.style.display !== 'none';
}); });
} }
function clearOverflowControls() { function clearOverflowControls() {
if (!navList) return; if (!moreMenu) return;
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){ moreMenu.innerHTML = moreMenuOriginal;
node.remove(); if (moreControlItem) {
}); moreControlItem.style.display = '';
}
if (moreToggle) {
moreToggle.textContent = moreLabelDefault;
}
} }
function restoreAllNavItems() { function restoreAllNavItems() {
if (!navList) return; if (!navList) return;
navList.querySelectorAll('li.nav-item').forEach(function(li){ navList.querySelectorAll('li.nav-item').forEach(function(li){
if (li.id === navOverflowAnchor.id) return; if (navOverflowAnchor && li.id === navOverflowAnchor.id) return;
if (li.dataset.overflowControl === 'true') return; if (li.dataset.overflowControl === 'true') return;
li.style.display = ''; li.style.display = '';
}); });
clearOverflowControls(); clearOverflowControls();
} }
function createOverflowControl() {
const li = document.createElement('li');
li.className = 'nav-item dropdown';
li.dataset.overflowControl = 'true';
const toggleId = navOverflowAnchor.id + '-toggle';
const menuId = navOverflowAnchor.id + '-menu';
li.innerHTML =
'<a class="nav-link dropdown-toggle" href="#" id="' + toggleId + '" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-controls="' + menuId + '">⋮ Weitere</a>' +
'<ul class="dropdown-menu" aria-labelledby="' + toggleId + '" id="' + menuId + '"></ul>';
return li;
}
function getNavCandidatePriority(item) { function getNavCandidatePriority(item) {
if (!(item instanceof HTMLElement)) { if (!(item instanceof HTMLElement)) {
return -1; return -1;
@@ -1752,33 +1827,70 @@
function rebuildOverflowControl(hiddenSources) { function rebuildOverflowControl(hiddenSources) {
clearOverflowControls(); clearOverflowControls();
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return; if (!moreMenu || hiddenSources.length === 0) return;
const control = createOverflowControl();
const menu = control.querySelector('ul.dropdown-menu');
const controlLink = control.querySelector(':scope > a.nav-link');
if (controlLink) {
controlLink.textContent = '⋮ Weitere (' + hiddenSources.length + ')';
}
const overflowFragment = document.createDocumentFragment();
hiddenSources.forEach(function(source){ hiddenSources.forEach(function(source){
appendSourceToOverflowMenu(source, menu); appendSourceToOverflowMenu(source, overflowFragment);
}); });
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider'); const overflowWrap = document.createElement('div');
if (trailingDivider) { overflowWrap.appendChild(overflowFragment);
const trailingDivider = overflowWrap.querySelector('li:last-child .dropdown-divider');
if (trailingDivider && trailingDivider.parentElement) {
trailingDivider.parentElement.remove(); trailingDivider.parentElement.remove();
} }
navList.insertBefore(control, navOverflowAnchor); const overflowEntries = Array.from(overflowWrap.childNodes);
if (overflowEntries.length > 0) {
const marker = document.createElement('li');
marker.innerHTML = '<h6 class="dropdown-header">Ausgeblendete Navigation</h6>';
const firstExistingNode = moreMenu.firstChild;
moreMenu.insertBefore(marker, firstExistingNode);
let insertAfter = marker;
overflowEntries.forEach(function(node){
moreMenu.insertBefore(node, insertAfter.nextSibling);
insertAfter = node;
});
const divider = document.createElement('li');
divider.innerHTML = '<hr class="dropdown-divider">';
moreMenu.insertBefore(divider, insertAfter.nextSibling);
if (moreToggle) {
moreToggle.textContent = moreLabelDefault + ' (' + hiddenSources.length + ')';
}
}
if (moreControlItem) {
moreControlItem.style.display = '';
}
} }
function adaptNavbarByWidth() { function adaptNavbarByWidth() {
if (!navList || !navOverflowAnchor) return; if (!navList) return;
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
function isNavOverflowing(bufferPx) {
const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
const containerOverflow = navContainer ? (navContainer.scrollWidth > (navContainer.clientWidth - buffer)) : false;
const listOverflow = navList.scrollWidth > (navList.clientWidth - buffer);
return collapseOverflow || containerOverflow || listOverflow;
}
applyCompactMode(); applyCompactMode();
if (window.innerWidth < 992) { // Desktop/tablet-large: keep complete navigation visible.
if (!isMobileViewport) {
restoreAllNavItems();
return;
}
// Mobile menu is collapsed: avoid hiding links preemptively.
if (navCollapse && !navCollapse.classList.contains('show')) {
restoreAllNavItems(); restoreAllNavItems();
return; return;
} }
@@ -1787,8 +1899,9 @@
const hiddenSources = []; const hiddenSources = [];
let candidates = collectTopLevelNavSources(); let candidates = collectTopLevelNavSources();
const overflowBuffer = 12;
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) { while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates); const toHide = pickNextNavItemToHide(candidates);
if (!toHide) { if (!toHide) {
break; break;
@@ -1800,8 +1913,9 @@
rebuildOverflowControl(hiddenSources); rebuildOverflowControl(hiddenSources);
// One final pass in case the overflow menu label/count itself changed row width.
candidates = collectTopLevelNavSources(); candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) { while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates); const toHide = pickNextNavItemToHide(candidates);
if (!toHide) { if (!toHide) {
break; break;
@@ -1839,12 +1953,12 @@
// Initialize overflow control for inventory navbar // Initialize overflow control for inventory navbar
if (navList && navOverflowAnchor) { if (navList && navOverflowAnchor) {
initNavbarOverflow(navList, navOverflowAnchor); initNavbarOverflow(navList, navOverflowAnchor, { moreToggleId: 'invMoreDropdown' });
} }
// Initialize overflow control for library navbar // Initialize overflow control for library navbar
if (libraryNavList && libNavOverflowAnchor) { if (libraryNavList && libNavOverflowAnchor) {
initNavbarOverflow(libraryNavList, libNavOverflowAnchor); initNavbarOverflow(libraryNavList, libNavOverflowAnchor, { moreToggleId: 'libMoreDropdown' });
} }
})(); })();
</script> </script>
+237 -1
View File
@@ -208,12 +208,23 @@
<script> <script>
// View mode persistence // View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode'; const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
const MOBILE_LIBRARY_MAX_WIDTH = 900;
const MOBILE_LIBRARY_MIN_ITEMS = 24;
function isMobileViewport() {
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
}
function setViewMode(mode) { function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode); localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer'); const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader'); const tableHeader = document.getElementById('tableHeader');
const items = container.querySelectorAll('.item-card'); const renderedItems = Array.from(container.querySelectorAll('.item-card'));
const virtualizationState = window.mobileLibraryVirtualizationState || null;
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
? virtualizationState.items.filter(item => !container.contains(item))
: [];
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
items.forEach(item => { items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode'); const cardContent = item.querySelector('.item-content.card-mode');
@@ -236,6 +247,229 @@ function setViewMode(mode) {
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table'); document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
} }
function initMobileWindowedLibraryLoading() {
const container = document.getElementById('itemsContainer');
if (!container) {
return;
}
if (!window.mobileLibraryVirtualizationState) {
window.mobileLibraryVirtualizationState = {
active: false,
items: [],
topSpacer: null,
bottomSpacer: null,
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
lastStart: -1,
lastEnd: -1,
framePending: false,
scrollListener: null,
resizeListener: null,
initialized: false
};
}
const state = window.mobileLibraryVirtualizationState;
function restoreAllImages() {
state.items.forEach(item => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
const originalSrc = image.dataset.mobileSrc || '';
if (!image.getAttribute('src') && originalSrc) {
image.setAttribute('src', originalSrc);
}
});
}
function updateImageMemory(startIndex, endIndex) {
const imageBuffer = 4;
state.items.forEach((item, index) => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
if (!image.dataset.mobileSrc) {
image.dataset.mobileSrc = image.getAttribute('src') || '';
}
image.loading = 'lazy';
image.decoding = 'async';
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
if (shouldKeepLoaded) {
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
image.setAttribute('src', image.dataset.mobileSrc);
}
return;
}
if (image.getAttribute('src')) {
image.removeAttribute('src');
}
});
}
function renderVisibleWindow() {
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
return;
}
const viewportHeight = window.innerHeight || 800;
const scrollTop = window.scrollY || window.pageYOffset || 0;
const renderBuffer = viewportHeight * 1.5;
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
if (endIndex - startIndex < 14) {
endIndex = Math.min(state.items.length, startIndex + 14);
}
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
return;
}
state.lastStart = startIndex;
state.lastEnd = endIndex;
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
state.topSpacer.nextSibling.remove();
}
const fragment = document.createDocumentFragment();
for (let index = startIndex; index < endIndex; index += 1) {
fragment.appendChild(state.items[index]);
}
container.insertBefore(fragment, state.bottomSpacer);
let measuredHeightSum = 0;
let measuredCount = 0;
for (let index = startIndex; index < endIndex; index += 1) {
const height = state.items[index].getBoundingClientRect().height;
if (height > 0) {
measuredHeightSum += height;
measuredCount += 1;
}
}
if (measuredCount > 0) {
const measuredAverage = measuredHeightSum / measuredCount;
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
}
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
updateImageMemory(startIndex, endIndex);
}
function scheduleWindowRender() {
if (!state.active || state.framePending) {
return;
}
state.framePending = true;
requestAnimationFrame(() => {
state.framePending = false;
renderVisibleWindow();
});
}
function activateVirtualization() {
if (state.active) {
return;
}
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
return;
}
state.topSpacer = document.createElement('div');
state.topSpacer.className = 'mobile-window-spacer top';
state.bottomSpacer = document.createElement('div');
state.bottomSpacer.className = 'mobile-window-spacer bottom';
state.items.forEach(item => item.remove());
container.appendChild(state.topSpacer);
container.appendChild(state.bottomSpacer);
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = true;
if (!state.scrollListener) {
state.scrollListener = () => scheduleWindowRender();
window.addEventListener('scroll', state.scrollListener, { passive: true });
}
scheduleWindowRender();
}
function deactivateVirtualization() {
if (!state.active) {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
return;
}
if (state.scrollListener) {
window.removeEventListener('scroll', state.scrollListener);
state.scrollListener = null;
}
if (state.topSpacer && state.topSpacer.parentNode === container) {
state.topSpacer.remove();
}
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
state.bottomSpacer.remove();
}
const fragment = document.createDocumentFragment();
state.items.forEach(item => fragment.appendChild(item));
container.appendChild(fragment);
restoreAllImages();
state.topSpacer = null;
state.bottomSpacer = null;
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = false;
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(currentMode);
}
function syncVirtualizationWithViewport() {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
if (shouldVirtualize) {
activateVirtualization();
scheduleWindowRender();
} else {
deactivateVirtualization();
}
}
if (!state.initialized) {
state.resizeListener = () => syncVirtualizationWithViewport();
window.addEventListener('resize', state.resizeListener, { passive: true });
state.initialized = true;
}
syncVirtualizationWithViewport();
}
// Initialize view mode // Initialize view mode
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card'; const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
@@ -311,6 +545,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (e.target === this) this.style.display = 'none'; if (e.target === this) this.style.display = 'none';
}); });
}); });
initMobileWindowedLibraryLoading();
}); });
function displayLibraryItemDetail(item) { function displayLibraryItemDetail(item) {
+10 -3
View File
@@ -33,11 +33,12 @@ LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}" COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
KEEP_DAYS="${KEEP_DAYS:-7}" KEEP_DAYS="${KEEP_DAYS:-7}"
MIN_KEEP="${MIN_KEEP:-7}" MIN_KEEP="${MIN_KEEP:-7}"
DB_NAME="${DB_NAME:-Inventarsystem}" DB_NAME="${DB_NAME:-inventar_default}"
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}" MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}" INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}" INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
BACKUP_MODE="${BACKUP_MODE:-auto}" BACKUP_MODE="${BACKUP_MODE:-auto}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
DOCKER_AVAILABLE=0 DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=() DOCKER_COMPOSE_CMD=()
USE_NULL_OUTPUT=false USE_NULL_OUTPUT=false
@@ -58,6 +59,8 @@ Options:
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter) --keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP) --min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE) --mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
-h|--help Show this help and exit -h|--help Show this help and exit
EOF EOF
} }
@@ -87,6 +90,10 @@ while [[ $# -gt 0 ]]; do
MIN_KEEP="$2"; shift 2;; MIN_KEEP="$2"; shift 2;;
--mode) --mode)
BACKUP_MODE="$2"; shift 2;; BACKUP_MODE="$2"; shift 2;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"; shift;;
-h|--help) -h|--help)
usage; exit 0;; usage; exit 0;;
*) *)
@@ -102,12 +109,12 @@ log_message() {
init_docker_compose() { init_docker_compose() {
if docker compose version >/dev/null 2>&1; then if docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1 DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml") DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
return 0 return 0
fi fi
if sudo docker compose version >/dev/null 2>&1; then if sudo docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1 DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml") DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
return 0 return 0
fi fi
+5 -10
View File
@@ -8,12 +8,10 @@
# Scale example: To support 10 tenants with 20 users each: # Scale example: To support 10 tenants with 20 users each:
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10 # docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
version: '3.9'
services: services:
# Management Container for multi-tenant scripts # Management Container for multi-tenant scripts
tenant-manager: tenant-manager:
image: bash image: docker:cli
container_name: tenant-manager container_name: tenant-manager
restart: "no" restart: "no"
profiles: profiles:
@@ -21,7 +19,7 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- .:/workspace - .:/workspace
entrypoint: ["bash", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"] entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
nginx: nginx:
image: nginx:1.27-alpine image: nginx:1.27-alpine
@@ -71,9 +69,9 @@ services:
image: mongo:7.0 image: mongo:7.0
container_name: inventarsystem-mongodb container_name: inventarsystem-mongodb
restart: unless-stopped restart: unless-stopped
command: mongod --wiredTigerCacheSizeGB 2 --journal --dbPath /data/db command: mongod --wiredTigerCacheSizeGB 2
ports: expose:
- "27017:27017" - "27017"
volumes: volumes:
- mongodb_data:/data/db - mongodb_data:/data/db
- ./docker/mongo:/docker-entrypoint-initdb.d:ro - ./docker/mongo:/docker-entrypoint-initdb.d:ro
@@ -193,9 +191,6 @@ volumes:
networks: networks:
inventar-net: inventar-net:
driver: bridge driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
# Scale guidance: # Scale guidance:
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB # 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
+5 -4
View File
@@ -37,7 +37,8 @@ upstream inventar_backend {
# Main HTTPS server block # Main HTTPS server block
server { server {
listen 443 ssl http2; listen 443 ssl;
http2 on;
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$; server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
# SSL Configuration (wildcard certificate) # SSL Configuration (wildcard certificate)
@@ -75,9 +76,9 @@ server {
application/x-javascript application/xml+rss application/json; application/x-javascript application/xml+rss application/json;
gzip_comp_level 5; gzip_comp_level 5;
# Access/Error logs with tenant in filename # Access/Error logs
access_log /var/log/nginx/inventar_access_${tenant}.log combined buffer=32k; access_log /var/log/nginx/inventar_access.log combined buffer=32k;
error_log /var/log/nginx/inventar_error_${tenant}.log warn; error_log /var/log/nginx/inventar_error.log warn;
# Root location - proxy to app with tenant context # Root location - proxy to app with tenant context
location / { location / {
+24 -5
View File
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
CLEANUP_OLD_SERVICES=true CLEANUP_OLD_SERVICES=true
CLEANUP_OLD_REMOVE_CRON=false CLEANUP_OLD_REMOVE_CRON=false
TMP_DIR="" TMP_DIR=""
COMPOSE_FILE="docker-compose-multitenant.yml"
cleanup_tmp_dir() { cleanup_tmp_dir() {
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
@@ -50,11 +51,10 @@ refresh_start_script_from_main() {
pin_compose_app_image() { pin_compose_app_image() {
local tag="$1" local tag="$1"
local compose_file local compose_file
compose_file="$PROJECT_DIR/docker-compose.yml"
for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
if [ ! -f "$compose_file" ]; then if [ ! -f "$compose_file" ]; then
echo "Warning: $compose_file not found; cannot pin app image" continue
return 0
fi fi
python3 - <<'PY' "$compose_file" "$tag" python3 - <<'PY' "$compose_file" "$tag"
@@ -151,6 +151,8 @@ if in_app_service and build_found and app_service_indent is not None:
with open(compose_file, "w", encoding="utf-8") as f: with open(compose_file, "w", encoding="utf-8") as f:
f.writelines(out) f.writelines(out)
PY PY
done
} }
install_docker_if_missing() { install_docker_if_missing() {
@@ -173,6 +175,8 @@ Options:
--remove-legacy-system Remove old host MongoDB/system after successful import --remove-legacy-system Remove old host MongoDB/system after successful import
--skip-cleanup-old Do not run cleanup-old.sh after install --skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup --cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME) --legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI) --legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--legacy-system-dir <path> Optional old system directory to remove after migration --legacy-system-dir <path> Optional old system directory to remove after migration
@@ -199,6 +203,14 @@ parse_args() {
CLEANUP_OLD_REMOVE_CRON=true CLEANUP_OLD_REMOVE_CRON=true
shift shift
;; ;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--legacy-db-name) --legacy-db-name)
LEGACY_DB_NAME="$2" LEGACY_DB_NAME="$2"
shift 2 shift 2
@@ -280,14 +292,21 @@ backup_legacy_database() {
restore_legacy_backup_into_docker() { restore_legacy_backup_into_docker() {
local i local i
local compose_path
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
return 0 return 0
fi fi
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
if [ ! -f "$compose_path" ]; then
echo "Error: compose file not found: $compose_path"
exit 1
fi
echo "Waiting for Docker MongoDB to become ready..." echo "Waiting for Docker MongoDB to become ready..."
for i in $(seq 1 60); do for i in $(seq 1 60); do
if sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then if sudo docker compose -f "$compose_path" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
break break
fi fi
sleep 2 sleep 2
@@ -299,7 +318,7 @@ restore_legacy_backup_into_docker() {
fi fi
echo "Importing legacy backup into Docker MongoDB..." echo "Importing legacy backup into Docker MongoDB..."
sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE" sudo docker compose -f "$compose_path" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
echo "Legacy DB import completed." echo "Legacy DB import completed."
} }
+5 -4
View File
@@ -1,4 +1,4 @@
#!/bin/bash #!/bin/sh
# Script to manage multitenant deployment # Script to manage multitenant deployment
# Allows adding, removing, and restarting tenants without downtime for others # Allows adding, removing, and restarting tenants without downtime for others
@@ -48,11 +48,12 @@ case "$COMMAND" in
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c " 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'); import settings; from pymongo import MongoClient; import hashlib
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT)) client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}'] db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db.users.insert_one({'username': 'admin', 'password': 'hashed_password_here', 'role': 'admin'}) hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
print(f'Tenant {sys.argv[1]} database initialized.') db.users.insert_one({'Username': 'admin', 'Password': hashed_pw, 'Role': 'admin', 'Name': 'Admin', 'Nachname': 'User'})
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
" "$TENANT_ID" " "$TENANT_ID"
echo "Tenant '$TENANT_ID' successfully added. Ready to use." echo "Tenant '$TENANT_ID' successfully added. Ready to use."
else else
+1
View File
@@ -14,3 +14,4 @@ python-barcode
openpyxl openpyxl
cryptography cryptography
pywebpush pywebpush
py-vapid==1.9.0
+2 -2
View File
@@ -3,5 +3,5 @@ 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 )"
"$SCRIPT_DIR/stop.sh" "$SCRIPT_DIR/stop.sh" "$@"
"$SCRIPT_DIR/start.sh" "$SCRIPT_DIR/start.sh" "$@"
+14 -3
View File
@@ -6,12 +6,13 @@ LOG_DIR="$SCRIPT_DIR/logs"
LOG_FILE="$LOG_DIR/restore.log" LOG_FILE="$LOG_DIR/restore.log"
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)" WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}" DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
SOURCE_PATH="" SOURCE_PATH=""
BACKUP_DATE="" BACKUP_DATE=""
DROP_DATABASE=false DROP_DATABASE=false
RESTART_SERVICES=false RESTART_SERVICES=false
STAGED_PATH="" STAGED_PATH=""
COMPOSE_FILE="docker-compose-multitenant.yml"
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups" BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
BACKUP_ROOT_SYSTEM="/var/backups" BACKUP_ROOT_SYSTEM="/var/backups"
@@ -56,6 +57,8 @@ Options:
- latest: newest from local/system backup roots - latest: newest from local/system backup roots
--drop-database Drop target DB before import (recommended for full restore) --drop-database Drop target DB before import (recommended for full restore)
--restart-services Restart stack after restore --restart-services Restart stack after restore
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--list List detected backup candidates --list List detected backup candidates
--help Show this help --help Show this help
@@ -68,12 +71,12 @@ EOF
setup_compose() { setup_compose() {
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=(docker compose) DOCKER_COMPOSE=(docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
return 0 return 0
fi fi
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=($SUDO docker compose) DOCKER_COMPOSE=($SUDO docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
return 0 return 0
fi fi
@@ -459,6 +462,14 @@ parse_args() {
RESTART_SERVICES=true RESTART_SERVICES=true
shift shift
;; ;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--list) --list)
list_backups list_backups
exit 0 exit 0
+42 -11
View File
@@ -7,6 +7,7 @@ cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.docker-build.env" ENV_FILE="$SCRIPT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo" APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$SCRIPT_DIR/dist" DIST_DIR="$SCRIPT_DIR/dist"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
SUDO="" SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
@@ -14,10 +15,11 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
fi fi
NUITKA_BUILD_VALUE="0" NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="80" HTTP_PORT_VALUE="8001"
HTTPS_PORT_VALUE="443" HTTPS_PORT_VALUE="8443"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}" CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}" APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
COMPOSE_FILE="docker-compose-multitenant.yml"
usage() { usage() {
cat <<EOF cat <<EOF
@@ -26,6 +28,8 @@ Usage: $0 [options]
Options: Options:
--no-cron Do not create or update cron jobs --no-cron Do not create or update cron jobs
--with-cron Create/update cron jobs (default) --with-cron Create/update cron jobs (default)
--multitenant Use the multi-tenant architecture deployment
--singletenant Use the legacy single-tenant compose deployment
-h, --help Show this help message -h, --help Show this help message
EOF EOF
} }
@@ -41,6 +45,14 @@ parse_args() {
CRON_SETUP_VALUE="1" CRON_SETUP_VALUE="1"
shift shift
;; ;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -450,7 +462,7 @@ stack_owns_host_port() {
local container_port="$2" local container_port="$2"
local mapped_port local mapped_port
mapped_port="$(docker compose --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)" mapped_port="$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
if [ -z "$mapped_port" ]; then if [ -z "$mapped_port" ]; then
return 1 return 1
fi fi
@@ -493,20 +505,20 @@ configure_host_ports() {
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)" requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi fi
if [ -z "$requested_http" ]; then if [ -z "$requested_http" ]; then
requested_http="80" requested_http="8001"
fi fi
if stack_owns_host_port "$requested_http" "80"; then if stack_owns_host_port "$requested_http" "80"; then
HTTP_PORT_VALUE="$requested_http" HTTP_PORT_VALUE="$requested_http"
elif port_in_use "$requested_http"; then elif port_in_use "$requested_http"; then
stop_host_nginx_services || true
if ! port_in_use "$requested_http"; then if ! port_in_use "$requested_http"; then
HTTP_PORT_VALUE="$requested_http" HTTP_PORT_VALUE="$requested_http"
echo "Freed HTTP port $requested_http by stopping host nginx service" echo "Freed HTTP port $requested_http by stopping host nginx service"
else else
HTTP_PORT_VALUE="$(find_free_port 8080)" HTTP_PORT_VALUE="$(find_free_port 8080)"
echo "HTTP port 80 is in use. Using fallback HTTP port: $HTTP_PORT_VALUE" echo "HTTP port is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
fi fi
else else
HTTP_PORT_VALUE="$requested_http" HTTP_PORT_VALUE="$requested_http"
@@ -518,13 +530,13 @@ configure_host_ports() {
fi fi
if [ -z "$requested_https" ]; then if [ -z "$requested_https" ]; then
requested_https="443" requested_https="8443"
fi fi
if stack_owns_host_port "$requested_https" "443"; then if stack_owns_host_port "$requested_https" "443"; then
HTTPS_PORT_VALUE="$requested_https" HTTPS_PORT_VALUE="$requested_https"
elif port_in_use "$requested_https"; then elif port_in_use "$requested_https"; then
stop_host_nginx_services || true
if ! port_in_use "$requested_https"; then if ! port_in_use "$requested_https"; then
HTTPS_PORT_VALUE="$requested_https" HTTPS_PORT_VALUE="$requested_https"
@@ -533,7 +545,7 @@ configure_host_ports() {
fi fi
HTTPS_PORT_VALUE="$(find_free_port 8443)" HTTPS_PORT_VALUE="$(find_free_port 8443)"
echo "HTTPS port 443 is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE" echo "HTTPS port is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
else else
HTTPS_PORT_VALUE="$requested_https" HTTPS_PORT_VALUE="$requested_https"
fi fi
@@ -548,9 +560,22 @@ INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
EOF EOF
} }
write_runtime_compose_override() {
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
services:
app:
image: ${APP_IMAGE_VALUE}
build: null
EOF
}
verify_stack_health() { verify_stack_health() {
local compose_args running_services retry_count=0 local compose_args running_services retry_count=0
compose_args=(--env-file "$ENV_FILE") compose_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
fi
compose_args+=(--env-file "$ENV_FILE")
# Try health check with optional restart on first failure # Try health check with optional restart on first failure
while [[ $retry_count -lt 2 ]]; do while [[ $retry_count -lt 2 ]]; do
@@ -603,9 +628,15 @@ resolve_app_image
configure_host_ports configure_host_ports
ensure_app_image_loaded ensure_app_image_loaded
write_env_file write_env_file
write_runtime_compose_override
echo "Starting Inventarsystem Docker stack (app + mongodb)..." echo "Starting Inventarsystem Docker stack (app + mongodb)..."
docker compose --env-file "$ENV_FILE" up -d --remove-orphans compose_up_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
fi
compose_up_args+=(--env-file "$ENV_FILE")
docker compose "${compose_up_args[@]}" up -d --remove-orphans
verify_stack_health verify_stack_health
+19 -1
View File
@@ -4,12 +4,30 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
COMPOSE_FILE="docker-compose-multitenant.yml"
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
*)
shift
;;
esac
done
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker command not found. Install Docker first." echo "Error: docker command not found. Install Docker first."
exit 1 exit 1
fi fi
echo "Stopping Inventarsystem Docker stack..." echo "Stopping Inventarsystem Docker stack..."
docker compose down docker compose -f "$COMPOSE_FILE" down
echo "Stack stopped." echo "Stack stopped."
+48 -3
View File
@@ -15,6 +15,7 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
ENV_FILE="$PROJECT_DIR/.docker-build.env" ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo" APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$PROJECT_DIR/dist" DIST_DIR="$PROJECT_DIR/dist"
COMPOSE_FILE="docker-compose-multitenant.yml"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
chmod 777 "$LOG_DIR" 2>/dev/null || true chmod 777 "$LOG_DIR" 2>/dev/null || true
@@ -118,6 +119,41 @@ server {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
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
}
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
}
server { server {
listen 443 ssl; listen 443 ssl;
server_name _; server_name _;
@@ -360,6 +396,13 @@ deploy() {
local tag="$1" local tag="$1"
local meta_file="$2" local meta_file="$2"
local app_image="${APP_IMAGE_REPO}:${tag}" local app_image="${APP_IMAGE_REPO}:${tag}"
local compose_path
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
if [ ! -f "$compose_path" ]; then
log_message "ERROR: compose file not found: $compose_path"
exit 1
fi
cd "$PROJECT_DIR" cd "$PROJECT_DIR"
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
@@ -385,15 +428,15 @@ EOF
fi fi
fi fi
docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1 docker compose -f "$compose_path" --env-file "$ENV_FILE" pull 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" up -d --remove-orphans >> "$LOG_FILE" 2>&1
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
} }
verify_stack_health() { verify_stack_health() {
local compose_args running_services local compose_args running_services
local https_port local https_port
compose_args=(--env-file "$ENV_FILE") compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')" https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
if [ -z "$https_port" ]; then if [ -z "$https_port" ]; then
https_port="443" https_port="443"
@@ -418,6 +461,8 @@ verify_stack_health() {
} }
main() { main() {
parse_args "$@"
ensure_runtime_dependencies ensure_runtime_dependencies
ensure_tls_certificates ensure_tls_certificates
ensure_nginx_config_mount_source ensure_nginx_config_mount_source