diff --git a/README.md b/README.md
index 40800df..6260db7 100755
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# AssetManager 0.5.5.84
+# AssetManager 0.5.5.88
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral.
diff --git a/VERSION b/VERSION
index 6204fa0..7539992 100755
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.5.5.84
+0.5.5.88
diff --git a/app/i18n.py b/app/i18n.py
index c7193ca..73976df 100755
--- a/app/i18n.py
+++ b/app/i18n.py
@@ -108,6 +108,12 @@ BASE_TRANSLATIONS = {
"categories.create": ("Create category", "Kategorie anlegen"), "categories.active_fields": ("Active fields", "Aktive Felder"),
"status.help": ("Define which status is set when assets are issued and returned.", "Lege hier fest, welcher Status beim Ausgeben und Zurücknehmen gesetzt wird."),
"status.on_issue": ("On issue", "Beim Ausgeben"), "status.on_return": ("On return", "Beim Zurücknehmen"),
+"status.show_in_software_list": ("In software lists", "In Softwarelisten"),
+"status.software_list_help": (
+ "Only statuses with this option disabled are excluded from the global software lists. Assets without an assigned status remain included.",
+ "Nur Statuswerte ohne diesen Haken werden aus den globalen Softwarelisten ausgeschlossen. Assets ohne zugewiesenen Status bleiben enthalten."
+),
+"status.software_list_help": ("‘In software lists’ controls whether assets with this status are included in the global software lists, software statistics and Excel exports. Software shown directly on an individual asset is not affected.", "„In Softwarelisten“ steuert, ob Assets mit diesem Status in den globalen Softwarelisten, Software-Statistiken und Excel-Exporten berücksichtigt werden. Die Softwareanzeige direkt im einzelnen Asset bleibt davon unberührt."),
"status.delete_confirm": ("Really delete this status value?", "Statuswert wirklich löschen?"), "status.new_value": ("New status value", "Neuer Statuswert"),
"mesh.title": ("MeshCentral synchronization", "MeshCentral-Synchronisierung"), "mesh.start": ("Start synchronization", "Synchronisierung starten"),
"mesh.linked_assets": ("linked assets", "verknüpfte Assets"), "mesh.conflicts": ("Conflicts", "Konflikte"), "mesh.missing": ("no longer found", "nicht mehr gefunden"),
diff --git a/app/main.py b/app/main.py
index 680845f..19c22c7 100755
--- a/app/main.py
+++ b/app/main.py
@@ -1950,9 +1950,8 @@ def _xlsx_response(filename: str, headers: list[str], rows: list[list]) -> Respo
workbook = Workbook()
sheet = workbook.active
sheet.title = "Export"
- sheet.freeze_panes = "A2"
- sheet.auto_filter.ref = f"A1:{sheet.cell(row=1, column=max(1, len(headers))).column_letter}1"
sheet.append(headers)
+ sheet.freeze_panes = "A2"
header_fill = PatternFill("solid", fgColor="D9EAF7")
for cell in sheet[1]:
cell.font = Font(bold=True)
@@ -1960,6 +1959,8 @@ def _xlsx_response(filename: str, headers: list[str], rows: list[list]) -> Respo
cell.alignment = Alignment(vertical="top")
for row in rows:
sheet.append([_display_value(value) for value in row])
+ last_column = sheet.cell(row=1, column=max(1, len(headers))).column_letter
+ sheet.auto_filter.ref = f"A1:{last_column}{max(1, sheet.max_row)}"
for column in sheet.columns:
max_length = min(max((len(str(cell.value or "")) for cell in column), default=10) + 2, 60)
sheet.column_dimensions[column[0].column_letter].width = max_length
@@ -4582,8 +4583,17 @@ async def status_options_save(request: Request, db: Session = Depends(get_db)):
name = (form.get(f"name_{option.id}") or "").strip()
if not name:
continue
+ old_name = option.name
+ if name != old_name:
+ # Asset.status stores the status name as text. Keep existing assets
+ # linked when an administrator renames a status option.
+ db.query(Asset).filter(Asset.status == old_name).update(
+ {Asset.status: name},
+ synchronize_session=False,
+ )
option.name = name
option.active = form.get(f"active_{option.id}") == "on"
+ option.show_in_software_list = form.get(f"software_list_{option.id}") == "on"
try:
option.sort_order = int(form.get(f"sort_{option.id}") or option.sort_order)
except ValueError:
@@ -4596,7 +4606,12 @@ async def status_options_save(request: Request, db: Session = Depends(get_db)):
new_name = (form.get("new_name") or "").strip()
if new_name:
max_order = max((item.sort_order for item in existing), default=-1)
- db.add(StatusOption(name=new_name, sort_order=max_order + 1, active=True))
+ db.add(StatusOption(
+ name=new_name,
+ sort_order=max_order + 1,
+ active=True,
+ show_in_software_list=form.get("new_show_in_software_list") == "on",
+ ))
try:
db.commit()
except IntegrityError as exc:
@@ -5080,6 +5095,27 @@ def software_callback_health():
}
+def _software_list_query(db: Session, *entities):
+ """Base query for global software lists and statistics.
+
+ Status options explicitly disabled for software lists are excluded.
+ Assets without a status, or with a legacy/unmatched status value, remain
+ visible so enabling this feature cannot silently hide existing inventory.
+ The software inventory on an individual asset remains unchanged.
+ """
+ return (
+ db.query(*entities)
+ .join(Asset, InstalledSoftware.asset_id == Asset.id)
+ .outerjoin(StatusOption, StatusOption.name == Asset.status)
+ .filter(
+ or_(
+ StatusOption.id.is_(None),
+ StatusOption.show_in_software_list.is_(True),
+ )
+ )
+ )
+
+
def _software_overview_statistics(db: Session) -> dict[str, int]:
"""Return only the counters required by the software overview.
@@ -5087,7 +5123,8 @@ def _software_overview_statistics(db: Session) -> dict[str, int]:
PostgreSQL performs the counting and grouping and returns only scalar values.
"""
total_entries, initialized_assets = (
- db.query(
+ _software_list_query(
+ db,
func.count(InstalledSoftware.id),
func.count(func.distinct(InstalledSoftware.asset_id)),
)
@@ -5098,7 +5135,8 @@ def _software_overview_statistics(db: Session) -> dict[str, int]:
# intentionally preserves the exact grouping semantics used by the existing
# detailed summary (Unicode casefold is stronger than SQL LOWER).
summary_rows = (
- db.query(
+ _software_list_query(
+ db,
InstalledSoftware.name,
InstalledSoftware.publisher,
InstalledSoftware.platform,
@@ -5158,7 +5196,8 @@ def _software_summary_rows(db: Session, grouping: str) -> list[dict[str, Any]]:
distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.version, "")), ""))
)
query_rows = (
- db.query(
+ _software_list_query(
+ db,
name_key.label("name_key"),
platform_key.label("platform_key"),
func.min(func.coalesce(InstalledSoftware.name, "")).label("name"),
@@ -5198,7 +5237,8 @@ def _software_summary_rows(db: Session, grouping: str) -> list[dict[str, Any]]:
if grouping == "version":
query_rows = (
- db.query(
+ _software_list_query(
+ db,
*common_columns,
version_key.label("version_key"),
func.min(func.coalesce(InstalledSoftware.version, "")).label("version"),
@@ -5232,7 +5272,8 @@ def _software_summary_rows(db: Session, grouping: str) -> list[dict[str, Any]]:
distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.version, "")), ""))
)
query_rows = (
- db.query(
+ _software_list_query(
+ db,
*common_columns,
versions_array.label("versions"),
func.count(func.distinct(InstalledSoftware.asset_id)).label("asset_count"),
@@ -5266,7 +5307,8 @@ def _software_summary_rows(db: Session, grouping: str) -> list[dict[str, Any]]:
def _software_installation_rows(db: Session):
"""Return only columns rendered by the detailed installation table."""
return (
- db.query(
+ _software_list_query(
+ db,
InstalledSoftware.id.label("id"),
InstalledSoftware.asset_id.label("asset_id"),
Asset.name.label("asset_name"),
@@ -5278,7 +5320,6 @@ def _software_installation_rows(db: Session):
InstalledSoftware.install_date.label("install_date"),
InstalledSoftware.last_seen_at.label("last_seen_at"),
)
- .join(Asset, InstalledSoftware.asset_id == Asset.id)
.order_by(
InstalledSoftware.name.asc(),
Asset.name.asc(),
diff --git a/app/migrations.py b/app/migrations.py
index d6048c8..298fe82 100755
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -35,6 +35,7 @@ CATEGORY_FIELD_COLUMNS = {
STATUS_COLUMNS = {
"use_for_issue": "BOOLEAN DEFAULT FALSE",
"use_for_return": "BOOLEAN DEFAULT FALSE",
+ "show_in_software_list": "BOOLEAN NOT NULL DEFAULT TRUE",
}
diff --git a/app/models.py b/app/models.py
index 09f8801..d0feded 100755
--- a/app/models.py
+++ b/app/models.py
@@ -193,6 +193,7 @@ class StatusOption(Base):
active: Mapped[bool] = mapped_column(Boolean, default=True)
use_for_issue: Mapped[bool] = mapped_column(Boolean, default=False)
use_for_return: Mapped[bool] = mapped_column(Boolean, default=False)
+ show_in_software_list: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class SyncRun(Base):
diff --git a/app/static/css/app.css b/app/static/css/app.css
index 39d0504..5aaa497 100755
--- a/app/static/css/app.css
+++ b/app/static/css/app.css
@@ -3241,3 +3241,84 @@ html[data-theme="dark"] .advanced-filter-popup select,html[data-theme="dark"] .a
.software-package-start-option{display:inline-flex!important;flex-direction:row!important;align-items:center!important;justify-content:flex-start!important;gap:.55rem!important;width:auto!important;max-width:100%!important;margin:0!important;}
.software-package-start-application input[type=checkbox],
.software-package-start-option input[type=checkbox]{width:auto!important;flex:0 0 auto!important;margin:0!important;}
+
+/* v0.5.5.87+: one table implementation across all interactive data lists.
+ table-tools.js adds standard-data-table-scroll automatically, including to
+ older views that did not have an explicit table-scroll wrapper. */
+.standard-data-table-scroll{
+ width:100%;
+ max-width:100%;
+ max-height:calc(100vh - 230px);
+ min-height:0;
+ overflow:auto;
+ overscroll-behavior:contain;
+ border:1px solid var(--am-border,#d8dde2);
+ border-radius:6px;
+ background:var(--am-surface,#fff);
+}
+
+.standard-data-table-scroll > .data-table{
+ margin:0;
+}
+
+/* v0.5.5.88 correction: keep the complete THEAD sticky.
+ The TH cells stay position:relative so resize handles keep the correct
+ positioning context. This combines the sticky behavior that already
+ worked in 0.5.5.86 with the unified resize logic from 0.5.5.87. */
+.standard-data-table-scroll > .data-table > thead{
+ position:sticky !important;
+ top:0 !important;
+ z-index:32;
+ /* Opaque background closes the tiny collapsed-border seam between the
+ title row and filter row so scrolled table content cannot shine through. */
+ background:#f5f7f9;
+}
+html[data-theme="dark"] .standard-data-table-scroll > .data-table > thead{
+ background:#232e39;
+}
+
+/* The THEAD owns vertical sticking. Individual header cells must therefore
+ not be sticky themselves, but they must remain positioned for resizers. */
+.standard-data-table-scroll > .data-table > thead > tr > th{
+ position:relative !important;
+ top:auto !important;
+ z-index:auto;
+ overflow:visible !important;
+ background-clip:padding-box;
+}
+.standard-data-table-scroll > .data-table > thead > tr:first-child > th{
+ background:#e7ecf0;
+}
+.standard-data-table-scroll > .data-table > thead > .column-filter-row > th{
+ background:#f5f7f9;
+}
+.standard-data-table-scroll > .data-table > thead > .column-filter-row > th.filter-cell-active{
+ background:#fff4c2;
+}
+html[data-theme="dark"] .standard-data-table-scroll > .data-table > thead > tr:first-child > th{
+ background:#293541;
+}
+html[data-theme="dark"] .standard-data-table-scroll > .data-table > thead > .column-filter-row > th{
+ background:#232e39;
+}
+html[data-theme="dark"] .standard-data-table-scroll > .data-table > thead > .column-filter-row > th.filter-cell-active{
+ background:#554612;
+}
+
+/* Sticky THEAD and resize handles must coexist: every resizable header remains
+ the absolute-positioning context for its handle. */
+.standard-data-table th.table-resizable-column-header,
+.standard-data-table th.table-row-number-header{
+ position:relative !important;
+ overflow:visible !important;
+}
+.standard-data-table th.table-resizable-column-header > .table-column-resizer,
+.standard-data-table th.table-row-number-header > .table-row-number-resizer{
+ position:absolute !important;
+ top:0 !important;
+ right:0 !important;
+ bottom:0 !important;
+ width:10px !important;
+ z-index:40 !important;
+}
+
diff --git a/app/static/js/table-tools.js b/app/static/js/table-tools.js
index 94bd7e2..23f4d66 100755
--- a/app/static/js/table-tools.js
+++ b/app/static/js/table-tools.js
@@ -165,14 +165,69 @@
return collator.compare(String(left.value), String(right.value));
}
+ function ensureStandardTableShell(table) {
+ table.classList.add('standard-data-table');
+
+ const shellSelector = '.standard-data-table-scroll, .table-scroll, .table-wrap, .sticky-table, .management-table-scroll';
+ const directParent = table.parentElement;
+ let shell = directParent?.matches(shellSelector) ? directParent : null;
+
+ if (!shell) {
+ const parent = table.parentNode;
+ if (!parent) return null;
+ shell = document.createElement('div');
+ shell.className = 'table-scroll standard-data-table-scroll';
+ parent.insertBefore(shell, table);
+ shell.appendChild(table);
+ } else {
+ shell.classList.add('standard-data-table-scroll');
+ }
+
+ return shell;
+ }
+
+ function ensureStableTableStorageKey(table) {
+ if (table.dataset.storageKey) return table.dataset.storageKey;
+ if (table.id) {
+ table.dataset.storageKey = table.id;
+ return table.id;
+ }
+ const tables = [...document.querySelectorAll('main table.data-table')];
+ const index = Math.max(0, tables.indexOf(table));
+ const key = `${window.location.pathname}:data-table-${index}`;
+ table.dataset.storageKey = key;
+ return key;
+ }
+
function initTable(table) {
const thead = table.tHead;
const tbody = table.tBodies[0];
if (!thead || !tbody || !thead.rows.length) return;
+
+ const tableShell = ensureStandardTableShell(table);
+ ensureStableTableStorageKey(table);
+
if (table.dataset.tableToolsReady === '1') return;
const headerRow = thead.rows[0];
+ // Sticky title/filter rows and resizable columns must coexist. Sticky is
+ // applied to the TH cells in CSS; this custom property supplies the exact
+ // offset for the filter row from the rendered title-row height.
+ function updateStickyHeaderMetrics() {
+ const measured = headerRow.getBoundingClientRect().height || headerRow.offsetHeight || 0;
+ if (measured > 0) {
+ table.style.setProperty('--table-title-row-height', `${Math.ceil(measured)}px`);
+ }
+ }
+
+ if (typeof ResizeObserver === 'function') {
+ const observer = new ResizeObserver(() => updateStickyHeaderMetrics());
+ observer.observe(headerRow);
+ table._stickyHeaderResizeObserver = observer;
+ }
+ window.addEventListener('resize', updateStickyHeaderMetrics);
+
// Add a purely visual row-number column before every real table column.
// It is recalculated after filtering, sorting and partial server reloads and
// therefore never represents a database identifier.
@@ -188,7 +243,7 @@
}
const numberHeader = headerRow.querySelector('th[data-row-number-column="1"]');
- const rowNumberStoragePart = table.dataset.storageKey || table.id || window.location.pathname;
+ const rowNumberStoragePart = ensureStableTableStorageKey(table);
const rowNumberWidthKey = `table:${rowNumberStoragePart}:row-number-width`;
const defaultRowNumberWidth = 64;
const minimumRowNumberWidth = 48;
@@ -1189,10 +1244,15 @@
});
thead.appendChild(filterRow);
- if (isAssetTable) {
- installColumnResizers();
- applyStoredColumnWidths();
- }
+ updateStickyHeaderMetrics();
+ requestAnimationFrame(updateStickyHeaderMetrics);
+
+ // All AssetManager data tables use the same resize implementation.
+ // This keeps width changes, table growth and user-state persistence
+ // identical across Assets, Software, Jobs, Settings and detail lists.
+ installColumnResizers();
+ applyStoredColumnWidths();
+ requestAnimationFrame(updateStickyHeaderMetrics);
// Editable cells are always read live, so changed values immediately
// participate in filtering without a stale cache.
@@ -1210,6 +1270,7 @@
table.assetUpdateRowNumbers = updateRowNumbers;
table.assetRebuildFilterCache = rebuildMissingCellKeys;
table.assetApplyStoredColumnWidths = applyStoredColumnWidths;
+ table.assetUpdateStickyHeaderMetrics = updateStickyHeaderMetrics;
table.dataset.tableToolsReady = '1';
ensureFilterStatus();
@@ -1352,7 +1413,7 @@
const initializeTableTools = () => {
document.querySelectorAll('table.data-table').forEach(initTable);
- document.querySelectorAll('main table:not(#asset-table):not([data-column-resize="off"])').forEach(initUniversalTableResize);
+ document.querySelectorAll('main table:not(.data-table):not([data-column-resize="off"])').forEach(initUniversalTableResize);
};
if (uiState?.ready) uiState.ready.finally(initializeTableTools);
else initializeTableTools();
@@ -1369,6 +1430,7 @@
}
table.assetUpdateRowNumbers?.();
table.assetApplyStoredColumnWidths?.();
+ requestAnimationFrame(() => table.assetUpdateStickyHeaderMetrics?.());
}
});
})();
diff --git a/app/templates/meshcentral_sync.html b/app/templates/meshcentral_sync.html
index e525071..12eb91c 100755
--- a/app/templates/meshcentral_sync.html
+++ b/app/templates/meshcentral_sync.html
@@ -128,7 +128,7 @@
-{{t('mesh.recent_runs')}}
| {{t('mesh.start_time')}} | {{t('assets.status')}} | {{t('mesh.found')}} | {{t('mesh.created')}} | {{t('mesh.changed')}} | {{t('mesh.conflicts')}} | {{t('mesh.errors')}} | {{t('mesh.message')}} |
{% for run in runs %} | {{run.status}} | {{run.devices_found}} | {{run.created_count}} | {{run.updated_count}} | {{run.conflict_count}} | {{run.error_count}} | {{run.message or ''}} |
{% else %}| {{t('mesh.no_runs')}} |
{% endfor %}
+{{t('mesh.recent_runs')}}
| {{t('mesh.start_time')}} | {{t('assets.status')}} | {{t('mesh.found')}} | {{t('mesh.created')}} | {{t('mesh.changed')}} | {{t('mesh.conflicts')}} | {{t('mesh.errors')}} | {{t('mesh.message')}} |
{% for run in runs %} | {{run.status}} | {{run.devices_found}} | {{run.created_count}} | {{run.updated_count}} | {{run.conflict_count}} | {{run.error_count}} | {{run.message or ''}} |
{% else %}| {{t('mesh.no_runs')}} |
{% endfor %}