Optimize table view and export for software lists added. excelexport fixed - first line

This commit is contained in:
2026-09-24 11:38:40 +02:00
parent 80f6136399
commit 8de15f223c
16 changed files with 320 additions and 24 deletions
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
0.5.5.84
0.5.5.88
+6
View File
@@ -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"),
+51 -10
View File
@@ -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(),
+1
View File
@@ -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",
}
+1
View File
@@ -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):
+81
View File
@@ -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;
}
+66 -4
View File
@@ -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) {
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?.());
}
});
})();
+1 -1
View File
@@ -128,7 +128,7 @@
</form>
</section>
<section class="panel"><h2>{{t('mesh.recent_runs')}}</h2><table><thead><tr><th>{{t('mesh.start_time')}}</th><th>{{t('assets.status')}}</th><th>{{t('mesh.found')}}</th><th>{{t('mesh.created')}}</th><th>{{t('mesh.changed')}}</th><th>{{t('mesh.conflicts')}}</th><th>{{t('mesh.errors')}}</th><th>{{t('mesh.message')}}</th></tr></thead><tbody>{% for run in runs %}<tr><td><time class="local-time" datetime="{{ utc_iso(run.started_at) }}" data-utc="{{ utc_iso(run.started_at) }}">{{ run.started_at }}</time></td><td>{{run.status}}</td><td>{{run.devices_found}}</td><td>{{run.created_count}}</td><td>{{run.updated_count}}</td><td>{{run.conflict_count}}</td><td>{{run.error_count}}</td><td>{{run.message or ''}}</td></tr>{% else %}<tr><td colspan="8">{{t('mesh.no_runs')}}</td></tr>{% endfor %}</tbody></table></section>
<section class="panel"><h2>{{t('mesh.recent_runs')}}</h2><table class="data-table" data-storage-key="meshcentral-recent-runs"><thead><tr><th>{{t('mesh.start_time')}}</th><th>{{t('assets.status')}}</th><th>{{t('mesh.found')}}</th><th>{{t('mesh.created')}}</th><th>{{t('mesh.changed')}}</th><th>{{t('mesh.conflicts')}}</th><th>{{t('mesh.errors')}}</th><th>{{t('mesh.message')}}</th></tr></thead><tbody>{% for run in runs %}<tr><td><time class="local-time" datetime="{{ utc_iso(run.started_at) }}" data-utc="{{ utc_iso(run.started_at) }}">{{ run.started_at }}</time></td><td>{{run.status}}</td><td>{{run.devices_found}}</td><td>{{run.created_count}}</td><td>{{run.updated_count}}</td><td>{{run.conflict_count}}</td><td>{{run.error_count}}</td><td>{{run.message or ''}}</td></tr>{% else %}<tr><td colspan="8">{{t('mesh.no_runs')}}</td></tr>{% endfor %}</tbody></table></section>
<script>(()=>{
const startButton=document.getElementById('sync-start');
const dryButton=document.getElementById('sync-dry-run');
+5 -3
View File
@@ -1,6 +1,8 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{ t('app.status_values') }}</h1></div><p>{{ t('status.help') }}</p>
<form method="post"><div class="table-scroll management-table-scroll"><table class="data-table"><thead><tr><th>{{ t('common.order') }}</th><th>{{ t('assets.status') }}</th><th>{{ t('common.active') }}</th><th>{{ t('status.on_issue') }}</th><th>{{ t('status.on_return') }}</th><th>{{ t('common.actions') }}</th></tr></thead><tbody>
{% for option in options %}<tr><td><input type="number" name="sort_{{option.id}}" value="{{option.sort_order}}"></td><td><input name="name_{{option.id}}" value="{{option.name}}" required></td><td><input class="checkbox" type="checkbox" name="active_{{option.id}}" {% if option.active %}checked{% endif %}></td><td><input type="radio" name="issue_status_id" value="{{ option.id }}" {% if option.use_for_issue %}checked{% endif %}></td><td><input type="radio" name="return_status_id" value="{{ option.id }}" {% if option.use_for_return %}checked{% endif %}></td><td><button formmethod="post" formaction="/status-options/{{option.id}}/delete" class="icon-button button-danger" onclick="return confirm({{ t('status.delete_confirm')|tojson }});">🗑</button></td></tr>{% endfor %}
<tr><td>{{ t('common.new') }}</td><td><input name="new_name" placeholder="{{ t('status.new_value') }}"></td><td colspan="4"></td></tr></tbody></table></div><div class="form-actions"><button class="button button-save" type="submit">💾 {{ t('common.save') }}</button></div></form>
<p class="muted">{{ t('status.software_list_help') }}</p>
<form method="post"><div class="table-scroll management-table-scroll"><table class="data-table"><thead><tr><th>{{ t('common.order') }}</th><th>{{ t('assets.status') }}</th><th>{{ t('common.active') }}</th><th>{{ t('status.on_issue') }}</th><th>{{ t('status.on_return') }}</th><th>{{ t('status.show_in_software_list') }}</th><th>{{ t('common.actions') }}</th></tr></thead><tbody>
{% for option in options %}<tr><td><input type="number" name="sort_{{option.id}}" value="{{option.sort_order}}"></td><td><input name="name_{{option.id}}" value="{{option.name}}" required></td><td><input class="checkbox" type="checkbox" name="active_{{option.id}}" {% if option.active %}checked{% endif %}></td><td><input type="radio" name="issue_status_id" value="{{ option.id }}" {% if option.use_for_issue %}checked{% endif %}></td><td><input type="radio" name="return_status_id" value="{{ option.id }}" {% if option.use_for_return %}checked{% endif %}></td><td><input class="checkbox" type="checkbox" name="software_list_{{option.id}}" {% if option.show_in_software_list %}checked{% endif %}></td><td><button formmethod="post" formaction="/status-options/{{option.id}}/delete" class="icon-button button-danger" onclick="return confirm({{ t('status.delete_confirm')|tojson }});">🗑</button></td></tr>{% endfor %}
<tr><td>{{ t('common.new') }}</td><td><input name="new_name" placeholder="{{ t('status.new_value') }}"></td><td colspan="3"></td><td><input class="checkbox" type="checkbox" name="new_show_in_software_list" checked></td><td></td></tr></tbody></table></div><div class="form-actions"><button class="button button-save" type="submit">💾 {{ t('common.save') }}</button></div></form>
<p class="form-help">{{ t('status.software_list_help') }}</p>
{% endblock %}
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.84"
APP_VERSION = "0.5.5.88"
__version__ = APP_VERSION
+5 -1
View File
@@ -1,3 +1,7 @@
- [0.5.5.88](UPDATE-0.5.5.88.md) - keep title/filter rows sticky while preserving unified column resizing.
- [0.5.5.87](UPDATE-0.5.5.87.md) - unify interactive table behavior and restore column resizing with sticky two-row headers.
- [0.5.5.86](UPDATE-0.5.5.86.md) - fix status filtering so assets without a status remain visible in global software lists.
- [0.5.5.85](UPDATE-0.5.5.85.md) - add per-status control for inclusion in global software lists, statistics and Excel exports.
- [0.5.5.84](UPDATE-0.5.5.84.md) - add Excel export to detailed and aggregated software inventory lists.
- [0.5.5.83](UPDATE-0.5.5.83.md) - fix Windows PowerShell compatibility for post-install application start.
- [0.5.5.82](UPDATE-0.5.5.82.md) - add optional post-install application launch in the active interactive user session.
@@ -20,7 +24,7 @@
Release notes are stored outside the project root to keep the repository overview compact.
The current release is **0.5.5.84**.
The current release is **0.5.5.88**.
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
+25
View File
@@ -0,0 +1,25 @@
# Update 0.5.5.85
## Added
- New status flag **In software lists** / **In Softwarelisten**.
- Existing status values default to enabled during the lightweight database migration.
- Newly created status values default to enabled and can be changed directly in the status administration.
## Applied to
The flag controls inclusion in:
- the detailed global software installation list,
- the aggregated software list,
- software overview counters and statistics,
- the detailed Excel software export,
- the aggregated Excel software export.
Software inventory shown directly on an individual asset is intentionally not filtered by this flag.
## Database
`status_options.show_in_software_list` is added as `BOOLEAN NOT NULL DEFAULT TRUE`.
This preserves the previous behavior after the update until an administrator explicitly disables the flag for one or more status values.
+28
View File
@@ -0,0 +1,28 @@
# Update 0.5.5.86
## Fixed
- Global software lists no longer require every asset to have a matching status option.
- Assets with no assigned status remain included.
- Assets with legacy/unmatched status text remain included.
- Only assets whose matching status explicitly has `In Softwarelisten` disabled are excluded.
- Renaming a status option now updates the status text of existing assets so the relationship is preserved.
## Background
Version 0.5.5.85 used an inner join between `assets.status` and `status_options.name`.
Assets created by MeshCentral can have no status assigned, so those assets disappeared from
all global software lists even when all configured status flags were enabled.
## Additional fixes
- Software table header rows remain visible while scrolling the detailed and aggregated lists.
- The generated table filter row remains fixed directly below the column headers.
- Excel exports now write column headers to row 1 before enabling AutoFilter.
- Excel AutoFilter spans the complete exported data range and row 1 remains frozen.
## Sticky table header follow-up
- Keep the complete table header section (`thead`) sticky instead of positioning the title and filter cells independently.
- Title row and generated filter row now remain together while scrolling in the asset list and both global software lists.
- Removed the dependency on a fixed 41 px title-row height for these tables.
- Excel export behavior from the previous 0.5.5.86 build remains unchanged.
+23
View File
@@ -0,0 +1,23 @@
# Update 0.5.5.87
## Fixed
- Restored column resizing after the sticky-header change from 0.5.5.86.
- Header cells remain relative positioning contexts while the complete `thead` is sticky.
- Removed the separate data-table resize path for non-Asset tables.
## Unified table behavior
All interactive `data-table` views now use the same central table-tools implementation for:
- title row and generated filter row,
- sticky two-row header,
- column filtering and sorting,
- visual row numbers,
- column resizing without moving neighboring columns,
- table-width growth/shrink matching the resized column,
- per-user persistence of column widths, filters and sorting.
Existing views without an explicit scroll wrapper are wrapped automatically by the shared table component. This keeps new and older list views on the same behavior without page-specific CSS fixes.
The MeshCentral recent-run list was also moved to the shared `data-table` component. HTML-only privacy report tables remain intentionally outside the interactive table system.
+22
View File
@@ -0,0 +1,22 @@
# Update 0.5.5.88
## Fixed
- Keeps the complete table header (`thead`) fixed while vertically scrolling, so the title row and generated filter row always move together.
- Keeps every header cell positioned (`position: relative`) so the existing column-resize handles continue to use the correct positioning context.
- Preserves the unified column-width behavior from 0.5.5.87: widening one column grows the table while neighboring columns keep their width.
- Preserves persisted column widths, filtering, sorting and column movement.
- Applies the same behavior to all interactive `data-table` lists.
## Result
Interactive tables use one common behavior for:
- sticky title row,
- sticky filter row,
- resizable columns,
- table growth while widening columns,
- persisted column widths,
- filtering, sorting and column movement.
The final 0.5.5.88 correction keeps `thead` sticky and the individual `th` elements relative. This combination avoids the conflict between sticky headers and absolutely positioned resize handles.
- Close the small visual seam between sticky title and filter rows with an opaque THEAD background.