Log Startdialog added

This commit is contained in:
2026-09-29 11:40:50 +02:00
parent 1a8f654de9
commit c3f1ef43db
13 changed files with 274 additions and 123 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# AssetManager 0.5.5.91 # AssetManager 0.5.5.92
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. 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.91 0.5.5.92
+85
View File
@@ -23,6 +23,8 @@ import ssl
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import importlib.metadata
import platform
from typing import Any from typing import Any
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from urllib.parse import quote from urllib.parse import quote
@@ -214,6 +216,79 @@ if not ldap_logger.handlers:
ldap_console_handler.setFormatter(logging.Formatter("LDAP | %(levelname)s | %(message)s")) ldap_console_handler.setFormatter(logging.Formatter("LDAP | %(levelname)s | %(message)s"))
ldap_logger.addHandler(ldap_console_handler) ldap_logger.addHandler(ldap_console_handler)
def _runtime_package_version(distribution_name: str) -> str:
try:
return importlib.metadata.version(distribution_name)
except importlib.metadata.PackageNotFoundError:
return "not installed"
except Exception:
return "unknown"
def _database_server_version() -> str:
try:
with engine.connect() as connection:
version = connection.exec_driver_sql("SELECT version()").scalar()
return str(version or "unknown").split(",", 1)[0]
except Exception as exc:
return f"unavailable ({exc.__class__.__name__})"
def _configured_callback_base_url() -> str:
try:
software = load_config().get("software", {}) or {}
configured = str(software.get("callback_base_url", "") or "").strip().rstrip("/")
if configured:
return configured
general = load_config().get("general", {}) or {}
return str(general.get("callback_base_url", "") or "").strip().rstrip("/")
except Exception:
return ""
def _startup_banner(role: str) -> None:
role_name = "Application" if role == "app" else "Callback"
callback_base = _configured_callback_base_url()
callback_bind = str(os.getenv("CALLBACK_BIND_IP", "127.0.0.1") or "127.0.0.1")
callback_port = str(os.getenv("CALLBACK_PORT", "8090") or "8090")
internal_callback = str(os.getenv("CALLBACK_INTERNAL_BASE_URL", "http://callback:8001") or "http://callback:8001").rstrip("/")
lines = [
"=" * 80,
f" AssetManager - {role_name} Startup",
"=" * 80,
f" AssetManager version : {application_version()}",
f" Container role : {role}",
f" Container version : {application_version()}",
f" Image reference : {os.getenv('ASSETMANAGER_IMAGE_REF', 'not provided')}",
f" Build version : {APP_VERSION}",
f" Build date : {os.getenv('ASSETMANAGER_BUILD_DATE', 'not provided')}",
f" Git commit : {os.getenv('ASSETMANAGER_GIT_COMMIT', 'not provided')}",
f" Python : {platform.python_version()}",
f" FastAPI : {_runtime_package_version('fastapi')}",
f" Uvicorn : {_runtime_package_version('uvicorn')}",
f" SQLAlchemy : {_runtime_package_version('sqlalchemy')}",
f" psycopg : {_runtime_package_version('psycopg')}",
f" Database : {_database_server_version()}",
f" Data directory : {DATA_ROOT}",
f" Startup time UTC : {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')}",
]
if role == "callback":
lines.extend([
"-" * 80,
f" Callback base URL : {callback_base or '<automatic / not fixed>'}",
f" Callback API URL : {(callback_base + '/api/software-jobs/<job_id>/callback') if callback_base else '<resolved by application per job>'}",
f" Callback health URL : {(callback_base + '/api/software-callback/health') if callback_base else '<resolved from callback base URL>'}",
f" Internal callback : {internal_callback}",
f" Published listener : {callback_bind}:{callback_port} -> container:8001",
])
lines.extend([
"-" * 80,
f" AssetManager {role} ready",
"=" * 80,
])
print("\n".join(lines), flush=True)
app = FastAPI(title=os.getenv("APP_TITLE", "AssetManager")) app = FastAPI(title=os.getenv("APP_TITLE", "AssetManager"))
# Dedicated callback-only ASGI application. It deliberately exposes only the # Dedicated callback-only ASGI application. It deliberately exposes only the
# software callback endpoint and its health endpoint so deployments may publish # software callback endpoint and its health endpoint so deployments may publish
@@ -956,6 +1031,16 @@ async def job_definition_script_generator(request: Request):
return JSONResponse({"script": script, "interpreter": interpreter, "action": action}) return JSONResponse({"script": script, "interpreter": interpreter, "action": action})
@app.on_event("startup")
def log_application_startup():
_startup_banner("app")
@callback_app.on_event("startup")
def log_callback_startup():
_startup_banner("callback")
@app.on_event("startup") @app.on_event("startup")
def start_backup_scheduler(): def start_backup_scheduler():
global BACKUP_THREAD global BACKUP_THREAD
+151 -111
View File
@@ -21,6 +21,7 @@ from .software_packages import build_deployment_user_script, load_package_manife
_DISPATCH_SLOT_LOCK = threading.Lock() _DISPATCH_SLOT_LOCK = threading.Lock()
_NEXT_DISPATCH_MONOTONIC = 0.0 _NEXT_DISPATCH_MONOTONIC = 0.0
_SOFTWARE_INVENTORY_MESHCTRL_LOCK = threading.Lock()
def _wait_for_dispatch_slot() -> tuple[float, float]: def _wait_for_dispatch_slot() -> tuple[float, float]:
@@ -744,134 +745,173 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
f'Execution timeout: {execution_timeout_seconds or timeout} seconds', f'Execution timeout: {execution_timeout_seconds or timeout} seconds',
] ]
if upload_required: inventory_meshctrl_lock_acquired = False
suffix=_script_extension(interpreter) if job.job_type == 'software_inventory':
temp_dir=tempfile.mkdtemp(prefix=f'assetmanager-job-{job.id}-') lock_started = time.monotonic()
temp_path=str(Path(temp_dir)/('run'+suffix)) _SOFTWARE_INVENTORY_MESHCTRL_LOCK.acquire()
Path(temp_path).write_text(payload,encoding='utf-8',newline='\n') inventory_meshctrl_lock_acquired = True
diagnostics += [f'Remote directory: {remote_dir}',f'Remote file: {remote_file}',f'Local staging bytes: {os.path.getsize(temp_path)}'] diagnostics += [
'MeshCtrl isolation: software inventory serialized',
f'MeshCtrl isolation wait: {time.monotonic() - lock_started:.3f} seconds',
]
try:
if upload_required:
suffix=_script_extension(interpreter)
temp_dir=tempfile.mkdtemp(prefix=f'assetmanager-job-{job.id}-')
temp_path=str(Path(temp_dir)/('run'+suffix))
Path(temp_path).write_text(payload,encoding='utf-8',newline='\n')
diagnostics += [f'Remote directory: {remote_dir}',f'Remote file: {remote_file}',f'Local staging bytes: {os.path.getsize(temp_path)}']
if remote_cleanup_enabled: if remote_cleanup_enabled:
stale_cleanup=_cleanup_stale_remote_job_directories( stale_cleanup=_cleanup_stale_remote_job_directories(
cfg,asset,password,job.platform,remote_dir,remote_job_retention_hours,min(timeout,120) cfg,asset,password,job.platform,remote_dir,remote_job_retention_hours,min(timeout,120)
) )
diagnostics += [ diagnostics += [
'--- Stale remote job cleanup stdout ---',stale_cleanup.stdout or '', '--- Stale remote job cleanup stdout ---',stale_cleanup.stdout or '',
'--- Stale remote job cleanup stderr ---',stale_cleanup.stderr or '', '--- Stale remote job cleanup stderr ---',stale_cleanup.stderr or '',
f'Stale remote job cleanup return code: {stale_cleanup.returncode}', f'Stale remote job cleanup return code: {stale_cleanup.returncode}',
] ]
prepared=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,timeout,remote_file) prepared=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,timeout,remote_file)
diagnostics += ['--- Prepare directory stdout ---',prepared.stdout or '','--- Prepare directory stderr ---',prepared.stderr or '',f'Prepare return code: {prepared.returncode}'] diagnostics += ['--- Prepare directory stdout ---',prepared.stdout or '','--- Prepare directory stderr ---',prepared.stderr or '',f'Prepare return code: {prepared.returncode}']
if prepared.returncode!=0: raise RuntimeError(f'Remote Jobverzeichnis konnte nicht erstellt werden: {prepared.stderr or prepared.stdout}') if prepared.returncode!=0: raise RuntimeError(f'Remote Jobverzeichnis konnte nicht erstellt werden: {prepared.stderr or prepared.stdout}')
upload_results=[] upload_results=[]
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
upload_results.append(uploaded)
diagnostics += ['--- Upload attempt 1 stdout ---',uploaded.stdout or '','--- Upload attempt 1 stderr ---',uploaded.stderr or '',f'Upload attempt 1 return code: {uploaded.returncode}']
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
if not upload_ok:
# MeshCtrl can return code 0 together with "Upload error".
# Remove only the stale target and retry once. Do not touch ACLs of already uploaded files.
repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file,reset_acl=False)
diagnostics += ['--- Upload repair stdout ---',repair.stdout or '','--- Upload repair stderr ---',repair.stderr or '',f'Upload repair return code: {repair.returncode}']
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout) uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
upload_results.append(uploaded) upload_results.append(uploaded)
diagnostics += ['--- Upload attempt 2 stdout ---',uploaded.stdout or '','--- Upload attempt 2 stderr ---',uploaded.stderr or '',f'Upload attempt 2 return code: {uploaded.returncode}'] diagnostics += ['--- Upload attempt 1 stdout ---',uploaded.stdout or '','--- Upload attempt 1 stderr ---',uploaded.stderr or '',f'Upload attempt 1 return code: {uploaded.returncode}']
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '') upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
if not upload_ok: if not upload_ok:
combined='\n\n'.join(((item.stdout or '')+'\n'+(item.stderr or '')).strip() for item in upload_results if ((item.stdout or '')+(item.stderr or '')).strip()) # MeshCtrl can return code 0 together with "Upload error".
raise RuntimeError(f'MeshCentral-Dateiupload fehlgeschlagen nach 2 Versuchen: {combined.strip()}') # Remove only the stale target and retry once. Do not touch ACLs of already uploaded files.
repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file,reset_acl=False)
diagnostics += ['--- Upload repair stdout ---',repair.stdout or '','--- Upload repair stderr ---',repair.stderr or '',f'Upload repair return code: {repair.returncode}']
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
upload_results.append(uploaded)
diagnostics += ['--- Upload attempt 2 stdout ---',uploaded.stdout or '','--- Upload attempt 2 stderr ---',uploaded.stderr or '',f'Upload attempt 2 return code: {uploaded.returncode}']
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
if not upload_ok:
combined='\n\n'.join(((item.stdout or '')+'\n'+(item.stderr or '')).strip() for item in upload_results if ((item.stdout or '')+(item.stderr or '')).strip())
raise RuntimeError(f'MeshCentral-Dateiupload fehlgeschlagen nach 2 Versuchen: {combined.strip()}')
if job.job_type == 'software_deployment': if job.job_type == 'software_deployment':
payload_files = package_payload_files(job.package_id, job.action) payload_files = package_payload_files(job.package_id, job.action)
diagnostics += [f'Package payload files: {len(payload_files)}'] diagnostics += [f'Package payload files: {len(payload_files)}']
for package_file in payload_files: for package_file in payload_files:
remote_package_file = remote_dir + '\\' + package_file.name remote_package_file = remote_dir + '\\' + package_file.name
package_uploads = [] package_uploads = []
package_uploaded = _upload_script(cfg, asset, password, str(package_file), remote_dir, timeout)
package_uploads.append(package_uploaded)
diagnostics += [
f'--- Package upload {package_file.name} attempt 1 stdout ---', package_uploaded.stdout or '',
f'--- Package upload {package_file.name} attempt 1 stderr ---', package_uploaded.stderr or '',
f'Package upload {package_file.name} attempt 1 return code: {package_uploaded.returncode}',
]
package_text = (package_uploaded.stdout or '') + '\n' + (package_uploaded.stderr or '')
package_ok = package_uploaded.returncode == 0 and 'Upload done' in package_text and 'Upload error' not in package_text
if not package_ok:
package_repair = _prepare_remote_directory(
cfg,
asset,
password,
job.platform,
remote_dir,
min(timeout, 120),
remote_package_file,
reset_acl=False,
)
diagnostics += [
f'--- Package upload {package_file.name} repair stdout ---', package_repair.stdout or '',
f'--- Package upload {package_file.name} repair stderr ---', package_repair.stderr or '',
f'Package upload {package_file.name} repair return code: {package_repair.returncode}',
]
package_uploaded = _upload_script(cfg, asset, password, str(package_file), remote_dir, timeout) package_uploaded = _upload_script(cfg, asset, password, str(package_file), remote_dir, timeout)
package_uploads.append(package_uploaded) package_uploads.append(package_uploaded)
diagnostics += [ diagnostics += [
f'--- Package upload {package_file.name} attempt 2 stdout ---', package_uploaded.stdout or '', f'--- Package upload {package_file.name} attempt 1 stdout ---', package_uploaded.stdout or '',
f'--- Package upload {package_file.name} attempt 2 stderr ---', package_uploaded.stderr or '', f'--- Package upload {package_file.name} attempt 1 stderr ---', package_uploaded.stderr or '',
f'Package upload {package_file.name} attempt 2 return code: {package_uploaded.returncode}', f'Package upload {package_file.name} attempt 1 return code: {package_uploaded.returncode}',
] ]
package_text = (package_uploaded.stdout or '') + '\n' + (package_uploaded.stderr or '') package_text = (package_uploaded.stdout or '') + '\n' + (package_uploaded.stderr or '')
package_ok = package_uploaded.returncode == 0 and 'Upload done' in package_text and 'Upload error' not in package_text package_ok = package_uploaded.returncode == 0 and 'Upload done' in package_text and 'Upload error' not in package_text
if not package_ok: if not package_ok:
combined = '\n\n'.join( package_repair = _prepare_remote_directory(
((item.stdout or '') + '\n' + (item.stderr or '')).strip() cfg,
for item in package_uploads asset,
if ((item.stdout or '') + (item.stderr or '')).strip() password,
) job.platform,
raise RuntimeError( remote_dir,
f'MeshCentral-Paketdateiupload fehlgeschlagen fuer {package_file.name}: {combined.strip()}' min(timeout, 120),
) remote_package_file,
reset_acl=False,
)
diagnostics += [
f'--- Package upload {package_file.name} repair stdout ---', package_repair.stdout or '',
f'--- Package upload {package_file.name} repair stderr ---', package_repair.stderr or '',
f'Package upload {package_file.name} repair return code: {package_repair.returncode}',
]
package_uploaded = _upload_script(cfg, asset, password, str(package_file), remote_dir, timeout)
package_uploads.append(package_uploaded)
diagnostics += [
f'--- Package upload {package_file.name} attempt 2 stdout ---', package_uploaded.stdout or '',
f'--- Package upload {package_file.name} attempt 2 stderr ---', package_uploaded.stderr or '',
f'Package upload {package_file.name} attempt 2 return code: {package_uploaded.returncode}',
]
package_text = (package_uploaded.stdout or '') + '\n' + (package_uploaded.stderr or '')
package_ok = package_uploaded.returncode == 0 and 'Upload done' in package_text and 'Upload error' not in package_text
if not package_ok:
combined = '\n\n'.join(
((item.stdout or '') + '\n' + (item.stderr or '')).strip()
for item in package_uploads
if ((item.stdout or '') + (item.stderr or '')).strip()
)
raise RuntimeError(
f'MeshCentral-Paketdateiupload fehlgeschlagen fuer {package_file.name}: {combined.strip()}'
)
preflight=_remote_script_preflight(cfg,asset,password,job.platform,remote_file,min(timeout,120)) preflight_ok = False
diagnostics += [ preflight_attempts = 3
'--- Remote script preflight stdout ---', preflight.stdout or '', for preflight_attempt in range(1, preflight_attempts + 1):
'--- Remote script preflight stderr ---', preflight.stderr or '', preflight = _remote_script_preflight(
f'Remote script preflight return code: {preflight.returncode}', cfg,
] asset,
preflight_text=(preflight.stdout or '')+'\n'+(preflight.stderr or '') password,
if preflight.returncode!=0 or (job.platform=='windows' and ('File exists: True' not in preflight_text or 'Readable: True' not in preflight_text)): job.platform,
raise RuntimeError('Remote Jobskript ist nach dem Upload nicht lesbar. Siehe Remote script preflight im Dispatcherlog.') remote_file,
min(timeout, 120),
)
diagnostics += [
f'--- Remote script preflight attempt {preflight_attempt}/{preflight_attempts} stdout ---',
preflight.stdout or '',
f'--- Remote script preflight attempt {preflight_attempt}/{preflight_attempts} stderr ---',
preflight.stderr or '',
f'Remote script preflight attempt {preflight_attempt}/{preflight_attempts} return code: {preflight.returncode}',
]
preflight_text = (preflight.stdout or '') + '\n' + (preflight.stderr or '')
preflight_ok = preflight.returncode == 0 and (
job.platform != 'windows'
or ('File exists: True' in preflight_text and 'Readable: True' in preflight_text)
)
if preflight_ok:
break
if preflight_attempt < preflight_attempts:
diagnostics += [
f'Remote script preflight attempt {preflight_attempt}/{preflight_attempts}: not readable',
'Retrying remote script preflight in 1 second...',
]
time.sleep(1)
diagnostics += [f'Remote execution shell: {launch_shell}',f'Remote execution command: {launch_command}',f'MeshCtrl PowerShell mode: False'] if not preflight_ok:
started=datetime.utcnow() raise RuntimeError('Remote Jobskript ist nach dem Upload nicht lesbar. Siehe Remote script preflight im Dispatcherlog.')
result=_launch_uploaded_script(cfg,asset,password,job.platform,interpreter,remote_file,timeout)
finished=datetime.utcnow()
diagnostics += [f'Execution started UTC: {started.isoformat()}Z',f'Execution finished UTC: {finished.isoformat()}Z','--- Execution stdout ---',result.stdout or '','--- Execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
else:
started=datetime.utcnow()
action=['RunCommand','--id',asset.mesh_node_id,'--run',payload,'--reply']
if job.platform=='windows' and interpreter=='powershell': action += ['--powershell']
result=_run_meshctrl(cfg,asset,password,action,timeout)
finished=datetime.utcnow()
diagnostics += ['--- Direct execution stdout ---',result.stdout or '','--- Direct execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
now=datetime.utcnow() diagnostics += [f'Remote execution shell: {launch_shell}',f'Remote execution command: {launch_command}',f'MeshCtrl PowerShell mode: False']
# MeshCtrl itself returns code 0 even for some remote PowerShell parser started=datetime.utcnow()
# errors. Only delete the remote files after a completed successful result=_launch_uploaded_script(cfg,asset,password,job.platform,interpreter,remote_file,timeout)
# callback, never merely because the MeshCtrl process exited cleanly. finished=datetime.utcnow()
db.expire_all() diagnostics += [f'Execution started UTC: {started.isoformat()}Z',f'Execution finished UTC: {finished.isoformat()}Z','--- Execution stdout ---',result.stdout or '','--- Execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
callback_state=db.get(SoftwareJob,job.id) else:
cleanup_result=None started=datetime.utcnow()
callback_success=bool(callback_state and callback_state.callback_completed_at is not None and callback_state.status=='success') action=['RunCommand','--id',asset.mesh_node_id,'--run',payload,'--reply']
diagnostics += [f'Callback completed before cleanup: {bool(callback_state and callback_state.callback_completed_at)}',f'Callback status before cleanup: {callback_state.status if callback_state else "unknown"}'] if job.platform=='windows' and interpreter=='powershell': action += ['--powershell']
if upload_required and callback_success: result=_run_meshctrl(cfg,asset,password,action,timeout)
cleanup_result=_cleanup_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120)) finished=datetime.utcnow()
diagnostics += ['--- Cleanup stdout ---',cleanup_result.stdout or '','--- Cleanup stderr ---',cleanup_result.stderr or '',f'Cleanup return code: {cleanup_result.returncode}'] diagnostics += ['--- Direct execution stdout ---',result.stdout or '','--- Direct execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
elif upload_required:
diagnostics += ['Remote cleanup skipped: no successful completed callback; files retained for diagnosis.'] now=datetime.utcnow()
# MeshCtrl itself returns code 0 even for some remote PowerShell parser
# errors. Only delete the remote files after a completed successful
# callback, never merely because the MeshCtrl process exited cleanly.
db.expire_all()
callback_state=db.get(SoftwareJob,job.id)
cleanup_result=None
callback_success=bool(callback_state and callback_state.callback_completed_at is not None and callback_state.status=='success')
diagnostics += [f'Callback completed before cleanup: {bool(callback_state and callback_state.callback_completed_at)}',f'Callback status before cleanup: {callback_state.status if callback_state else "unknown"}']
if upload_required and callback_success:
cleanup_result=_cleanup_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120))
diagnostics += ['--- Cleanup stdout ---',cleanup_result.stdout or '','--- Cleanup stderr ---',cleanup_result.stderr or '',f'Cleanup return code: {cleanup_result.returncode}']
elif upload_required:
diagnostics += ['Remote cleanup skipped: no successful completed callback; files retained for diagnosis.']
finally:
if inventory_meshctrl_lock_acquired:
_SOFTWARE_INVENTORY_MESHCTRL_LOCK.release()
db.query(SoftwareJob).filter(SoftwareJob.id==job.id).update({SoftwareJob.meshctrl_stdout:'\n'.join(diagnostics)[-30000:],SoftwareJob.meshctrl_stderr:(result.stderr or '')[-20000:],SoftwareJob.sent_at:now},synchronize_session=False) db.query(SoftwareJob).filter(SoftwareJob.id==job.id).update({SoftwareJob.meshctrl_stdout:'\n'.join(diagnostics)[-30000:],SoftwareJob.meshctrl_stderr:(result.stderr or '')[-20000:],SoftwareJob.sent_at:now},synchronize_session=False)
db.commit() db.commit()
+2 -2
View File
@@ -312,6 +312,6 @@
</script> </script>
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ today }}" required></label><div class="form-actions"><button type="button" class="button button-secondary" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit" class="button button-issue">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %} {% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ today }}" required></label><div class="form-actions"><button type="button" class="button button-secondary" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit" class="button button-issue">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}
<script src="/static/js/issue-dialog.js"></script> <script src="/static/js/issue-dialog.js?v={{ application_version() }}"></script>
<script src="/static/js/asset-inventory.js"></script> <script src="/static/js/asset-inventory.js?v={{ application_version() }}"></script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -625,7 +625,7 @@
})(); })();
</script> </script>
{% endif %} {% endif %}
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ now().strftime('%Y-%m-%d') if now is defined else '' }}" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js"></script> {% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ now().strftime('%Y-%m-%d') if now is defined else '' }}" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js?v={{ application_version() }}"></script>
<script> <script>
(() => { (() => {
+2 -2
View File
@@ -105,7 +105,7 @@
{% endif %} {% endif %}
</aside> </aside>
<main class="app-content">{% block content %}{% endblock %}</main> <main class="app-content">{% block content %}{% endblock %}</main>
</div><div id="toast-container" class="toast-container" aria-live="polite"></div><script src="/static/js/local-time.js"></script><script src="/static/js/browser-state.js?v={{ app_version }}"></script><script src="/static/js/table-tools.js?v={{ app_version }}"></script><script src="/static/js/list-tools.js?v={{ app_version }}"></script><script src="/static/js/asset-columns.js?v={{ app_version }}"></script><script src="/static/js/asset-duplicates.js?v={{ app_version }}"></script><script src="/static/js/toasts.js"></script><script src="/static/js/image-editor.js?v={{ app_version }}"></script><script> </div><div id="toast-container" class="toast-container" aria-live="polite"></div><script src="/static/js/local-time.js?v={{ application_version() }}"></script><script src="/static/js/browser-state.js?v={{ application_version() }}"></script><script src="/static/js/table-tools.js?v={{ application_version() }}"></script><script src="/static/js/list-tools.js?v={{ application_version() }}"></script><script src="/static/js/asset-columns.js?v={{ application_version() }}"></script><script src="/static/js/asset-duplicates.js?v={{ application_version() }}"></script><script src="/static/js/toasts.js?v={{ application_version() }}"></script><script src="/static/js/image-editor.js?v={{ application_version() }}"></script><script>
(() => { (() => {
document.addEventListener('keydown', event => { document.addEventListener('keydown', event => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return; if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
@@ -136,5 +136,5 @@
document.documentElement.classList.toggle('sidebar-collapsed-preset', collapsed); document.documentElement.classList.toggle('sidebar-collapsed-preset', collapsed);
}); });
})(); })();
</script><script src="/static/js/asset-page-boot.js"></script><script src="/static/js/presence.js"></script><script src="/static/js/file-picker.js?v={{ app_version }}"></script><script src="/static/js/software-inventory-selection.js?v={{ app_version }}"></script> </script><script src="/static/js/asset-page-boot.js?v={{ application_version() }}"></script><script src="/static/js/presence.js?v={{ application_version() }}"></script><script src="/static/js/file-picker.js?v={{ application_version() }}"></script><script src="/static/js/software-inventory-selection.js?v={{ application_version() }}"></script>
</body></html> </body></html>
+1 -1
View File
@@ -8,5 +8,5 @@
<div class="table-scroll sticky-table"><table class="data-table" id="dashboard-asset-table"><thead><tr><th>{{ t('assets.name') }}</th><th>{{ t('assets.category') }}</th>{% for f in fields if f.field_name != 'name' %}<th>{{ t('field.' ~ f.field_name, f.label) }}</th>{% endfor %}<th data-no-sort="1" data-no-filter="1">{{ t('common.actions') }}</th></tr></thead><tbody> <div class="table-scroll sticky-table"><table class="data-table" id="dashboard-asset-table"><thead><tr><th>{{ t('assets.name') }}</th><th>{{ t('assets.category') }}</th>{% for f in fields if f.field_name != 'name' %}<th>{{ t('field.' ~ f.field_name, f.label) }}</th>{% endfor %}<th data-no-sort="1" data-no-filter="1">{{ t('common.actions') }}</th></tr></thead><tbody>
{% for a in assets %}<tr><td><a href="/assets/{{a.id}}">{{a.name}}</a></td><td>{{a.category.name}}</td>{% for f in fields if f.field_name != 'name' %}<td class="multiline">{{a|attr(f.field_name) or ''}}</td>{% endfor %}<td class="row-actions">{% if is_admin %}{% if issue_status_name and a.status == issue_status_name %}<form class="inline-form" method="post" action="/assets/{{a.id}}/return"><input type="hidden" name="return_to" value="dashboard"><button class="icon-button button-return" title="{{ t('assets.return') }}">↩</button></form>{% else %}<button type="button" class="icon-button button-issue" data-issue-action="/assets/{{a.id}}/issue" data-return-to="dashboard" title="{{ t('assets.issue') }}">↗</button>{% endif %}<a class="icon-button button-duplicate" href="/assets/{{a.id}}/duplicate" title="{{ t('assets.duplicate') }}">⧉</a><form class="inline-form" method="post" action="/assets/{{a.id}}/delete" onsubmit="return confirm({{ t('assets.delete_confirm', name=a.name)|tojson }});"><button class="icon-button button-danger" title="{{ t('common.delete') }}">🗑</button></form>{% else %}<span class="muted">{{ t('common.read_only') }}</span>{% endif %}</td></tr>{% else %}<tr><td colspan="40" class="empty-state">{{ t('assets.none') }}</td></tr>{% endfor %} {% for a in assets %}<tr><td><a href="/assets/{{a.id}}">{{a.name}}</a></td><td>{{a.category.name}}</td>{% for f in fields if f.field_name != 'name' %}<td class="multiline">{{a|attr(f.field_name) or ''}}</td>{% endfor %}<td class="row-actions">{% if is_admin %}{% if issue_status_name and a.status == issue_status_name %}<form class="inline-form" method="post" action="/assets/{{a.id}}/return"><input type="hidden" name="return_to" value="dashboard"><button class="icon-button button-return" title="{{ t('assets.return') }}">↩</button></form>{% else %}<button type="button" class="icon-button button-issue" data-issue-action="/assets/{{a.id}}/issue" data-return-to="dashboard" title="{{ t('assets.issue') }}">↗</button>{% endif %}<a class="icon-button button-duplicate" href="/assets/{{a.id}}/duplicate" title="{{ t('assets.duplicate') }}">⧉</a><form class="inline-form" method="post" action="/assets/{{a.id}}/delete" onsubmit="return confirm({{ t('assets.delete_confirm', name=a.name)|tojson }});"><button class="icon-button button-danger" title="{{ t('common.delete') }}">🗑</button></form>{% else %}<span class="muted">{{ t('common.read_only') }}</span>{% endif %}</td></tr>{% else %}<tr><td colspan="40" class="empty-state">{{ t('assets.none') }}</td></tr>{% endfor %}
</tbody></table></div> </tbody></table></div>
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js"></script> {% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js?v={{ application_version() }}"></script>
{% endblock %} {% endblock %}
+2 -2
View File
@@ -133,6 +133,6 @@
</table> </table>
</div> </div>
</section> </section>
<script src="/static/js/software-job-selection.js?v={{ app_version }}"></script> <script src="/static/js/software-job-selection.js?v={{ application_version() }}"></script>
<script src="/static/js/software-job-status.js?v={{ app_version }}"></script> <script src="/static/js/software-job-status.js?v={{ application_version() }}"></script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.91" APP_VERSION = "0.5.5.92"
__version__ = APP_VERSION __version__ = APP_VERSION
+10
View File
@@ -27,6 +27,10 @@ services:
environment: environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager} DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
APP_TITLE: AssetManager APP_TITLE: AssetManager
ASSETMANAGER_CONTAINER_ROLE: app
ASSETMANAGER_IMAGE_REF: ${ASSETMANAGER_IMAGE:-local-build}
ASSETMANAGER_BUILD_DATE: ${ASSETMANAGER_BUILD_DATE:-}
ASSETMANAGER_GIT_COMMIT: ${ASSETMANAGER_GIT_COMMIT:-}
ASSETMANAGER_DATA_ROOT: /assetmanager-data ASSETMANAGER_DATA_ROOT: /assetmanager-data
APP_CONFIG: /assetmanager-data/config/config.json APP_CONFIG: /assetmanager-data/config/config.json
APPINFO_PATH: /assetmanager-data/config/APPINFO.json APPINFO_PATH: /assetmanager-data/config/APPINFO.json
@@ -74,6 +78,12 @@ services:
environment: environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager} DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
APP_TITLE: AssetManager APP_TITLE: AssetManager
ASSETMANAGER_CONTAINER_ROLE: callback
ASSETMANAGER_IMAGE_REF: ${ASSETMANAGER_IMAGE:-local-build}
ASSETMANAGER_BUILD_DATE: ${ASSETMANAGER_BUILD_DATE:-}
ASSETMANAGER_GIT_COMMIT: ${ASSETMANAGER_GIT_COMMIT:-}
CALLBACK_BIND_IP: ${CALLBACK_BIND_IP:-127.0.0.1}
CALLBACK_PORT: ${CALLBACK_PORT:-8090}
ASSETMANAGER_DATA_ROOT: /assetmanager-data ASSETMANAGER_DATA_ROOT: /assetmanager-data
APP_CONFIG: /assetmanager-data/config/config.json APP_CONFIG: /assetmanager-data/config/config.json
APPINFO_PATH: /assetmanager-data/config/APPINFO.json APPINFO_PATH: /assetmanager-data/config/APPINFO.json
+2 -1
View File
@@ -1,3 +1,4 @@
- [0.5.5.92](UPDATE-0.5.5.92.md) - startup diagnostics with runtime, database and callback endpoint information.
- [0.5.5.91](UPDATE-0.5.5.91.md) - smoother table scrolling and consolidated current development changes. - [0.5.5.91](UPDATE-0.5.5.91.md) - smoother table scrolling and consolidated current development changes.
- [0.5.5.90](UPDATE-0.5.5.90.md) - modular analyzer profiles, community repository foundation and portable package import/export. - [0.5.5.90](UPDATE-0.5.5.90.md) - modular analyzer profiles, community repository foundation and portable package import/export.
- [0.5.5.89](UPDATE-0.5.5.89.md) - recursively analyze supported SFX payloads and package selected embedded installers. - [0.5.5.89](UPDATE-0.5.5.89.md) - recursively analyze supported SFX payloads and package selected embedded installers.
@@ -27,7 +28,7 @@
Release notes are stored outside the project root to keep the repository overview compact. Release notes are stored outside the project root to keep the repository overview compact.
The current release is **0.5.5.91**. The current release is **0.5.5.92**.
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details. Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
+15
View File
@@ -0,0 +1,15 @@
# Update 0.5.5.92
Version 0.5.5.92 adds structured startup diagnostics for the AssetManager application and callback containers.
## Changes
- Added a clearly delimited startup block to Docker/container logs for the application service.
- Added a dedicated startup block to the callback-only service.
- Startup diagnostics report the running AssetManager/build version, Python and core runtime package versions.
- The actual connected PostgreSQL server version is queried and logged at startup.
- Callback startup diagnostics show the configured public callback base/API/health URLs and the internal/published listener information.
- Missing build metadata is reported as `not provided`; no credentials, tokens or database passwords are logged.
- Version updated to 0.5.5.92.
Version 0.5.5.91 remains the stabilization baseline for table scrolling, JSON query handling, static cache busting, remote-script preflight retries and serialized software-inventory MeshCtrl execution.