multi value filtering added and font color fix for duplicates
@@ -5,6 +5,7 @@
|
||||
.public-release-local-patterns
|
||||
data/config/config.json
|
||||
data/config/APPINFO.json
|
||||
docker-compose_old.yml
|
||||
|
||||
# Runtime and user data
|
||||
data/postgres/*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# AssetManager 0.5.5.65
|
||||
# AssetManager 0.5.5.68
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ BASE_TRANSLATIONS = {
|
||||
"lists.row_number": ("Row number", "Zeilennummer"),
|
||||
"lists.row_number_resize": ("Drag to change the row-number column width", "Ziehen, um die Breite der Nummernspalte zu ändern"),
|
||||
"lists.column_resize": ("Drag to change the column width", "Ziehen, um die Spaltenbreite zu ändern"),
|
||||
"lists.multi_value_filter_hint": ("Paste multiple search values one per line. Values within one column are OR-linked.", "Mehrere Suchwerte können zeilenweise eingefügt werden. Innerhalb einer Spalte gilt ODER."),
|
||||
"appearance.colors_title": ("System colors", "Systemfarben"),
|
||||
"appearance.colors_help": ("Configure the central colors used throughout the application.", "Konfigurieren Sie die zentral im gesamten System verwendeten Farben."),
|
||||
"appearance.primary_color": ("Primary color", "Primärfarbe"),
|
||||
@@ -1314,6 +1315,15 @@ BASE_TRANSLATIONS.update({
|
||||
"software.compare.comparison_name": ("Comparison name", "Vergleichsname"),
|
||||
"software.compare.status": ("Comparison status", "Vergleichsstatus"),
|
||||
"software.compare.empty": ("No software inventory is available for these assets.", "Für diese Assets sind keine Softwareinventuren vorhanden."),
|
||||
"software.compare.select_second_title": ("Select second asset", "Zweites Asset auswählen"),
|
||||
"software.compare.select_second_help": ("Choose the second asset for the comparison. The selection shows only values that actually exist for the selected field.", "Wähle das zweite Asset für den Vergleich. Das Auswahlfeld zeigt nur tatsächlich vorhandene Werte des gewählten Feldes."),
|
||||
"software.compare.first_asset": ("First asset", "Erstes Asset"),
|
||||
"software.compare.select_by": ("Select by", "Auswahl nach"),
|
||||
"software.compare.second_asset": ("Second asset", "Zweites Asset"),
|
||||
"software.compare.choose_value": ("Select value …", "Wert auswählen …"),
|
||||
"software.compare.no_selectable_values": ("No matching values available", "Keine passenden Werte vorhanden"),
|
||||
"software.compare.available_values": ("{count} values available", "{count} Werte verfügbar"),
|
||||
"software.compare.load_error": ("Selection could not be loaded", "Auswahl konnte nicht geladen werden"),
|
||||
})
|
||||
|
||||
BASE_TRANSLATIONS.update({
|
||||
|
||||
@@ -1840,21 +1840,41 @@ def save_upload(upload: UploadFile | None) -> str | None:
|
||||
return f"/static/uploads/{filename}"
|
||||
|
||||
|
||||
def _standard_image_url(filename: str) -> str:
|
||||
return "/static/uploads/library/" + urllib.parse.quote(filename)
|
||||
def _standard_image_url(relative_path: str | Path) -> str:
|
||||
"""Build a public URL for a file inside the persistent image library."""
|
||||
relative = Path(relative_path)
|
||||
encoded = "/".join(urllib.parse.quote(part, safe="") for part in relative.parts)
|
||||
return "/static/uploads/library/" + encoded
|
||||
|
||||
|
||||
def list_standard_images() -> list[dict[str, str]]:
|
||||
"""Return reusable images stored in the persistent standard image library."""
|
||||
"""Read reusable images directly from the persistent filesystem library.
|
||||
|
||||
The directory is scanned on every form request so files copied to the Docker
|
||||
host become available after a browser reload. Subdirectories are supported
|
||||
as well; symlinks are ignored so the library cannot escape its configured
|
||||
root directory.
|
||||
"""
|
||||
images: list[dict[str, str]] = []
|
||||
try:
|
||||
candidates = sorted(STANDARD_IMAGE_DIR.iterdir(), key=lambda item: item.name.casefold())
|
||||
candidates = sorted(
|
||||
STANDARD_IMAGE_DIR.rglob("*"),
|
||||
key=lambda item: item.relative_to(STANDARD_IMAGE_DIR).as_posix().casefold(),
|
||||
)
|
||||
except OSError:
|
||||
return images
|
||||
|
||||
for path in candidates:
|
||||
if not path.is_file() or path.suffix.lower() not in STANDARD_IMAGE_EXTENSIONS:
|
||||
try:
|
||||
if path.is_symlink() or not path.is_file() or path.suffix.lower() not in STANDARD_IMAGE_EXTENSIONS:
|
||||
continue
|
||||
relative = path.relative_to(STANDARD_IMAGE_DIR)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
images.append({"name": path.name, "path": _standard_image_url(path.name)})
|
||||
images.append({
|
||||
"name": relative.as_posix(),
|
||||
"path": _standard_image_url(relative),
|
||||
})
|
||||
return images
|
||||
|
||||
|
||||
@@ -2313,7 +2333,11 @@ def _seed_default_charts(db: Session) -> None:
|
||||
|
||||
@app.get("/")
|
||||
def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
|
||||
categories = _sort_localized(
|
||||
db.query(Category).options(joinedload(Category.visible_fields)).all(),
|
||||
"name",
|
||||
request,
|
||||
)
|
||||
category_asset_counts = dict(
|
||||
_apply_asset_access(
|
||||
db.query(Asset.category_id, func.count(Asset.id)),
|
||||
@@ -2639,13 +2663,161 @@ def _filter_assets_by_job_states(
|
||||
return filtered_assets, views
|
||||
|
||||
|
||||
def _software_compare_field_options(db: Session, request: Request) -> list[dict[str, str]]:
|
||||
"""Return scalar, filterable asset fields that can identify a comparison asset."""
|
||||
definitions = (
|
||||
db.query(FieldDefinition)
|
||||
.filter(
|
||||
FieldDefinition.is_active.is_(True),
|
||||
FieldDefinition.filterable.is_(True),
|
||||
)
|
||||
.order_by(FieldDefinition.sort_order, func.lower(FieldDefinition.label), FieldDefinition.id)
|
||||
.all()
|
||||
)
|
||||
language_code = _request_language(request)
|
||||
own = translation_dictionary(db, language_code)
|
||||
english = translation_dictionary(db, "en") if language_code != "en" else own
|
||||
options: list[dict[str, str]] = []
|
||||
for definition in definitions:
|
||||
if definition.data_type in {"long_text", "json"}:
|
||||
continue
|
||||
if definition.field_name == "parent_asset_id":
|
||||
continue
|
||||
key = definition.translation_key or f"field.{definition.field_name}"
|
||||
label = own.get(key) or english.get(key) or definition.label or definition.field_name
|
||||
options.append({"field_name": definition.field_name, "label": label})
|
||||
|
||||
options.sort(
|
||||
key=lambda item: (
|
||||
0 if item["field_name"] == "name" else 1,
|
||||
_localized_text_key(item["label"], language_code),
|
||||
item["field_name"],
|
||||
)
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
def _software_compare_custom_value(row: AssetFieldValue, definition: FieldDefinition) -> Any:
|
||||
if definition.data_type == "boolean":
|
||||
return row.value_boolean
|
||||
if definition.data_type == "datetime":
|
||||
return row.value_datetime
|
||||
if definition.data_type == "date":
|
||||
return row.value_date
|
||||
if definition.data_type in {"integer", "decimal"}:
|
||||
return row.value_number
|
||||
if definition.data_type == "json":
|
||||
return row.value_json
|
||||
return row.value_text
|
||||
|
||||
|
||||
def _software_compare_display_value(request: Request, value: Any, definition: FieldDefinition) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if definition.data_type == "boolean":
|
||||
return _translate_request(request, "common.yes", "Yes") if bool(value) else _translate_request(request, "common.no", "No")
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat(timespec="seconds")
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
@app.get("/api/assets/software-compare/options")
|
||||
def assets_software_compare_options(
|
||||
request: Request,
|
||||
field_name: str = "name",
|
||||
exclude_asset_id: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Return accessible second-asset choices for the requested selection field."""
|
||||
_require_admin(request)
|
||||
definition = (
|
||||
db.query(FieldDefinition)
|
||||
.filter(
|
||||
FieldDefinition.field_name == field_name,
|
||||
FieldDefinition.is_active.is_(True),
|
||||
FieldDefinition.filterable.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not definition or definition.data_type in {"long_text", "json"} or definition.field_name == "parent_asset_id":
|
||||
raise HTTPException(400, "Unsupported comparison selection field")
|
||||
|
||||
query = _apply_asset_access(db.query(Asset), request)
|
||||
if exclude_asset_id is not None:
|
||||
query = query.filter(Asset.id != exclude_asset_id)
|
||||
assets = query.order_by(func.lower(Asset.name), Asset.id).all()
|
||||
if not assets:
|
||||
return {"field_name": field_name, "options": []}
|
||||
|
||||
values_by_asset: dict[int, Any] = {}
|
||||
if definition.is_system and hasattr(Asset, definition.field_name):
|
||||
for asset in assets:
|
||||
values_by_asset[asset.id] = getattr(asset, definition.field_name, None)
|
||||
else:
|
||||
rows = (
|
||||
db.query(AssetFieldValue)
|
||||
.filter(
|
||||
AssetFieldValue.field_definition_id == definition.id,
|
||||
AssetFieldValue.asset_id.in_([asset.id for asset in assets]),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
values_by_asset[row.asset_id] = _software_compare_custom_value(row, definition)
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
counts: dict[str, int] = {}
|
||||
for asset in assets:
|
||||
display_value = _software_compare_display_value(
|
||||
request,
|
||||
values_by_asset.get(asset.id),
|
||||
definition,
|
||||
)
|
||||
if not display_value:
|
||||
continue
|
||||
normalized = _normalize_filter_text(display_value)
|
||||
counts[normalized] = counts.get(normalized, 0) + 1
|
||||
candidates.append({
|
||||
"asset_id": asset.id,
|
||||
"asset_name": asset.name,
|
||||
"value": display_value,
|
||||
"normalized": normalized,
|
||||
})
|
||||
|
||||
language_code = _request_language(request)
|
||||
candidates.sort(
|
||||
key=lambda item: (
|
||||
_localized_text_key(item["value"], language_code),
|
||||
_localized_text_key(item["asset_name"], language_code),
|
||||
item["asset_id"],
|
||||
)
|
||||
)
|
||||
options = []
|
||||
for item in candidates:
|
||||
label = item["value"]
|
||||
if counts.get(item["normalized"], 0) > 1:
|
||||
label = f"{label} — {item['asset_name']}"
|
||||
options.append({
|
||||
"asset_id": item["asset_id"],
|
||||
"value": item["value"],
|
||||
"label": label,
|
||||
"asset_name": item["asset_name"],
|
||||
})
|
||||
return {"field_name": field_name, "options": options}
|
||||
|
||||
|
||||
@app.get("/assets")
|
||||
def assets_list(request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
|
||||
categories = (
|
||||
categories = _sort_localized(
|
||||
db.query(Category)
|
||||
.options(selectinload(Category.visible_fields).joinedload(CategoryField.definition))
|
||||
.order_by(Category.name)
|
||||
.all()
|
||||
.all(),
|
||||
"name",
|
||||
request,
|
||||
)
|
||||
|
||||
requested_exclusions = request.query_params.getlist("job_filter_exclude")
|
||||
@@ -2715,6 +2887,7 @@ def assets_list(request: Request, category_id: int | None = None, db: Session =
|
||||
"job_filter_statuses": ASSET_JOB_FILTER_STATUSES,
|
||||
"job_state_by_asset": job_state_by_asset,
|
||||
"asset_platforms": {asset.id: detect_platform(asset) for asset in assets},
|
||||
"compare_field_options": _software_compare_field_options(db, request) if _is_admin(request) else [],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2949,7 +3122,11 @@ def chart_delete(chart_id: int, request: Request, db: Session = Depends(get_db))
|
||||
@app.get("/assets/new")
|
||||
def asset_new(request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
|
||||
categories = _sort_localized(
|
||||
db.query(Category).options(joinedload(Category.visible_fields)).all(),
|
||||
"name",
|
||||
request,
|
||||
)
|
||||
selected = next((c for c in categories if c.id == category_id), categories[0] if categories else None)
|
||||
return templates.TemplateResponse("asset_form.html", {"request": request, "asset": None, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).order_by(Asset.name).all(), "custom_values": {}, "standard_images": list_standard_images()})
|
||||
|
||||
@@ -2960,7 +3137,11 @@ def asset_duplicate(asset_id: int, request: Request, db: Session = Depends(get_d
|
||||
original = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first()
|
||||
if not original:
|
||||
raise HTTPException(404, "Asset nicht gefunden")
|
||||
categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
|
||||
categories = _sort_localized(
|
||||
db.query(Category).options(joinedload(Category.visible_fields)).all(),
|
||||
"name",
|
||||
request,
|
||||
)
|
||||
class DuplicateValues:
|
||||
pass
|
||||
duplicate = DuplicateValues()
|
||||
@@ -3431,7 +3612,7 @@ def asset_tree(
|
||||
or bool(by_parent.get(asset.id))
|
||||
]
|
||||
|
||||
categories = db.query(Category).order_by(Category.name).all()
|
||||
categories = _sort_localized(db.query(Category).all(), "name", request)
|
||||
actual_roots = [
|
||||
asset for asset in all_assets
|
||||
if asset.parent_asset_id is None or asset.parent_asset_id not in all_by_id
|
||||
@@ -3821,7 +4002,11 @@ def asset_mesh_inventory_json(asset_id: int, request: Request, db: Session = Dep
|
||||
def asset_edit(asset_id: int, request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
asset = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first()
|
||||
categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
|
||||
categories = _sort_localized(
|
||||
db.query(Category).options(joinedload(Category.visible_fields)).all(),
|
||||
"name",
|
||||
request,
|
||||
)
|
||||
if not asset:
|
||||
raise HTTPException(404, "Asset nicht gefunden")
|
||||
selected = next((category for category in categories if category.id == category_id), None) if category_id else asset.category
|
||||
@@ -4429,11 +4614,12 @@ def status_option_delete(option_id: int, request: Request, db: Session = Depends
|
||||
@app.get("/categories")
|
||||
def categories_list(request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
categories = (
|
||||
categories = _sort_localized(
|
||||
db.query(Category)
|
||||
.options(selectinload(Category.visible_fields))
|
||||
.order_by(Category.name)
|
||||
.all()
|
||||
.all(),
|
||||
"name",
|
||||
request,
|
||||
)
|
||||
asset_counts = dict(
|
||||
db.query(Asset.category_id, func.count(Asset.id))
|
||||
@@ -5115,7 +5301,7 @@ _SOFTWARE_JOB_FILTER_KEYS = (
|
||||
def _software_job_filter_values(request: Request) -> dict[str, str]:
|
||||
"""Read and normalize the server-side filters of the recent-job table."""
|
||||
return {
|
||||
key: str(request.query_params.get(f"job_filter_{key}") or "").strip()[:200]
|
||||
key: str(request.query_params.get(f"job_filter_{key}") or "").strip()[:12000]
|
||||
for key in _SOFTWARE_JOB_FILTER_KEYS
|
||||
}
|
||||
|
||||
@@ -5240,115 +5426,157 @@ def _software_job_date_filter(column: Any, search_value: str):
|
||||
return or_(*conditions)
|
||||
|
||||
|
||||
def _multi_filter_terms(value: Any, *, max_terms: int = 200) -> list[str]:
|
||||
"""Split a table filter into unique non-empty line-based OR terms."""
|
||||
terms: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in str(value or "").replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||
term = raw.strip()
|
||||
if not term:
|
||||
continue
|
||||
key = term.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
terms.append(term[:300])
|
||||
if len(terms) >= max_terms:
|
||||
break
|
||||
return terms
|
||||
|
||||
|
||||
def _apply_software_job_filters(query: Any, request: Request, filters: dict[str, str]):
|
||||
"""Apply all recent-job filters before LIMIT is evaluated.
|
||||
|
||||
This keeps an old job searchable even when the visible result size is only
|
||||
25 rows. The large script, result and log columns are still excluded from
|
||||
the final SELECT by ``load_only`` below.
|
||||
Multiple lines within one column are OR-linked. Different columns remain
|
||||
AND-linked. This allows users to paste a column copied from Excel directly
|
||||
into a table filter without changing the normal single-value behavior.
|
||||
"""
|
||||
parameters_text = cast(SoftwareJob.parameters, String)
|
||||
|
||||
if filters["job_id"]:
|
||||
value = filters["job_id"]
|
||||
if value.isdigit():
|
||||
query = query.filter(SoftwareJob.id == int(value))
|
||||
else:
|
||||
query = query.filter(_sql_text_contains(SoftwareJob.id, value))
|
||||
|
||||
if filters["created_at"]:
|
||||
query = query.filter(
|
||||
_software_job_date_filter(SoftwareJob.created_at, filters["created_at"])
|
||||
)
|
||||
|
||||
if filters["asset"]:
|
||||
query = query.filter(_sql_text_contains(Asset.name, filters["asset"]))
|
||||
|
||||
if filters["job_type"]:
|
||||
value = filters["job_type"]
|
||||
conditions = [
|
||||
_sql_text_contains(SoftwareJob.job_type, value),
|
||||
_sql_text_contains(parameters_text, value),
|
||||
]
|
||||
job_type_codes = _translated_filter_codes(
|
||||
request,
|
||||
"jobs.type",
|
||||
("software_inventory", "job_definition"),
|
||||
value,
|
||||
)
|
||||
if job_type_codes:
|
||||
conditions.append(SoftwareJob.job_type.in_(job_type_codes))
|
||||
job_kind_codes = _translated_filter_codes(
|
||||
request,
|
||||
"jobdefs.kind",
|
||||
("script", "registry"),
|
||||
value,
|
||||
)
|
||||
for kind in job_kind_codes:
|
||||
conditions.append(
|
||||
and_(
|
||||
SoftwareJob.job_type == "job_definition",
|
||||
_sql_text_contains(parameters_text, kind),
|
||||
)
|
||||
)
|
||||
terms = _multi_filter_terms(filters["job_id"])
|
||||
if terms:
|
||||
conditions = []
|
||||
for value in terms:
|
||||
if value.isdigit():
|
||||
conditions.append(SoftwareJob.id == int(value))
|
||||
else:
|
||||
conditions.append(_sql_text_contains(SoftwareJob.id, value))
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
if filters["package"]:
|
||||
value = filters["package"]
|
||||
query = query.filter(
|
||||
terms = _multi_filter_terms(filters["created_at"])
|
||||
if terms:
|
||||
query = query.filter(or_(*[
|
||||
_software_job_date_filter(SoftwareJob.created_at, value)
|
||||
for value in terms
|
||||
]))
|
||||
|
||||
terms = _multi_filter_terms(filters["asset"])
|
||||
if terms:
|
||||
query = query.filter(or_(*[
|
||||
_sql_text_contains(Asset.name, value)
|
||||
for value in terms
|
||||
]))
|
||||
|
||||
terms = _multi_filter_terms(filters["job_type"])
|
||||
if terms:
|
||||
term_conditions = []
|
||||
for value in terms:
|
||||
conditions = [
|
||||
_sql_text_contains(SoftwareJob.job_type, value),
|
||||
_sql_text_contains(parameters_text, value),
|
||||
]
|
||||
job_type_codes = _translated_filter_codes(
|
||||
request,
|
||||
"jobs.type",
|
||||
("software_inventory", "job_definition"),
|
||||
value,
|
||||
)
|
||||
if job_type_codes:
|
||||
conditions.append(SoftwareJob.job_type.in_(job_type_codes))
|
||||
job_kind_codes = _translated_filter_codes(
|
||||
request,
|
||||
"jobdefs.kind",
|
||||
("script", "registry"),
|
||||
value,
|
||||
)
|
||||
for kind in job_kind_codes:
|
||||
conditions.append(
|
||||
and_(
|
||||
SoftwareJob.job_type == "job_definition",
|
||||
_sql_text_contains(parameters_text, kind),
|
||||
)
|
||||
)
|
||||
term_conditions.append(or_(*conditions))
|
||||
query = query.filter(or_(*term_conditions))
|
||||
|
||||
terms = _multi_filter_terms(filters["package"])
|
||||
if terms:
|
||||
query = query.filter(or_(*[
|
||||
or_(
|
||||
_sql_text_contains(SoftwarePackage.name, value),
|
||||
_sql_text_contains(parameters_text, value),
|
||||
)
|
||||
)
|
||||
for value in terms
|
||||
]))
|
||||
|
||||
if filters["action"]:
|
||||
value = filters["action"]
|
||||
conditions = [_sql_text_contains(SoftwareJob.action, value)]
|
||||
action_codes = _translated_filter_codes(
|
||||
request,
|
||||
"jobs.action",
|
||||
("run", "install", "uninstall", "update"),
|
||||
value,
|
||||
)
|
||||
if action_codes:
|
||||
conditions.append(SoftwareJob.action.in_(action_codes))
|
||||
terms = _multi_filter_terms(filters["action"])
|
||||
if terms:
|
||||
term_conditions = []
|
||||
for value in terms:
|
||||
conditions = [_sql_text_contains(SoftwareJob.action, value)]
|
||||
action_codes = _translated_filter_codes(
|
||||
request,
|
||||
"jobs.action",
|
||||
("run", "install", "uninstall", "update"),
|
||||
value,
|
||||
)
|
||||
if action_codes:
|
||||
conditions.append(SoftwareJob.action.in_(action_codes))
|
||||
term_conditions.append(or_(*conditions))
|
||||
query = query.filter(or_(*term_conditions))
|
||||
|
||||
terms = _multi_filter_terms(filters["priority"])
|
||||
if terms:
|
||||
conditions = []
|
||||
for value in terms:
|
||||
if re.fullmatch(r"[-+]?\d+", value):
|
||||
conditions.append(SoftwareJob.priority == int(value))
|
||||
else:
|
||||
conditions.append(_sql_text_contains(SoftwareJob.priority, value))
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
if filters["priority"]:
|
||||
value = filters["priority"]
|
||||
if re.fullmatch(r"[-+]?\d+", value):
|
||||
query = query.filter(SoftwareJob.priority == int(value))
|
||||
else:
|
||||
query = query.filter(_sql_text_contains(SoftwareJob.priority, value))
|
||||
terms = _multi_filter_terms(filters["platform"])
|
||||
if terms:
|
||||
query = query.filter(or_(*[
|
||||
_sql_text_contains(SoftwareJob.platform, value)
|
||||
for value in terms
|
||||
]))
|
||||
|
||||
if filters["platform"]:
|
||||
query = query.filter(
|
||||
_sql_text_contains(SoftwareJob.platform, filters["platform"])
|
||||
)
|
||||
|
||||
if filters["status"]:
|
||||
value = filters["status"]
|
||||
conditions = [_sql_text_contains(SoftwareJob.status, value)]
|
||||
status_codes = _translated_filter_codes(
|
||||
request,
|
||||
"software.status",
|
||||
(
|
||||
"created",
|
||||
"sending",
|
||||
"sent",
|
||||
"running",
|
||||
"success",
|
||||
"partial",
|
||||
"failed",
|
||||
"timeout",
|
||||
"cancelled",
|
||||
),
|
||||
value,
|
||||
)
|
||||
if status_codes:
|
||||
conditions.append(SoftwareJob.status.in_(status_codes))
|
||||
query = query.filter(or_(*conditions))
|
||||
terms = _multi_filter_terms(filters["status"])
|
||||
if terms:
|
||||
term_conditions = []
|
||||
for value in terms:
|
||||
conditions = [_sql_text_contains(SoftwareJob.status, value)]
|
||||
status_codes = _translated_filter_codes(
|
||||
request,
|
||||
"software.status",
|
||||
(
|
||||
"created",
|
||||
"sending",
|
||||
"sent",
|
||||
"running",
|
||||
"success",
|
||||
"partial",
|
||||
"failed",
|
||||
"timeout",
|
||||
"cancelled",
|
||||
),
|
||||
value,
|
||||
)
|
||||
if status_codes:
|
||||
conditions.append(SoftwareJob.status.in_(status_codes))
|
||||
term_conditions.append(or_(*conditions))
|
||||
query = query.filter(or_(*term_conditions))
|
||||
|
||||
return query
|
||||
|
||||
@@ -7269,7 +7497,7 @@ async def translations_import(request: Request, file: UploadFile = File(...), ig
|
||||
@app.get("/settings/appearance")
|
||||
def settings_appearance_page(request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
categories = db.query(Category).order_by(Category.name.asc()).all()
|
||||
categories = _sort_localized(db.query(Category).all(), "name", request)
|
||||
return templates.TemplateResponse(
|
||||
"settings.html",
|
||||
{
|
||||
@@ -7286,7 +7514,7 @@ def settings_appearance_page(request: Request, db: Session = Depends(get_db)):
|
||||
@app.get("/settings/authentication")
|
||||
def settings_authentication_page(request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
categories = db.query(Category).order_by(Category.name.asc()).all()
|
||||
categories = _sort_localized(db.query(Category).all(), "name", request)
|
||||
return templates.TemplateResponse(
|
||||
"settings.html",
|
||||
{
|
||||
@@ -7303,7 +7531,7 @@ def settings_authentication_page(request: Request, db: Session = Depends(get_db)
|
||||
@app.get("/settings/system")
|
||||
def settings_system_page(request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
categories = db.query(Category).order_by(Category.name.asc()).all()
|
||||
categories = _sort_localized(db.query(Category).all(), "name", request)
|
||||
return templates.TemplateResponse(
|
||||
"settings.html",
|
||||
{
|
||||
@@ -8050,7 +8278,7 @@ def settings_backup_restore(request: Request, backup_name: str, confirmation: st
|
||||
@app.get("/settings")
|
||||
def settings_page(request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
categories = db.query(Category).order_by(Category.name.asc()).all()
|
||||
categories = _sort_localized(db.query(Category).all(), "name", request)
|
||||
return templates.TemplateResponse(
|
||||
"settings.html",
|
||||
{
|
||||
@@ -8217,7 +8445,7 @@ def meshcentral_sync_page(request: Request, db: Session = Depends(get_db)):
|
||||
missing = db.query(Asset).filter(Asset.mesh_sync_status == "missing").count()
|
||||
definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all()
|
||||
mappings = db.query(MeshFieldMapping).order_by(MeshFieldMapping.priority, MeshFieldMapping.id).all()
|
||||
categories = db.query(Category).order_by(Category.name.asc()).all()
|
||||
categories = _sort_localized(db.query(Category).all(), "name", request)
|
||||
return templates.TemplateResponse("meshcentral_sync.html", {
|
||||
"request": request, "runs": runs, "config": public_config(),
|
||||
"linked": linked, "conflicts": conflicts, "missing": missing,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.table-scroll{overflow:auto}.table-hint{color:#667;font-size:13px}.multiline{white-space:pre-line;min-width:140px}#asset-table th[draggable=true]{cursor:grab;user-select:none}#asset-table th.dragging{opacity:.45}#asset-table{min-width:900px}
|
||||
|
||||
@media(max-width:700px){main{padding:0 10px}}
|
||||
.button-secondary{background:#546675}.column-filter-row th{padding:6px;background:#f5f7f9}.column-filter-row input{margin:0;min-width:110px;padding:6px;font-size:13px}.sortable-column{cursor:pointer;user-select:none;padding-right:24px;position:relative}.sortable-column.sort-asc::after{content:"▲";position:absolute;right:7px;font-size:10px}.sortable-column.sort-desc::after{content:"▼";position:absolute;right:7px;font-size:10px}tr[hidden]{display:none}.history-old,.history-new{white-space:pre-wrap}
|
||||
.button-secondary{background:#546675}.column-filter-row th{padding:6px;background:#f5f7f9}.column-filter-row input,.column-filter-row textarea{margin:0;min-width:110px;padding:6px;font-size:13px}.sortable-column{cursor:pointer;user-select:none;padding-right:24px;position:relative}.sortable-column.sort-asc::after{content:"▲";position:absolute;right:7px;font-size:10px}.sortable-column.sort-desc::after{content:"▼";position:absolute;right:7px;font-size:10px}tr[hidden]{display:none}.history-old,.history-new{white-space:pre-wrap}
|
||||
|
||||
/* Tabellen: fixierte Kopfzeilen und kompakte Aktionsschaltflächen */
|
||||
.sticky-table{max-height:calc(100vh - 170px);overflow:auto;border:1px solid #d8dde2;background:#fff}
|
||||
@@ -511,9 +511,17 @@ body.single-scroll-list .sticky-table + .muted{
|
||||
#asset-table tbody tr.duplicate-hidden{display:none!important}
|
||||
#asset-table tbody tr.duplicate-match td{
|
||||
background:#fff8d8;
|
||||
color:#1f2937;
|
||||
}
|
||||
#asset-table tbody tr.duplicate-match:hover td{
|
||||
background:#fff1ad;
|
||||
color:#1f2937;
|
||||
}
|
||||
html[data-theme="dark"] #asset-table tbody tr.duplicate-match td,
|
||||
html[data-theme="dark"] #asset-table tbody tr.duplicate-match td a,
|
||||
html[data-theme="dark"] #asset-table tbody tr.duplicate-match td .muted,
|
||||
html[data-theme="dark"] #asset-table tbody tr.duplicate-match td small{
|
||||
color:#1f2937!important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1094,12 +1102,22 @@ body.asset-list-scroll-page .asset-list-page #asset-table-scroll{
|
||||
display:table-cell !important;
|
||||
}
|
||||
|
||||
.column-filter-row input[type="search"]{
|
||||
.column-filter-row input[type="search"],
|
||||
.column-filter-row textarea{
|
||||
display:block;
|
||||
width:100%;
|
||||
min-width:90px;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
.column-filter-row textarea{
|
||||
height:32px;
|
||||
min-height:32px;
|
||||
max-height:96px;
|
||||
resize:vertical;
|
||||
line-height:18px;
|
||||
overflow:auto;
|
||||
white-space:pre;
|
||||
}
|
||||
|
||||
/* Management pages use browser scrolling; avoid clipped sticky rows. */
|
||||
.management-table-scroll thead tr:first-child th,
|
||||
@@ -1123,7 +1141,8 @@ body.asset-list-scroll-page .asset-list-page #asset-table-scroll{
|
||||
|
||||
|
||||
/* v0.4.0.8: clearly visible active table filters */
|
||||
.column-filter-row input[type="search"]{
|
||||
.column-filter-row input[type="search"],
|
||||
.column-filter-row textarea{
|
||||
transition:
|
||||
background-color .15s ease,
|
||||
border-color .15s ease,
|
||||
@@ -1134,7 +1153,8 @@ body.asset-list-scroll-page .asset-list-page #asset-table-scroll{
|
||||
background:#fff2c7;
|
||||
}
|
||||
|
||||
.column-filter-row input[type="search"].filter-input-active{
|
||||
.column-filter-row input[type="search"].filter-input-active,
|
||||
.column-filter-row textarea.filter-input-active{
|
||||
background:#fff7d8;
|
||||
border-color:#cf9b16;
|
||||
box-shadow:
|
||||
@@ -1143,7 +1163,8 @@ body.asset-list-scroll-page .asset-list-page #asset-table-scroll{
|
||||
font-weight:600;
|
||||
}
|
||||
|
||||
.column-filter-row input[type="search"].filter-input-active:focus{
|
||||
.column-filter-row input[type="search"].filter-input-active:focus,
|
||||
.column-filter-row textarea.filter-input-active:focus{
|
||||
background:#fff9e5;
|
||||
border-color:#a87500;
|
||||
box-shadow:
|
||||
@@ -3075,3 +3096,29 @@ table th.table-resizable-column-header > .table-column-resizer:focus-visible::af
|
||||
.image-upload-label{display:flex;flex-direction:column;align-items:flex-start;gap:5px;margin-top:8px}
|
||||
html[data-theme="dark"] .standard-image-library{border-color:#46515d}
|
||||
html[data-theme="dark"] .standard-image-choice{border-color:#46515d}
|
||||
|
||||
/* v0.5.5.67: line-based multi-value table filters and second-asset selector */
|
||||
.software-compare-select-dialog{
|
||||
width:min(560px,92vw);
|
||||
}
|
||||
.software-compare-select-dialog form{
|
||||
padding:0;
|
||||
border:0;
|
||||
}
|
||||
.software-compare-select-dialog label{
|
||||
display:grid;
|
||||
gap:6px;
|
||||
}
|
||||
.software-compare-select-dialog input,
|
||||
.software-compare-select-dialog select{
|
||||
width:100%;
|
||||
}
|
||||
.software-compare-selection-status{
|
||||
min-height:1.25em;
|
||||
margin:0;
|
||||
}
|
||||
html[data-theme="dark"] .column-filter-row textarea.filter-input-active{
|
||||
background:#493d14;
|
||||
color:#fff2b3;
|
||||
border-color:#d4a735;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
@@ -24,7 +24,7 @@
|
||||
}
|
||||
|
||||
function filterControls(table) {
|
||||
return [...table.querySelectorAll('thead input, thead select')].filter(control => {
|
||||
return [...table.querySelectorAll('thead input, thead select, thead textarea')].filter(control => {
|
||||
const type = (control.type || '').toLowerCase();
|
||||
return !['checkbox', 'radio', 'button', 'submit'].includes(type);
|
||||
});
|
||||
@@ -44,6 +44,12 @@
|
||||
try { state = JSON.parse(api.get(key) || 'null'); } catch (_) {}
|
||||
if (state?.field && (state.direction === 'asc' || state.direction === 'desc')) {
|
||||
table.assetApplySort(state.field, state.direction, {silent: true});
|
||||
} else if (table.dataset.defaultSortField) {
|
||||
table.assetApplySort(
|
||||
table.dataset.defaultSortField,
|
||||
table.dataset.defaultSortDirection === 'desc' ? 'desc' : 'asc',
|
||||
{silent: true}
|
||||
);
|
||||
} else {
|
||||
table.assetUpdateRowNumbers?.();
|
||||
}
|
||||
|
||||
@@ -714,9 +714,12 @@
|
||||
const active = filterDefinitions
|
||||
.map(definition => ({
|
||||
definition,
|
||||
term: definition.input?.value.trim() || ''
|
||||
terms: String(definition.input?.value || '')
|
||||
.split(/\r?\n/)
|
||||
.map(term => term.trim())
|
||||
.filter(Boolean)
|
||||
}))
|
||||
.filter(item => item.term);
|
||||
.filter(item => item.terms.length);
|
||||
|
||||
if (usesServerFilters) {
|
||||
const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10);
|
||||
@@ -737,13 +740,16 @@
|
||||
let visibleRows = 0;
|
||||
|
||||
[...tbody.rows].forEach(row => {
|
||||
const hidden = active.some(({ definition, term }) => {
|
||||
const hidden = active.some(({ definition, terms }) => {
|
||||
const cell = cellForDefinition(row, definition);
|
||||
const value = textValue(cell);
|
||||
if (definition.numericFilter) {
|
||||
return !numericFilterMatches(value, term);
|
||||
}
|
||||
return !searchTextMatches(value, term);
|
||||
const matchesAnyTerm = terms.some(term => {
|
||||
if (definition.numericFilter) {
|
||||
return numericFilterMatches(value, term);
|
||||
}
|
||||
return searchTextMatches(value, term);
|
||||
});
|
||||
return !matchesAnyTerm;
|
||||
});
|
||||
|
||||
row.hidden = hidden;
|
||||
@@ -824,11 +830,13 @@
|
||||
}
|
||||
|
||||
if (!header.dataset.noFilter && (!usesServerFilters || serverName)) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'search';
|
||||
const input = document.createElement('textarea');
|
||||
input.rows = 1;
|
||||
input.placeholder = header.dataset.filterPlaceholder || 'Filtern …';
|
||||
input.setAttribute('aria-label', `Filter ${textValue(header)}`);
|
||||
input.title = document.body.dataset.multiValueFilterHint || '';
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
if (usesServerFilters) {
|
||||
input.name = `job_filter_${serverName}`;
|
||||
input.value = header.dataset.serverFilterValue || '';
|
||||
@@ -836,18 +844,8 @@
|
||||
applyFilters();
|
||||
submitServerFilters(false);
|
||||
});
|
||||
input.addEventListener('search', () => {
|
||||
applyFilters();
|
||||
submitServerFilters(false);
|
||||
});
|
||||
input.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
submitServerFilters(true);
|
||||
});
|
||||
} else {
|
||||
input.addEventListener('input', applyFilters);
|
||||
input.addEventListener('search', applyFilters);
|
||||
}
|
||||
|
||||
filterCell.appendChild(input);
|
||||
|
||||
@@ -99,6 +99,33 @@
|
||||
{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
<dialog id="software-compare-select-dialog" class="software-compare-select-dialog">
|
||||
<form method="dialog">
|
||||
<div class="dialog-heading">
|
||||
<h2>{{ t('software.compare.select_second_title', 'Zweites Asset auswählen') }}</h2>
|
||||
<button type="button" class="button button-secondary button-small" id="software-compare-dialog-close" aria-label="{{ t('common.close', 'Schließen')|e }}">×</button>
|
||||
</div>
|
||||
<p class="muted">{{ t('software.compare.select_second_help', 'Wähle das zweite Asset für den Vergleich. Das Auswahlfeld zeigt nur vorhandene Werte des gewählten Feldes.') }}</p>
|
||||
<label>{{ t('software.compare.first_asset', 'Erstes Asset') }}
|
||||
<input type="text" id="software-compare-first-name" readonly>
|
||||
</label>
|
||||
<label>{{ t('software.compare.select_by', 'Auswahl nach') }}
|
||||
<select id="software-compare-field-select">
|
||||
{% for option in compare_field_options %}<option value="{{ option.field_name }}"{% if option.field_name == 'name' %} selected{% endif %}>{{ option.label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>{{ t('software.compare.second_asset', 'Zweites Asset') }}
|
||||
<select id="software-compare-second-select" disabled>
|
||||
<option value="">{{ t('common.loading', 'Lade…') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted software-compare-selection-status" id="software-compare-selection-status" role="status" aria-live="polite"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="button button-secondary" id="software-compare-dialog-cancel">{{ t('common.cancel', 'Abbrechen') }}</button>
|
||||
<button type="button" class="button" id="software-compare-dialog-start" disabled>⇄ {{ t('software.compare.button', 'Software vergleichen') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
<div class="table-scrollbar-top" id="asset-table-scroll-top" aria-hidden="true"><div></div></div>
|
||||
<div class="table-scroll sticky-table" id="asset-table-scroll">
|
||||
@@ -120,7 +147,7 @@
|
||||
<th data-no-sort="1" data-no-filter="1" class="actions-column">{{ t('common.actions', 'Aktionen') }}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for a in assets %}<tr data-platform="{{ asset_platforms.get(a.id, 'unknown') }}">
|
||||
{% for a in assets %}<tr data-platform="{{ asset_platforms.get(a.id, 'unknown') }}" data-asset-id="{{ a.id }}" data-asset-name="{{ a.name|e }}">
|
||||
<td class="asset-visual-column" data-field="asset_visual">
|
||||
<a href="/assets/{{ a.id }}" class="asset-visual-link" aria-label="{{ a.name|e }}">
|
||||
{% set visual_source = a.visual_source or 'auto' %}
|
||||
@@ -387,6 +414,14 @@
|
||||
const deleteButton = document.getElementById('bulk-delete-button');
|
||||
const mergeButton = document.getElementById('merge-duplicates-button');
|
||||
const softwareCompareButton = document.getElementById('software-compare-button');
|
||||
const softwareCompareDialog = document.getElementById('software-compare-select-dialog');
|
||||
const softwareCompareField = document.getElementById('software-compare-field-select');
|
||||
const softwareCompareSecond = document.getElementById('software-compare-second-select');
|
||||
const softwareCompareFirstName = document.getElementById('software-compare-first-name');
|
||||
const softwareCompareStatus = document.getElementById('software-compare-selection-status');
|
||||
const softwareCompareStart = document.getElementById('software-compare-dialog-start');
|
||||
const softwareCompareClose = document.getElementById('software-compare-dialog-close');
|
||||
const softwareCompareCancel = document.getElementById('software-compare-dialog-cancel');
|
||||
const jobTypeSelect = document.getElementById('bulk-job-type');
|
||||
const jobStartButton = document.getElementById('bulk-job-start-button');
|
||||
const isVisible = box => {
|
||||
@@ -424,8 +459,10 @@
|
||||
mergeButton.textContent = count >= 2 ? `⧉ {{ t('duplicates.merge') }} (${count})` : '⧉ {{ t('duplicates.merge') }}';
|
||||
}
|
||||
if (softwareCompareButton) {
|
||||
softwareCompareButton.disabled = count !== 2;
|
||||
softwareCompareButton.textContent = count === 2 ? `⇄ {{ t('software.compare.button', 'Software vergleichen') }} (2)` : '⇄ {{ t('software.compare.button', 'Software vergleichen') }}';
|
||||
softwareCompareButton.disabled = count < 1 || count > 2;
|
||||
softwareCompareButton.textContent = (count === 1 || count === 2)
|
||||
? `⇄ {{ t('software.compare.button', 'Software vergleichen') }} (${count})`
|
||||
: '⇄ {{ t('software.compare.button', 'Software vergleichen') }}';
|
||||
}
|
||||
if (jobTypeSelect) { jobTypeSelect.disabled = count === 0; filterJobDefinitions(); }
|
||||
if (jobStartButton) {
|
||||
@@ -436,13 +473,70 @@
|
||||
}
|
||||
if (all) { all.checked = visible.length > 0 && count === visible.length; all.indeterminate = count > 0 && count < visible.length; }
|
||||
}
|
||||
function submit(formId, containerId) {
|
||||
function submitIds(formId, containerId, ids) {
|
||||
const form = document.getElementById(formId);
|
||||
const container = document.getElementById(containerId);
|
||||
if (!form || !container) return;
|
||||
container.innerHTML = '';
|
||||
selected().forEach(id => { const input = document.createElement('input'); input.type='hidden'; input.name='asset_ids'; input.value=id; container.appendChild(input); });
|
||||
ids.forEach(id => { const input = document.createElement('input'); input.type='hidden'; input.name='asset_ids'; input.value=id; container.appendChild(input); });
|
||||
form.requestSubmit();
|
||||
}
|
||||
function submit(formId, containerId) {
|
||||
submitIds(formId, containerId, selected());
|
||||
}
|
||||
function selectedAssetName(assetId) {
|
||||
const box = boxes().find(item => item.value === String(assetId));
|
||||
return box?.closest('tr')?.dataset.assetName || '';
|
||||
}
|
||||
async function loadSoftwareCompareOptions() {
|
||||
const ids = selected();
|
||||
if (ids.length !== 1 || !softwareCompareField || !softwareCompareSecond) return;
|
||||
const firstId = ids[0];
|
||||
softwareCompareSecond.disabled = true;
|
||||
softwareCompareSecond.innerHTML = `<option value="">{{ t('common.loading', 'Lade…')|e }}</option>`;
|
||||
if (softwareCompareStatus) softwareCompareStatus.textContent = '';
|
||||
if (softwareCompareStart) softwareCompareStart.disabled = true;
|
||||
const target = new URL('/api/assets/software-compare/options', window.location.origin);
|
||||
target.searchParams.set('field_name', softwareCompareField.value || 'name');
|
||||
target.searchParams.set('exclude_asset_id', firstId);
|
||||
try {
|
||||
const response = await fetch(target.toString(), {headers: {'Accept': 'application/json'}, cache: 'no-store'});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
const options = Array.isArray(payload.options) ? payload.options : [];
|
||||
softwareCompareSecond.innerHTML = '';
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = options.length
|
||||
? {{ t('software.compare.choose_value', 'Wert auswählen …')|tojson }}
|
||||
: {{ t('software.compare.no_selectable_values', 'Keine passenden Werte vorhanden')|tojson }};
|
||||
softwareCompareSecond.appendChild(placeholder);
|
||||
options.forEach(item => {
|
||||
const option = document.createElement('option');
|
||||
option.value = String(item.asset_id);
|
||||
option.textContent = item.label || item.value || item.asset_name || String(item.asset_id);
|
||||
option.dataset.assetName = item.asset_name || '';
|
||||
softwareCompareSecond.appendChild(option);
|
||||
});
|
||||
softwareCompareSecond.disabled = options.length === 0;
|
||||
if (softwareCompareStatus) {
|
||||
softwareCompareStatus.textContent = options.length
|
||||
? {{ t('software.compare.available_values', '{count} Werte verfügbar')|tojson }}.replace('{count}', String(options.length))
|
||||
: {{ t('software.compare.no_selectable_values', 'Keine passenden Werte vorhanden')|tojson }};
|
||||
}
|
||||
} catch (error) {
|
||||
softwareCompareSecond.innerHTML = `<option value="">{{ t('software.compare.load_error', 'Auswahl konnte nicht geladen werden')|e }}</option>`;
|
||||
if (softwareCompareStatus) softwareCompareStatus.textContent = {{ t('software.compare.load_error', 'Auswahl konnte nicht geladen werden')|tojson }};
|
||||
}
|
||||
}
|
||||
async function openSoftwareCompareDialog() {
|
||||
const ids = selected();
|
||||
if (ids.length !== 1 || !softwareCompareDialog) return;
|
||||
if (softwareCompareFirstName) softwareCompareFirstName.value = selectedAssetName(ids[0]);
|
||||
if (softwareCompareField) softwareCompareField.value = 'name';
|
||||
softwareCompareDialog.showModal();
|
||||
await loadSoftwareCompareOptions();
|
||||
}
|
||||
table.addEventListener('change', event => {
|
||||
if (event.target.matches('.asset-select')) refresh();
|
||||
});
|
||||
@@ -450,7 +544,27 @@
|
||||
editButton?.addEventListener('click', () => submit('bulk-edit-form', 'bulk-edit-ids'));
|
||||
deleteButton?.addEventListener('click', () => submit('bulk-delete-form', 'bulk-delete-ids'));
|
||||
mergeButton?.addEventListener('click', () => submit('merge-duplicates-form', 'merge-duplicate-ids'));
|
||||
softwareCompareButton?.addEventListener('click', () => submit('software-compare-form', 'software-compare-ids'));
|
||||
softwareCompareButton?.addEventListener('click', () => {
|
||||
const ids = selected();
|
||||
if (ids.length === 2) {
|
||||
submitIds('software-compare-form', 'software-compare-ids', ids);
|
||||
return;
|
||||
}
|
||||
if (ids.length === 1) openSoftwareCompareDialog();
|
||||
});
|
||||
softwareCompareField?.addEventListener('change', loadSoftwareCompareOptions);
|
||||
softwareCompareSecond?.addEventListener('change', () => {
|
||||
if (softwareCompareStart) softwareCompareStart.disabled = !softwareCompareSecond.value;
|
||||
});
|
||||
softwareCompareStart?.addEventListener('click', () => {
|
||||
const ids = selected();
|
||||
const secondId = softwareCompareSecond?.value || '';
|
||||
if (ids.length !== 1 || !secondId) return;
|
||||
softwareCompareDialog?.close();
|
||||
submitIds('software-compare-form', 'software-compare-ids', [ids[0], secondId]);
|
||||
});
|
||||
softwareCompareClose?.addEventListener('click', () => softwareCompareDialog?.close());
|
||||
softwareCompareCancel?.addEventListener('click', () => softwareCompareDialog?.close());
|
||||
jobTypeSelect?.addEventListener('change', refresh);
|
||||
jobStartButton?.addEventListener('click', () => {
|
||||
const count = selected().length;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
--am-warning:{{ colors.get('warning_color', '#d97706') }};
|
||||
--am-danger:{{ colors.get('danger_color', '#b42318') }};
|
||||
}
|
||||
</style><script>(()=>{try{if(localStorage.getItem('assetmanager.sidebar.collapsed')==='true')document.documentElement.classList.add('sidebar-collapsed-preset')}catch(_){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title or general.get('title', 'AssetManager') }}</title>{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}<link rel="icon" href="{{ favicon_path }}">{% endif %}<link rel="stylesheet" href="/static/css/app.css?v={{ application_version() }}"><link rel="stylesheet" href="/custom.css"></head><body{% if request and request.url.path == '/assets' %} class="asset-list-scroll-page"{% endif %} data-record-count-template="{{ t('lists.record_count', 'Angezeigt: {visible} von {total} Datensätzen')|e }}" data-record-count-title="{{ t('lists.record_count_hint', 'Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter.')|e }}" data-filter-active-label="{{ t('lists.filter_active', 'Filter aktiv')|e }}" data-filters-active-template="{{ t('lists.filters_active', '{count} Filter aktiv')|e }}" data-filter-status-template="{{ t('lists.filter_status', 'Gefiltert: {visible} von {total} Datensätzen')|e }}" data-clear-filters-label="{{ t('lists.clear_filters', 'Filter zurücksetzen')|e }}" data-row-number-label="{{ t('lists.row_number', 'Zeilennummer')|e }}" data-row-number-resize-label="{{ t('lists.row_number_resize', 'Ziehen, um die Breite der Nummernspalte zu ändern')|e }}" data-column-resize-label="{{ t('lists.column_resize', 'Ziehen, um die Spaltenbreite zu ändern')|e }}" data-persist-browser-state="{{ 'true' if session_user and session_user.persist_browser_state else 'false' }}" data-toast-position="{{ session_user.toast_position if session_user else 'top-right' }}" data-toast-duration="{{ session_user.toast_duration_seconds if session_user else 6 }}">
|
||||
</style><script>(()=>{try{if(localStorage.getItem('assetmanager.sidebar.collapsed')==='true')document.documentElement.classList.add('sidebar-collapsed-preset')}catch(_){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title or general.get('title', 'AssetManager') }}</title>{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}<link rel="icon" href="{{ favicon_path }}">{% endif %}<link rel="stylesheet" href="/static/css/app.css?v={{ application_version() }}"><link rel="stylesheet" href="/custom.css"></head><body{% if request and request.url.path == '/assets' %} class="asset-list-scroll-page"{% endif %} data-record-count-template="{{ t('lists.record_count', 'Angezeigt: {visible} von {total} Datensätzen')|e }}" data-record-count-title="{{ t('lists.record_count_hint', 'Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter.')|e }}" data-filter-active-label="{{ t('lists.filter_active', 'Filter aktiv')|e }}" data-filters-active-template="{{ t('lists.filters_active', '{count} Filter aktiv')|e }}" data-filter-status-template="{{ t('lists.filter_status', 'Gefiltert: {visible} von {total} Datensätzen')|e }}" data-clear-filters-label="{{ t('lists.clear_filters', 'Filter zurücksetzen')|e }}" data-row-number-label="{{ t('lists.row_number', 'Zeilennummer')|e }}" data-row-number-resize-label="{{ t('lists.row_number_resize', 'Ziehen, um die Breite der Nummernspalte zu ändern')|e }}" data-column-resize-label="{{ t('lists.column_resize', 'Ziehen, um die Spaltenbreite zu ändern')|e }}" data-multi-value-filter-hint="{{ t('lists.multi_value_filter_hint', 'Mehrere Suchwerte können zeilenweise eingefügt werden. Innerhalb einer Spalte gilt ODER.')|e }}" data-persist-browser-state="{{ 'true' if session_user and session_user.persist_browser_state else 'false' }}" data-toast-position="{{ session_user.toast_position if session_user else 'top-right' }}" data-toast-duration="{{ session_user.toast_duration_seconds if session_user else 6 }}">
|
||||
<header class="main-header">
|
||||
<a class="brand" href="/">{% if general.get('logo') %}<img src="{{ general.get('logo') }}" alt="Logo">{% endif %}<span>{{ general.get('title', 'AssetManager') }}</span>{% if general.get('company_name') %}<span class="brand-company">– {{ general.get('company_name') }}</span>{% endif %}</a>
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
</dialog>
|
||||
|
||||
<div class="table-scroll management-table-scroll">
|
||||
<table class="data-table categories-table" data-storage-key="categories">
|
||||
<table class="data-table categories-table" data-storage-key="categories" data-default-sort-field="name" data-default-sort-direction="asc">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="selection-column" data-no-sort="1" data-no-filter="1" data-field="selection">
|
||||
|
||||