refactor(homarr): extract layout packing to filter plugin

This commit is contained in:
Tobias Wüst 2026-05-19 11:19:29 +02:00
parent e0cb1ac68c
commit 61193e26f4
Signed by: Tobias-Wuest
GPG key ID: 2D8992B0F4CA97E8
6 changed files with 405 additions and 58 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
/.idea/ /.idea/
plugins/lookup/__pycache__/

View file

@ -13,7 +13,7 @@ and customizable application tiles.
- a local admin user with bcrypt-hashed password - a local admin user with bcrypt-hashed password
- OIDC and credentials admin groups with full permissions - OIDC and credentials admin groups with full permissions
- application tiles defined in `homarr_apps`, auto-laid-out across all - application tiles defined in `homarr_apps`, auto-laid-out across all
three screen sizes three screen sizes via the bundled `homarr_compute_layouts` filter
- Skips the onboarding wizard so the instance is usable right after deploy - Skips the onboarding wizard so the instance is usable right after deploy
- Restarts the container via handler when the seed or compose file changes - Restarts the container via handler when the seed or compose file changes
@ -100,6 +100,53 @@ homarr_apps:
width: 2 width: 2
``` ```
## Layout filter plugin
The grid-packing algorithm that places tiles on the desktop, tablet
and mobile layouts lives in `filter_plugins/homarr_layout.py` rather
than inside the Jinja seed template. This keeps the SQL template
readable and lets the algorithm be unit-tested in isolation.
The filter is invoked once from `tasks/main.yml`:
```yaml
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
```
This produces a `homarr_layout` fact with two keys, both consumed by
`templates/homarr_seed.sql.j2`:
| Key | Shape | Purpose |
|---|---|---|
| `apps` | list, same order as `homarr_apps` | each entry enriched with `desktop`, `tablet`, `mobile` sub-dicts of `{x, y, w, h}` |
| `section_height` | dict with `desktop`, `tablet`, `mobile` | minimum height of the parent section so all tiles fit |
The filter signature accepts custom column counts if Homarr ever
changes the breakpoint widths:
```jinja
{{ homarr_apps | homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }}
```
To debug a layout without running the full deploy, run the play with
`-vv` — the `Show computed app layouts` task dumps the full
`homarr_layout` fact.
### Running the filter tests
The filter is covered by unit tests in
`filter_plugins/tests/test_homarr_layout.py`:
```bash
pip install pytest ansible-core
pytest filter_plugins/tests/
```
15 tests cover packing, width clamping, height/section-height,
input validation and custom grid sizes.
## First login ## First login
After the role completes, log in at `{{ homarr_base_url }}` with: After the role completes, log in at `{{ homarr_base_url }}` with:

