Compare commits
14 commits
a6f301ee54
...
30f3c16b59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30f3c16b59 | ||
|
|
fb81f60f9d | ||
|
|
48d12a1b4a | ||
| 03af64ca2c | |||
| 53e80ad7be | |||
| 78095cca1d | |||
| 61193e26f4 | |||
| 27ed51ee95 | |||
| e0cb1ac68c | |||
| bbbd1c8940 | |||
| 1c7ecabcaf | |||
| 422b196831 | |||
| d3bdb1fdec | |||
| 029b1a86d4 |
11 changed files with 652 additions and 229 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
|||
/.idea/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
plugins/lookup/__pycache__/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
140
roles/homarr/filter_plugins/homarr_layout.py
Normal file
140
roles/homarr/filter_plugins/homarr_layout.py
Normal 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,
|
||||
}
|
||||
178
roles/homarr/filter_plugins/tests/test_homarr_layout.py
Normal file
178
roles/homarr/filter_plugins/tests/test_homarr_layout.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -25,18 +25,26 @@ opnform_redis_image: "redis:7"
|
|||
opnform_db_image: "postgres:16"
|
||||
opnform_ingress_image: "nginx:1"
|
||||
|
||||
# REQUIRED SECRETS — generate with: openssl rand -base64 32
|
||||
# Always prefix opnform_app_key with "base64:"
|
||||
# REQUIRED SECRETS — must be overridden per-inventory.
|
||||
# Provide via OpenBao lookup, Ansible Vault or extra-vars.
|
||||
# Never commit real keys to version control.
|
||||
opnform_app_key: "base64:vsQw8EoC64nmhurLUUohXUlAeryaV6Y2Is64Tdvjlko="
|
||||
opnform_jwt_secret: "0b2e8ed326334a08ce3846bfcd6588f5a11be33999e96963cd4eaff1a3ae828b"
|
||||
opnform_front_api_secret: "8f52397785a110b657f2a6beab13362877bfac936ae9002bc236c54ed1011b2d"
|
||||
#
|
||||
# Generate with:
|
||||
# opnform_app_key: echo "base64:$(openssl rand -base64 32)"
|
||||
# opnform_jwt_secret: openssl rand -hex 32
|
||||
# opnform_front_api_secret: openssl rand -hex 32
|
||||
#
|
||||
# opnform_app_key MUST start with the prefix "base64:" — the validate
|
||||
# task at the top of tasks/main.yml enforces this.
|
||||
opnform_app_key: ""
|
||||
opnform_jwt_secret: ""
|
||||
opnform_front_api_secret: ""
|
||||
|
||||
# Database credentials
|
||||
# Database credentials. opnform_db_password must be overridden; the
|
||||
# validate task fails fast on an empty value.
|
||||
opnform_db_name: "opnform"
|
||||
opnform_db_user: "opnform"
|
||||
opnform_db_password: "xtNLUVc2ajcWictqWXWkLR"
|
||||
opnform_db_password: ""
|
||||
|
||||
# Admin bootstrap — when email+password are set, the role creates the
|
||||
# first user via OpnForm's /api/register endpoint, skipping the
|
||||
|
|
|
|||
220
roles/opnform/meta/argument_specs.yml
Normal file
220
roles/opnform/meta/argument_specs.yml
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Deploy OpnForm (api + ui + db + redis + ingress) via Docker Compose.
|
||||
description:
|
||||
- Renders a Compose stack for the full OpnForm setup (PHP-FPM api,
|
||||
Nuxt ui, Postgres, Redis, nginx ingress) and exposes it through
|
||||
Traefik.
|
||||
- Optionally bootstraps the first admin user via the OpnForm
|
||||
C(/api/register) endpoint (skipping the self-hosted setup page)
|
||||
and provisions a single OIDC identity connection in the default
|
||||
workspace via the workspace API. Both bootstraps are idempotent.
|
||||
options:
|
||||
docker_compose_base_dir:
|
||||
type: path
|
||||
default: /etc/docker/compose
|
||||
docker_volume_base_dir:
|
||||
type: path
|
||||
default: /srv/data
|
||||
opnform_service_name:
|
||||
type: str
|
||||
default: opnform
|
||||
opnform_docker_compose_dir:
|
||||
type: path
|
||||
description: Defaults to C({{ docker_compose_base_dir }}/{{ opnform_service_name }}).
|
||||
opnform_docker_volume_dir:
|
||||
type: path
|
||||
description: Defaults to C({{ docker_volume_base_dir }}/{{ opnform_service_name }}).
|
||||
opnform_storage_dir:
|
||||
type: path
|
||||
description: OpnForm storage volume mounted into the api container.
|
||||
opnform_db_data_dir:
|
||||
type: path
|
||||
opnform_redis_data_dir:
|
||||
type: path
|
||||
|
||||
opnform_domain:
|
||||
type: str
|
||||
default: forms.local.test
|
||||
description: Hostname used in the traefik Host rule.
|
||||
opnform_base_url:
|
||||
type: str
|
||||
default: https://forms.local.test
|
||||
description: Public URL OpnForm uses for APP_URL and NUXT_PUBLIC_APP_URL.
|
||||
|
||||
opnform_api_image:
|
||||
type: str
|
||||
default: jhumanj/opnform-api:latest
|
||||
opnform_client_image:
|
||||
type: str
|
||||
default: jhumanj/opnform-client:latest
|
||||
opnform_redis_image:
|
||||
type: str
|
||||
default: "redis:7"
|
||||
opnform_db_image:
|
||||
type: str
|
||||
default: "postgres:16"
|
||||
opnform_ingress_image:
|
||||
type: str
|
||||
default: "nginx:1"
|
||||
|
||||
opnform_app_key:
|
||||
type: str
|
||||
required: true
|
||||
description:
|
||||
- Laravel application key. Must be prefixed with C(base64:).
|
||||
Generate with C(echo "base64:$(openssl rand -base64 32)").
|
||||
Provide via OpenBao, Ansible Vault or extra-vars.
|
||||
opnform_jwt_secret:
|
||||
type: str
|
||||
required: true
|
||||
description: JWT signing secret. Generate with C(openssl rand -hex 32).
|
||||
opnform_front_api_secret:
|
||||
type: str
|
||||
required: true
|
||||
description: Shared secret between ui and api. Generate with C(openssl rand -hex 32).
|
||||
|
||||
opnform_db_name:
|
||||
type: str
|
||||
default: opnform
|
||||
opnform_db_user:
|
||||
type: str
|
||||
default: opnform
|
||||
opnform_db_password:
|
||||
type: str
|
||||
required: true
|
||||
|
||||
opnform_admin_name:
|
||||
type: str
|
||||
default: Administrator
|
||||
opnform_admin_email:
|
||||
type: str
|
||||
default: ''
|
||||
description:
|
||||
- When non-empty (together with C(opnform_admin_password)) the role
|
||||
bootstraps the first user via C(/api/register), skipping the
|
||||
self-hosted setup page. Required when C(opnform_oidc_enabled=true).
|
||||
opnform_admin_password:
|
||||
type: str
|
||||
default: ''
|
||||
description:
|
||||
- "Must satisfy OpnForm's policy: min 8 chars, letter + digit +
|
||||
symbol from C(@$!%*#?&-_+=.,:;<>^()[]{}|~)."
|
||||
opnform_admin_hear_about_us:
|
||||
type: str
|
||||
default: ansible
|
||||
|
||||
opnform_php_memory_limit:
|
||||
type: str
|
||||
default: 1G
|
||||
opnform_php_max_execution_time:
|
||||
type: str
|
||||
default: "600"
|
||||
opnform_php_upload_max_filesize:
|
||||
type: str
|
||||
default: 64M
|
||||
opnform_php_post_max_size:
|
||||
type: str
|
||||
default: 64M
|
||||
opnform_nginx_max_body_size:
|
||||
type: str
|
||||
default: 64m
|
||||
|
||||
opnform_mail_mailer:
|
||||
type: str
|
||||
default: log
|
||||
choices: [log, smtp, ses, mailgun, postmark, sendmail]
|
||||
opnform_mail_host:
|
||||
type: str
|
||||
default: ''
|
||||
opnform_mail_port:
|
||||
type: str
|
||||
default: ''
|
||||
opnform_mail_username:
|
||||
type: str
|
||||
default: ''
|
||||
opnform_mail_password:
|
||||
type: str
|
||||
default: ''
|
||||
opnform_mail_encryption:
|
||||
type: str
|
||||
default: ''
|
||||
choices: ['', tls, ssl]
|
||||
opnform_mail_from_address:
|
||||
type: str
|
||||
default: noreply@digitalboard.ch
|
||||
opnform_mail_from_name:
|
||||
type: str
|
||||
default: OpnForm
|
||||
|
||||
opnform_oidc_enabled:
|
||||
type: bool
|
||||
default: false
|
||||
description:
|
||||
- "When true the role calls the workspace API to create a single
|
||||
OIDC C(identity_connection) on the default workspace after the
|
||||
admin bootstrap. Requires C(opnform_admin_email) +
|
||||
C(opnform_admin_password) so the role can authenticate.
|
||||
Idempotent: skipped when any connection already exists."
|
||||
opnform_oidc_issuer:
|
||||
type: str
|
||||
default: https://auth.digitalboard.ch/realms/Digitalboard
|
||||
description: OIDC issuer URL.
|
||||
opnform_oidc_client_id:
|
||||
type: str
|
||||
default: opnform-digitalboard
|
||||
opnform_oidc_client_secret:
|
||||
type: str
|
||||
default: ''
|
||||
description: Required when C(opnform_oidc_enabled=true).
|
||||
opnform_oidc_client_name:
|
||||
type: str
|
||||
default: Digitalboard
|
||||
description: Display name shown in the OpnForm UI.
|
||||
opnform_oidc_slug:
|
||||
type: str
|
||||
default: oidc
|
||||
description:
|
||||
- OpnForm-side identifier used in C(/auth/{slug}/callback). Lowercase
|
||||
alphanumeric + hyphens, unique across all C(identity_connections).
|
||||
opnform_oidc_domain:
|
||||
type: str
|
||||
default: ''
|
||||
description:
|
||||
- Email domain that triggers OIDC for matching users. Required
|
||||
when C(opnform_oidc_enabled=true).
|
||||
opnform_oidc_scopes:
|
||||
type: list
|
||||
elements: str
|
||||
default: [openid, profile, email, groups]
|
||||
opnform_oidc_admin_group:
|
||||
type: str
|
||||
default: opnform-admins
|
||||
description:
|
||||
- Convenience setting that maps a single IdP group to the OpnForm
|
||||
C(admin) role. Ignored when C(opnform_oidc_group_role_mappings)
|
||||
is non-empty.
|
||||
opnform_oidc_group_role_mappings:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
description:
|
||||
- Full IdP-group -> OpnForm-role mapping. Takes precedence over
|
||||
C(opnform_oidc_admin_group).
|
||||
options:
|
||||
idp_group:
|
||||
type: str
|
||||
required: true
|
||||
description: Group name as it appears in the IdP groups claim.
|
||||
role:
|
||||
type: str
|
||||
required: true
|
||||
choices: [owner, admin, editor, member]
|
||||
|
||||
opnform_traefik_network:
|
||||
type: str
|
||||
default: proxy
|
||||
opnform_use_ssl:
|
||||
type: bool
|
||||
default: true
|
||||
|
|
@ -1,35 +1,16 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: Tobias Wüst
|
||||
description: Deploy OpnForm self-hosted form builder via Docker Compose behind Traefik
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
min_ansible_version: "2.15"
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
|
||||
min_ansible_version: 2.2
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- opnform
|
||||
- forms
|
||||
- docker
|
||||
- traefik
|
||||
- oidc
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
---
|
||||
services:
|
||||
api: &api-environment
|
||||
image: jhumanj/opnform-api:latest
|
||||
container_name: opnform-api
|
||||
volumes: &api-environment-volumes
|
||||
- opnform_storage:/usr/share/nginx/html/storage:rw
|
||||
environment: &api-env
|
||||
APP_ENV: production
|
||||
# Database settings
|
||||
DB_HOST: db
|
||||
REDIS_HOST: redis
|
||||
DB_DATABASE: ${DB_DATABASE:-forge}
|
||||
DB_USERNAME: ${DB_USERNAME:-forge}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-forge}
|
||||
DB_CONNECTION: ${DB_CONNECTION:-pgsql}
|
||||
# PHP Configuration
|
||||
PHP_MEMORY_LIMIT: "1G"
|
||||
PHP_MAX_EXECUTION_TIME: "600"
|
||||
PHP_UPLOAD_MAX_FILESIZE: "64M"
|
||||
PHP_POST_MAX_SIZE: "64M"
|
||||
env_file:
|
||||
- ./api/.env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy # Depend on redis being healthy too
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
api-worker:
|
||||
<<: *api-environment
|
||||
container_name: opnform-api-worker
|
||||
command: ["php", "artisan", "queue:work"]
|
||||
environment:
|
||||
<<: *api-env
|
||||
APP_ENV: production
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
api-scheduler:
|
||||
<<: *api-environment
|
||||
container_name: opnform-api-scheduler
|
||||
command: ["php", "artisan", "schedule:work"]
|
||||
environment:
|
||||
<<: *api-env
|
||||
APP_ENV: production
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1",
|
||||
]
|
||||
interval: 60s
|
||||
timeout: 30s
|
||||
retries: 3
|
||||
start_period: 70s # Allow time for first scheduled run and cache write
|
||||
|
||||
ui:
|
||||
image: jhumanj/opnform-client:latest
|
||||
container_name: opnform-client
|
||||
env_file:
|
||||
- ./client/.env
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 45s
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
container_name: opnform-redis
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: opnform-db
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_DATABASE:-forge}
|
||||
POSTGRES_USER: ${DB_USERNAME:-forge}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-forge}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-forge}"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
ingress:
|
||||
image: nginx:1
|
||||
container_name: opnform-ingress
|
||||
volumes:
|
||||
- ./docker/nginx.conf:/etc/nginx/templates/default.conf.template
|
||||
ports:
|
||||
- 80:80
|
||||
environment:
|
||||
- NGINX_MAX_BODY_SIZE=64m
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
ui:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
opnform_storage:
|
||||
redis-data:
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# vars file for homarr
|
||||
# vars file for opnform
|
||||
Loading…
Add table
Add a link
Reference in a new issue