diff --git a/.gitignore b/.gitignore index 2434dcf..a84afb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /.idea/ __pycache__/ *.pyc + +plugins/lookup/__pycache__/ diff --git a/roles/homarr/README.md b/roles/homarr/README.md index db0ed4d..1e92cba 100644 --- a/roles/homarr/README.md +++ b/roles/homarr/README.md @@ -13,7 +13,7 @@ and customizable application tiles. - a local admin user with bcrypt-hashed password - OIDC and credentials admin groups with full permissions - 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 - Restarts the container via handler when the seed or compose file changes @@ -100,6 +100,53 @@ homarr_apps: 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 After the role completes, log in at `{{ homarr_base_url }}` with: diff --git a/roles/homarr/filter_plugins/homarr_layout.py b/roles/homarr/filter_plugins/homarr_layout.py new file mode 100644 index 0000000..6650709 --- /dev/null +++ b/roles/homarr/filter_plugins/homarr_layout.py @@ -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, + } diff --git a/roles/homarr/filter_plugins/tests/test_homarr_layout.py b/roles/homarr/filter_plugins/tests/test_homarr_layout.py new file mode 100644 index 0000000..3a49f2b --- /dev/null +++ b/roles/homarr/filter_plugins/tests/test_homarr_layout.py @@ -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 diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index 06488f4..9d00cde 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -128,7 +128,23 @@ 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 diff --git a/roles/homarr/templates/homarr_seed.sql.j2 b/roles/homarr/templates/homarr_seed.sql.j2 index fdb1a2f..1d2526b 100644 --- a/roles/homarr/templates/homarr_seed.sql.j2 +++ b/roles/homarr/templates/homarr_seed.sql.j2 @@ -1,35 +1,13 @@ {#- - Auto-layout packing macro. + Homarr database seed. - Greedy left-to-right packing of apps into a grid with `cols` columns. - Returns the list of apps with computed x/y/w/h fields. - - Width is clamped to cols (so an app wider than the grid is downsized - rather than overflowing). Height is taken as-is. + The packing algorithm previously lived in this template as a Jinja + `pack()` macro with from_json/to_json round-trips. It has been + extracted to the `homarr_compute_layouts` filter plugin (see + filter_plugins/homarr_layout.py) and the result is provided as the + `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; -- ===================================================================== @@ -148,11 +126,14 @@ VALUES ( '{"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) VALUES - ('section-apps', 'layout-desktop', NULL, 0, 0, 10, 3), - ('section-apps', 'layout-tablet', NULL, 0, 0, 6, 4), - ('section-apps', 'layout-mobile', NULL, 0, 0, 2, 6); + ('section-apps', 'layout-desktop', NULL, 0, 0, 10, {{ homarr_layout.section_height.desktop }}), + ('section-apps', 'layout-tablet', NULL, 0, 0, 6, {{ homarr_layout.section_height.tablet }}), + ('section-apps', 'layout-mobile', NULL, 0, 0, 2, {{ homarr_layout.section_height.mobile }}); -- Board permissions INSERT OR IGNORE INTO boardGroupPermission (board_id, group_id, permission) @@ -161,11 +142,11 @@ VALUES ('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_apps %} +{% for app in homarr_layout.apps %} +-- {{ app.name }} INSERT OR IGNORE INTO app (id, name, description, icon_url, href) VALUES ( 'app-{{ app.id }}', @@ -184,28 +165,11 @@ VALUES ( '{"json": {}}' ); -{% endfor %} - INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height) VALUES -{% for entry in desktop_layout %} - ('item-{{ entry.id }}', 'section-apps', 'layout-desktop', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %} -{% endfor %} -; + ('item-{{ app.id }}', 'section-apps', 'layout-desktop', {{ app.desktop.x }}, {{ app.desktop.y }}, {{ app.desktop.w }}, {{ app.desktop.h }}), + ('item-{{ app.id }}', 'section-apps', 'layout-tablet', {{ app.tablet.x }}, {{ app.tablet.y }}, {{ app.tablet.w }}, {{ app.tablet.h }}), + ('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 %} -; - -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; \ No newline at end of file +COMMIT;