View file

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT-0
"""Custom Ansible filter plugin for computing Homarr grid layouts.
The Homarr SQL seed needs item_layout rows for three breakpoints
(desktop / tablet / mobile). Rather than embedding the packing
algorithm in Jinja with namespace gymnastics, this filter does the
computation in Python and hands the seed template a ready-to-render
data structure.
Usage in tasks:
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
The result is a dict with two keys:
apps original homarr_apps in order, each enriched with
'desktop', 'tablet', 'mobile' sub-dicts of
{'x', 'y', 'w', 'h'} ready for SQL templating.
section_height dict with 'desktop', 'tablet', 'mobile' keys
giving the minimum height (in grid cells) the
parent section must have to fit all tiles.
"""
from ansible.errors import AnsibleFilterError
def _pack(apps, cols):
"""Greedy left-to-right packing into a fixed-column grid.
Width values larger than the grid are clamped to the grid width
rather than overflowing so a tile declared with width=8 still
renders on the 6-column tablet grid (as a full-width tile) and on
the 2-column mobile grid (as a full-width tile) without breaking
the layout.
Returns (placements, total_height) where placements is a list of
{'id', 'x', 'y', 'w', 'h'} dicts, one per input app, in the same
order. total_height is the y-coordinate of the bottom of the last
occupied row (i.e. max(y + h) across placements).
"""
x = 0
y = 0
row_h = 0
max_y = 0
placements = []
for app in apps:
w = min(int(app.get('width', 1)), cols)
h = int(app.get('height', 1))
# Wrap to the next row when the tile would overflow the grid.
if x + w > cols:
x = 0
y += row_h
row_h = 0
placements.append({
'id': app['id'],
'x': x,
'y': y,
'w': w,
'h': h,
})
x += w
if h > row_h:
row_h = h
if y + h > max_y:
max_y = y + h
return placements, max_y
def homarr_compute_layouts(apps, desktop_cols=10, tablet_cols=6,
mobile_cols=2):
"""Compute responsive layouts for a list of Homarr apps.
Input validation is intentionally strict a malformed apps list
should fail the play with a clear message rather than produce a
broken SQL seed and a silently misconfigured dashboard.
Note: uniqueness of app ids is NOT checked here. The role's
`Validate homarr_apps have unique ids` assert task runs earlier
and is the single source of truth for that check.
"""
if not isinstance(apps, list):
raise AnsibleFilterError(
"homarr_compute_layouts: expected a list of apps, "
"got {0}".format(type(apps).__name__)
)
for index, app in enumerate(apps):
if not isinstance(app, dict):
raise AnsibleFilterError(
"homarr_compute_layouts: app at index {0} is not a "
"dict (got {1})".format(index, type(app).__name__)
)
for required in ('id', 'width'):
if required not in app:
raise AnsibleFilterError(
"homarr_compute_layouts: app at index {0} is "
"missing required key '{1}'".format(index, required)
)
desktop, h_desktop = _pack(apps, desktop_cols)
tablet, h_tablet = _pack(apps, tablet_cols)
mobile, h_mobile = _pack(apps, mobile_cols)
enriched = []
for src, d, t, m in zip(apps, desktop, tablet, mobile):
enriched.append({
**src,
'desktop': {'x': d['x'], 'y': d['y'], 'w': d['w'], 'h': d['h']},
'tablet': {'x': t['x'], 'y': t['y'], 'w': t['w'], 'h': t['h']},
'mobile': {'x': m['x'], 'y': m['y'], 'w': m['w'], 'h': m['h']},
})
# Floor section height at 1 so the section stays visible even
# when homarr_apps is empty.
return {
'apps': enriched,
'section_height': {
'desktop': max(h_desktop, 1),
'tablet': max(h_tablet, 1),
'mobile': max(h_mobile, 1),
},
}
class FilterModule(object):
"""Ansible filter plugin entry point."""
def filters(self):
return {
'homarr_compute_layouts': homarr_compute_layouts,
}

View file

@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT-0
"""Unit tests for the homarr_layout filter plugin.
Run from the role root:
pytest filter_plugins/tests/
Requires `pytest` and `ansible-core` in the environment.
"""
import os
import sys
# Make the filter importable without having Ansible auto-discovery in
# the way (it would only run during a real `ansible-playbook` invocation).
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import pytest # noqa: E402
from ansible.errors import AnsibleFilterError # noqa: E402
from homarr_layout import homarr_compute_layouts # noqa: E402
def _app(app_id, width, height=1):
"""Build a minimal app dict for tests."""
return {
'id': app_id,
'name': app_id.title(),
'icon': 'https://example.com/{0}.png'.format(app_id),
'href': 'https://{0}.example.com'.format(app_id),
'width': width,
'height': height,
}
# ---------------------------------------------------------------------
# Happy-path packing
# ---------------------------------------------------------------------
def test_empty_apps_returns_empty_list_and_min_height():
result = homarr_compute_layouts([])
assert result['apps'] == []
# Even an empty grid keeps section_height >= 1 so the section
# renders in the UI.
assert result['section_height'] == {
'desktop': 1, 'tablet': 1, 'mobile': 1,
}
def test_single_app_positioned_at_origin_in_all_grids():
result = homarr_compute_layouts([_app('a', width=2)])
a = result['apps'][0]
assert a['desktop'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
assert a['tablet'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
assert a['mobile'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
def test_original_app_keys_are_preserved():
apps = [_app('nextcloud', width=2)]
apps[0]['description'] = 'Cloud Storage'
result = homarr_compute_layouts(apps)
a = result['apps'][0]
# Original fields survive the layout enrichment.
assert a['name'] == 'Nextcloud'
assert a['description'] == 'Cloud Storage'
assert a['icon'] == 'https://example.com/nextcloud.png'
def test_desktop_wraps_after_filling_row():
# 5 apps of width 2 fill the 10-col desktop row exactly; 6th wraps.
apps = [_app('a{0}'.format(i), width=2) for i in range(6)]
result = homarr_compute_layouts(apps)
assert result['apps'][4]['desktop'] == {'x': 8, 'y': 0, 'w': 2, 'h': 1}
assert result['apps'][5]['desktop'] == {'x': 0, 'y': 1, 'w': 2, 'h': 1}
def test_mobile_wraps_after_every_app():
# Mobile is only 2 cols wide → every app of width 2 starts a new row.
apps = [_app('a{0}'.format(i), width=2) for i in range(3)]
result = homarr_compute_layouts(apps)
assert [a['mobile']['y'] for a in result['apps']] == [0, 1, 2]
# ---------------------------------------------------------------------
# Width clamping
# ---------------------------------------------------------------------
def test_width_clamped_per_grid():
result = homarr_compute_layouts([_app('big', width=8)])
# Desktop has room (8 <= 10), tablet clamps to 6, mobile clamps to 2.
a = result['apps'][0]
assert a['desktop']['w'] == 8
assert a['tablet']['w'] == 6
assert a['mobile']['w'] == 2
def test_width_larger_than_desktop_still_clamps():
# A pathological width=20 still works — it just becomes a full-width
# tile on every grid.
result = homarr_compute_layouts([_app('huge', width=20)])
a = result['apps'][0]
assert a['desktop']['w'] == 10
assert a['tablet']['w'] == 6
assert a['mobile']['w'] == 2
# ---------------------------------------------------------------------
# Height handling
# ---------------------------------------------------------------------
def test_section_height_grows_with_rows():
# 6 apps of width 2 on desktop → 5 in row 1, 1 in row 2.
apps = [_app('a{0}'.format(i), width=2) for i in range(6)]
result = homarr_compute_layouts(apps)
assert result['section_height']['desktop'] == 2
# On mobile every app is on its own row.
assert result['section_height']['mobile'] == 6
def test_tall_app_extends_row_height():
apps = [
_app('tall', width=2, height=3),
_app('short', width=2, height=1),
]
result = homarr_compute_layouts(apps)
# Both fit in row 0 horizontally, but the section must be 3 tall.
assert result['section_height']['desktop'] == 3
def test_tall_app_pushes_subsequent_row_down():
# tall (h=3) fills full desktop width → next app wraps to y=3.
result = homarr_compute_layouts([
_app('tall', width=10, height=3),
_app('next', width=2, height=1),
])
assert result['apps'][1]['desktop'] == {'x': 0, 'y': 3, 'w': 2, 'h': 1}
# ---------------------------------------------------------------------
# Input validation
# ---------------------------------------------------------------------
def test_rejects_non_list_input():
with pytest.raises(AnsibleFilterError, match='expected a list'):
homarr_compute_layouts('not a list')
def test_rejects_non_dict_entry():
with pytest.raises(AnsibleFilterError, match='not a dict'):
homarr_compute_layouts(['just a string'])
def test_rejects_app_without_id():
with pytest.raises(AnsibleFilterError, match="missing required key 'id'"):
homarr_compute_layouts([{'name': 'no id', 'width': 2}])
def test_rejects_app_without_width():
with pytest.raises(AnsibleFilterError,
match="missing required key 'width'"):
homarr_compute_layouts([{'id': 'no-width', 'name': 'x'}])
# ---------------------------------------------------------------------
# Configurable grid sizes
# ---------------------------------------------------------------------
def test_custom_grid_sizes():
# If Homarr ever switches to 12-col desktop, the filter still works.
result = homarr_compute_layouts(
[_app('a', width=4), _app('b', width=4), _app('c', width=4)],
desktop_cols=12, tablet_cols=8, mobile_cols=4,
)
# All three fit in desktop row 0 (4+4+4 = 12).
assert [a['desktop']['x'] for a in result['apps']] == [0, 4, 8]
assert result['section_height']['desktop'] == 1

View file

@ -128,7 +128,23 @@
no_log: true no_log: true
# ===================================================================== # =====================================================================
# 5. SEED DATABASE (only if local admin user does not exist yet) # 5. COMPUTE APP LAYOUTS
# =====================================================================
# Packing is done by the homarr_compute_layouts filter plugin (Python)
# rather than inline Jinja, so the seed template stays readable and the
# packing algorithm can be unit-tested in isolation.
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
- name: Show computed app layouts
ansible.builtin.debug:
var: homarr_layout
verbosity: 1
# =====================================================================
# 6. SEED DATABASE (only if local admin user does not exist yet)
# ===================================================================== # =====================================================================
- name: Check if local admin user exists - name: Check if local admin user exists

View file

@ -1,35 +1,13 @@
{#- {#-
Auto-layout packing macro. Homarr database seed.
Greedy left-to-right packing of apps into a grid with `cols` columns. The packing algorithm previously lived in this template as a Jinja
Returns the list of apps with computed x/y/w/h fields. `pack()` macro with from_json/to_json round-trips. It has been
extracted to the `homarr_compute_layouts` filter plugin (see
Width is clamped to cols (so an app wider than the grid is downsized filter_plugins/homarr_layout.py) and the result is provided as the
rather than overflowing). Height is taken as-is. `homarr_layout` fact set in tasks/main.yml. This template therefore
only renders SQL — no logic.
-#} -#}
{%- macro pack(apps, cols) -%}
{%- set ns = namespace(x=0, y=0, row_h=0, out=[]) -%}
{%- for app in apps -%}
{%- set w = [app.width, cols] | min -%}
{%- set h = app.height | default(1) -%}
{%- if ns.x + w > cols -%}
{%- set ns.x = 0 -%}
{%- set ns.y = ns.y + ns.row_h -%}
{%- set ns.row_h = 0 -%}
{%- endif -%}
{%- set _ = ns.out.append({'id': app.id, 'x': ns.x, 'y': ns.y, 'w': w, 'h': h}) -%}
{%- set ns.x = ns.x + w -%}
{%- if h > ns.row_h -%}
{%- set ns.row_h = h -%}
{%- endif -%}
{%- endfor -%}
{{- ns.out | to_json -}}
{%- endmacro -%}
{%- set desktop_layout = pack(homarr_apps, 10) | from_json -%}
{%- set tablet_layout = pack(homarr_apps, 6) | from_json -%}
{%- set mobile_layout = pack(homarr_apps, 2) | from_json -%}
BEGIN TRANSACTION; BEGIN TRANSACTION;
-- ===================================================================== -- =====================================================================
@ -148,11 +126,14 @@ VALUES (
'{"json": {}}' '{"json": {}}'
); );
-- Section height is sized to fit the computed layout (see
-- homarr_compute_layouts filter). It grows automatically when more
-- apps or taller tiles are added.
INSERT OR REPLACE INTO section_layout (section_id, layout_id, parent_section_id, x_offset, y_offset, width, height) INSERT OR REPLACE INTO section_layout (section_id, layout_id, parent_section_id, x_offset, y_offset, width, height)
VALUES VALUES
('section-apps', 'layout-desktop', NULL, 0, 0, 10, 3), ('section-apps', 'layout-desktop', NULL, 0, 0, 10, {{ homarr_layout.section_height.desktop }}),
('section-apps', 'layout-tablet', NULL, 0, 0, 6, 4), ('section-apps', 'layout-tablet', NULL, 0, 0, 6, {{ homarr_layout.section_height.tablet }}),
('section-apps', 'layout-mobile', NULL, 0, 0, 2, 6); ('section-apps', 'layout-mobile', NULL, 0, 0, 2, {{ homarr_layout.section_height.mobile }});
-- Board permissions -- Board permissions
INSERT OR IGNORE INTO boardGroupPermission (board_id, group_id, permission) INSERT OR IGNORE INTO boardGroupPermission (board_id, group_id, permission)
@ -161,11 +142,11 @@ VALUES
('board-default', 'group-credentials-admin', 'full-access'); ('board-default', 'group-credentials-admin', 'full-access');
-- ===================================================================== -- =====================================================================
-- APPS (auto-generated from homarr_apps variable) -- APPS (positions pre-computed by homarr_compute_layouts filter)
-- ===================================================================== -- =====================================================================
{% if homarr_apps | length > 0 %} {% for app in homarr_layout.apps %}
{% for app in homarr_apps %} -- {{ app.name }}
INSERT OR IGNORE INTO app (id, name, description, icon_url, href) INSERT OR IGNORE INTO app (id, name, description, icon_url, href)
VALUES ( VALUES (
'app-{{ app.id }}', 'app-{{ app.id }}',
@ -184,28 +165,11 @@ VALUES (
'{"json": {}}' '{"json": {}}'
); );
{% endfor %}
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height) INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
VALUES VALUES
{% for entry in desktop_layout %} ('item-{{ app.id }}', 'section-apps', 'layout-desktop', {{ app.desktop.x }}, {{ app.desktop.y }}, {{ app.desktop.w }}, {{ app.desktop.h }}),
('item-{{ entry.id }}', 'section-apps', 'layout-desktop', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %} ('item-{{ app.id }}', 'section-apps', 'layout-tablet', {{ app.tablet.x }}, {{ app.tablet.y }}, {{ app.tablet.w }}, {{ app.tablet.h }}),
{% endfor %} ('item-{{ app.id }}', 'section-apps', 'layout-mobile', {{ app.mobile.x }}, {{ app.mobile.y }}, {{ app.mobile.w }}, {{ app.mobile.h }});
;
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
VALUES
{% for entry in tablet_layout %}
('item-{{ entry.id }}', 'section-apps', 'layout-tablet', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %}
{% endfor %} {% endfor %}
; COMMIT;
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
VALUES
{% for entry in mobile_layout %}
('item-{{ entry.id }}', 'section-apps', 'layout-mobile', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %}
{% endfor %}
;
{% endif %}
COMMIT;