pixie-lab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. pixie/__init__.py +20 -0
  2. pixie/_util.py +38 -0
  3. pixie/catalog/__init__.py +25 -0
  4. pixie/catalog/_fetcher.py +513 -0
  5. pixie/catalog/_routes.py +392 -0
  6. pixie/catalog/_schema.py +263 -0
  7. pixie/catalog/_store.py +234 -0
  8. pixie/deploy/__init__.py +26 -0
  9. pixie/deploy/_main.py +321 -0
  10. pixie/deploy/_templates.py +153 -0
  11. pixie/disks.py +85 -0
  12. pixie/events/__init__.py +28 -0
  13. pixie/events/_kinds.py +203 -0
  14. pixie/events/_log.py +210 -0
  15. pixie/events/_routes.py +42 -0
  16. pixie/exports/__init__.py +17 -0
  17. pixie/exports/_routes.py +211 -0
  18. pixie/exports/_store.py +156 -0
  19. pixie/exports/_supervisor.py +240 -0
  20. pixie/flash.py +1971 -0
  21. pixie/images.py +473 -0
  22. pixie/machines/__init__.py +16 -0
  23. pixie/machines/_routes.py +190 -0
  24. pixie/machines/_store.py +623 -0
  25. pixie/oras.py +547 -0
  26. pixie/pivot/__init__.py +180 -0
  27. pixie/pivot/nbdboot +348 -0
  28. pixie/pxe/__init__.py +19 -0
  29. pixie/pxe/_renderer.py +244 -0
  30. pixie/pxe/_routes.py +386 -0
  31. pixie/tftp/__init__.py +21 -0
  32. pixie/tftp/_supervisor.py +129 -0
  33. pixie/tui/__init__.py +185 -0
  34. pixie/tui/_app.py +2219 -0
  35. pixie/tui_catalog.py +657 -0
  36. pixie/web/__init__.py +6 -0
  37. pixie/web/_auth.py +70 -0
  38. pixie/web/_settings_store.py +211 -0
  39. pixie/web/_static/.gitkeep +0 -0
  40. pixie/web/_static/bootstrap-icons.min.css +5 -0
  41. pixie/web/_static/bootstrap.min.css +12 -0
  42. pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
  43. pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
  44. pixie/web/_static/htmx.min.js +1 -0
  45. pixie/web/_static/pixie-favicon.png +0 -0
  46. pixie/web/_static/sse.js +290 -0
  47. pixie/web/_table_state.py +261 -0
  48. pixie/web/_templates/_partials/page_description.html +22 -0
  49. pixie/web/_templates/_partials/recent_events.html +47 -0
  50. pixie/web/_templates/_partials/table_helpers.html +189 -0
  51. pixie/web/_templates/catalog.html +364 -0
  52. pixie/web/_templates/catalog_detail.html +386 -0
  53. pixie/web/_templates/dashboard.html +256 -0
  54. pixie/web/_templates/events.html +125 -0
  55. pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
  56. pixie/web/_templates/ipxe/exit.j2 +10 -0
  57. pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
  58. pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
  59. pixie/web/_templates/ipxe/unavailable.j2 +14 -0
  60. pixie/web/_templates/layout.html +345 -0
  61. pixie/web/_templates/login.html +40 -0
  62. pixie/web/_templates/machine_detail.html +786 -0
  63. pixie/web/_templates/machines.html +186 -0
  64. pixie/web/_templates/settings.html +265 -0
  65. pixie/web/main.py +1910 -0
  66. pixie_lab-0.1.0.dist-info/METADATA +107 -0
  67. pixie_lab-0.1.0.dist-info/RECORD +70 -0
  68. pixie_lab-0.1.0.dist-info/WHEEL +4 -0
  69. pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
  70. pixie_lab-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,261 @@
1
+ """URL-state helpers for paginated + filterable HTML tables.
2
+
3
+ Every table page pixie shows (Catalog, Machines, Events) reads a
4
+ freeform search string + a page number off the query string, filters
5
+ the row set, and slices it before rendering. State lives in the URL
6
+ so a page is bookmarkable and survives a plain form re-submit.
7
+
8
+ Three helpers cover the surface:
9
+
10
+ - :func:`parse_pagination` reads ``?page=<N>&per_page=<N>``, clamps
11
+ ``per_page`` to the dropdown values, computes the offset + limit,
12
+ and returns a :class:`PageState` the template uses to render the
13
+ page-number footer + "Showing X-Y of Z" line.
14
+ - :func:`filter_rows` applies a case-insensitive substring match to
15
+ a caller-supplied set of attribute paths. Missing attributes /
16
+ ``None`` values just fall through so a row with a blank field is
17
+ never matched by a non-empty ``q``.
18
+ - :func:`build_query_string` merges + strips empties + emits a
19
+ URL-encoded query for header / pagination links so an active
20
+ ``?q=`` survives the click through to page 2.
21
+
22
+ No FastAPI / Starlette dependency: tests pass a plain dict.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import urllib.parse
28
+ from collections.abc import Iterable, Mapping
29
+ from dataclasses import dataclass
30
+ from typing import Any
31
+
32
+ # Page-size choices surfaced as the per-page dropdown. The first
33
+ # entry is the default. Kept small so the table fits a laptop
34
+ # without a 200-row page forcing manual scrolling.
35
+ PER_PAGE_CHOICES: tuple[int, ...] = (10, 25, 50, 100)
36
+ DEFAULT_PER_PAGE = PER_PAGE_CHOICES[1] # 25
37
+
38
+ # Number of page buttons shown around the current page in the
39
+ # footer. ``window=2`` gives ``... 3 4 [5] 6 7 ...`` (the standard
40
+ # Bootstrap pagination shape).
41
+ _NUMBERED_WINDOW = 2
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SortState:
46
+ """Parsed ``?sort=<col>&dir=asc|desc``.
47
+
48
+ ``column`` is one of the caller-supplied allowlist keys (the
49
+ URL-visible name, not necessarily the SQL identifier).
50
+ ``direction`` is ``"asc"`` or ``"desc"``. ``key_expr`` is
51
+ whatever the allowlist mapped the column to -- typically an
52
+ attribute name for in-memory sort, or a SQL fragment for the
53
+ events-table SQL path. Kept schema-agnostic so pixie's
54
+ in-memory sort and a later SQL-side sort share the parser.
55
+ """
56
+
57
+ column: str
58
+ direction: str # "asc" | "desc"
59
+ key_expr: str
60
+
61
+ def is_active(self, column: str) -> bool:
62
+ return self.column == column
63
+
64
+ def next_direction(self, column: str) -> str:
65
+ """For a header link on ``column``: the direction the click
66
+ should send. Clicking the currently-active column flips
67
+ direction; clicking any other column starts at ``asc``.
68
+ """
69
+ return "desc" if self.is_active(column) and self.direction == "asc" else "asc"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class PageState:
74
+ """Parsed ``?page=<N>&per_page=<N>`` + computed totals."""
75
+
76
+ page: int # 1-indexed; clamped to [1, last_page]
77
+ per_page: int # one of PER_PAGE_CHOICES
78
+ total: int # rows matching the (post-filter) input
79
+ offset: int # index of the first row on this page
80
+ limit: int # equals per_page
81
+
82
+ @property
83
+ def last_page(self) -> int:
84
+ # An empty table still has page 1 so ``page X of 1`` reads
85
+ # sensibly rather than ``X of 0``.
86
+ if self.total <= 0:
87
+ return 1
88
+ return (self.total + self.per_page - 1) // self.per_page
89
+
90
+ @property
91
+ def first_row(self) -> int:
92
+ """1-indexed row number of the first row on this page (0 if
93
+ the table is empty)."""
94
+ if self.total <= 0:
95
+ return 0
96
+ return self.offset + 1
97
+
98
+ @property
99
+ def last_row(self) -> int:
100
+ """1-indexed row number of the last row on this page."""
101
+ if self.total <= 0:
102
+ return 0
103
+ return min(self.offset + self.per_page, self.total)
104
+
105
+ @property
106
+ def has_prev(self) -> bool:
107
+ return self.page > 1
108
+
109
+ @property
110
+ def has_next(self) -> bool:
111
+ return self.page < self.last_page
112
+
113
+ def numbered_pages(self) -> list[int]:
114
+ """The page-number buttons to render in the footer.
115
+
116
+ Up to ``2 * _NUMBERED_WINDOW + 1`` centred on the current
117
+ page and clamped to ``[1, last_page]``. The template adds
118
+ explicit Prev / Next / First / Last outside this window so
119
+ very large tables don't grow a 50-button footer.
120
+ """
121
+ lo = max(1, self.page - _NUMBERED_WINDOW)
122
+ hi = min(self.last_page, self.page + _NUMBERED_WINDOW)
123
+ return list(range(lo, hi + 1))
124
+
125
+
126
+ def parse_pagination(
127
+ params: Mapping[str, str],
128
+ *,
129
+ total: int,
130
+ default_per_page: int = DEFAULT_PER_PAGE,
131
+ ) -> PageState:
132
+ """Parse ``?page=<N>&per_page=<N>``, clamp to sane values, return
133
+ a :class:`PageState`. ``total`` is the post-filter row count."""
134
+ raw_per = params.get("per_page") or ""
135
+ try:
136
+ per_candidate = int(raw_per)
137
+ except ValueError:
138
+ per_candidate = default_per_page
139
+ per_page = per_candidate if per_candidate in PER_PAGE_CHOICES else default_per_page
140
+
141
+ if total < 0:
142
+ total = 0
143
+ last_page = max(1, (total + per_page - 1) // per_page) if total > 0 else 1
144
+
145
+ raw_page = params.get("page") or ""
146
+ try:
147
+ page_candidate = int(raw_page)
148
+ except ValueError:
149
+ page_candidate = 1
150
+ page = max(1, min(last_page, page_candidate))
151
+
152
+ offset = (page - 1) * per_page
153
+ return PageState(page=page, per_page=per_page, total=total, offset=offset, limit=per_page)
154
+
155
+
156
+ def parse_sort(
157
+ params: Mapping[str, str],
158
+ *,
159
+ allowed: Mapping[str, str],
160
+ default_column: str,
161
+ default_direction: str = "asc",
162
+ ) -> SortState:
163
+ """Parse ``?sort=...&dir=...`` against a per-page column allowlist.
164
+
165
+ ``allowed`` maps the column-key the operator sees in the URL to
166
+ whatever the caller wants to sort on -- an attribute path for
167
+ in-memory sort, or a SQL fragment for SQL-side sort. Anything
168
+ not in ``allowed`` falls back to ``default_column``. The
169
+ allowlist is the safety guard: no operator-controlled string
170
+ ever flows into an eval / SQL context.
171
+ """
172
+ if default_column not in allowed:
173
+ raise ValueError(
174
+ f"default_column {default_column!r} must be in the allowlist {sorted(allowed)!r}"
175
+ )
176
+ raw_col = params.get("sort") or ""
177
+ column = raw_col if raw_col in allowed else default_column
178
+ raw_dir = (params.get("dir") or "").lower()
179
+ direction = raw_dir if raw_dir in ("asc", "desc") else default_direction
180
+ return SortState(column=column, direction=direction, key_expr=allowed[column])
181
+
182
+
183
+ def sort_rows(rows: Iterable[Any], sort: SortState) -> list[Any]:
184
+ """In-memory sort helper: pull the value at ``sort.key_expr`` (a
185
+ dotted attribute path via :func:`_resolve`) off each row and
186
+ order by it. Missing / None values sort as an empty string so a
187
+ row with a blank field lands consistently rather than raising."""
188
+
189
+ def _key(row: Any) -> tuple[int, str]:
190
+ val = _resolve(row, sort.key_expr)
191
+ if val is None:
192
+ return (1, "") # missing values sort after present ones on asc
193
+ return (0, str(val).lower())
194
+
195
+ return sorted(rows, key=_key, reverse=(sort.direction == "desc"))
196
+
197
+
198
+ def _resolve(row: Any, path: str) -> Any:
199
+ """Best-effort attribute / mapping-key lookup along a dotted
200
+ path. Returns ``None`` on any missing hop so callers can treat
201
+ ``None`` uniformly.
202
+ """
203
+ cur: Any = row
204
+ for part in path.split("."):
205
+ if cur is None:
206
+ return None
207
+ cur = cur.get(part) if isinstance(cur, Mapping) else getattr(cur, part, None)
208
+ return cur
209
+
210
+
211
+ def filter_rows(rows: Iterable[Any], q: str, *, fields: Iterable[str]) -> list[Any]:
212
+ """Case-insensitive substring search across ``fields`` on each
213
+ row. An empty ``q`` returns the input unchanged. ``fields`` is a
214
+ tuple of dotted paths that :func:`_resolve` walks -- attributes
215
+ on dataclasses, keys on dicts, mix-and-match.
216
+
217
+ A row matches when the query appears (as a substring, lowercase)
218
+ in ANY of the resolved field values. Numeric / non-string values
219
+ are coerced with ``str()`` so a search on ``12345`` hits a
220
+ ``size_bytes`` int.
221
+ """
222
+ q_clean = (q or "").strip().lower()
223
+ if not q_clean:
224
+ return list(rows)
225
+ field_list = tuple(fields)
226
+ kept: list[Any] = []
227
+ for row in rows:
228
+ for path in field_list:
229
+ val = _resolve(row, path)
230
+ if val is None:
231
+ continue
232
+ if q_clean in str(val).lower():
233
+ kept.append(row)
234
+ break
235
+ return kept
236
+
237
+
238
+ def build_query_string(
239
+ base: Mapping[str, str | None],
240
+ overrides: Mapping[str, str | None] | None = None,
241
+ ) -> str:
242
+ """Merge ``base`` + ``overrides``, drop empty / None values, and
243
+ return a URL-encoded query string suitable for pagination links.
244
+ Caller wraps with ``"?"`` when the returned string is non-empty.
245
+
246
+ ``None`` in ``overrides`` REMOVES the key (handy for "clear this
247
+ filter" links). Empty-string values are dropped so links don't
248
+ accumulate ``?q=&page=`` noise. Stable key order so two callers
249
+ producing the same URL emit byte-identical strings.
250
+ """
251
+ merged: dict[str, str] = {}
252
+ for k, v in base.items():
253
+ if v:
254
+ merged[k] = str(v)
255
+ if overrides:
256
+ for k, v in overrides.items():
257
+ if v is None or v == "":
258
+ merged.pop(k, None)
259
+ else:
260
+ merged[k] = str(v)
261
+ return urllib.parse.urlencode(sorted(merged.items()))
@@ -0,0 +1,22 @@
1
+ {# Per-page description strip.
2
+
3
+ Every operator page opens with a one-paragraph orientation of
4
+ "what you are looking at, how it changes, what you can do." The
5
+ goal is that a new operator hitting pixie for the first time can
6
+ read a page's description and know its purpose without having to
7
+ click around, and that a returning operator can skip it entirely.
8
+
9
+ Called with:
10
+ icon Bootstrap Icons name (no ``bi-`` prefix)
11
+ text One or two-sentence orientation paragraph
12
+
13
+ Ported from bty-web's ``page_description`` block. The visual shape
14
+ (left accent border + light-tinted background) is chosen so the
15
+ strip reads as a distinct "meta" element, not just another card. #}
16
+
17
+ {% macro page_description(icon, text) %}
18
+ <div class="page-description mb-3" role="doc-subtitle">
19
+ <i class="bi bi-{{ icon }} text-primary me-2" aria-hidden="true"></i>
20
+ <span class="text-body-secondary">{{ text }}</span>
21
+ </div>
22
+ {% endmacro %}
@@ -0,0 +1,47 @@
1
+ {# Small events card used on every list page (catalog / machines) and
2
+ on machine-detail. Renders up to N events (the caller picks how
3
+ many) with a link to /ui/events for the full log. Called with:
4
+
5
+ events (list of Event rows)
6
+ scope ("catalog" | "machine" | ...) -- shown in the
7
+ header + "all events" link's ?filter
8
+ empty_message optional custom prompt when events is empty
9
+ #}
10
+ {% macro recent_events_card(events, scope, empty_message="") %}
11
+ <div class="card shadow-sm mb-4">
12
+ <div class="card-header bg-light d-flex align-items-center">
13
+ <h2 class="h6 mb-0">
14
+ <i class="bi bi-clock-history me-2"></i>Recent {{ scope }} events
15
+ </h2>
16
+ <a href="/ui/events" class="ms-auto small">All events</a>
17
+ </div>
18
+ {% if events %}
19
+ <div class="table-responsive">
20
+ <table class="table table-sm table-hover align-middle mb-0">
21
+ <thead>
22
+ <tr>
23
+ <th scope="col" class="small text-muted" style="width: 12rem;">When</th>
24
+ <th scope="col" class="small text-muted" style="width: 16rem;">Kind</th>
25
+ <th scope="col" class="small text-muted">Summary</th>
26
+ </tr>
27
+ </thead>
28
+ <tbody>
29
+ {% for e in events %}
30
+ <tr>
31
+ <td class="small text-muted text-nowrap">{{ e.ts | fmt_ts }}</td>
32
+ <td class="small"><code>{{ e.kind }}</code></td>
33
+ <td class="small">{{ e.summary or "" }}</td>
34
+ </tr>
35
+ {% endfor %}
36
+ </tbody>
37
+ </table>
38
+ </div>
39
+ {% else %}
40
+ <div class="card-body">
41
+ <p class="text-muted small mb-0">
42
+ {{ empty_message or "No " ~ scope ~ " events recorded yet." }}
43
+ </p>
44
+ </div>
45
+ {% endif %}
46
+ </div>
47
+ {% endmacro %}
@@ -0,0 +1,189 @@
1
+ {# Table helpers -- ported from bty's ``_table_macros.html`` shape so
2
+ an operator moving between the two consoles reads the same
3
+ toolbar layout.
4
+
5
+ Callers pass a :class:`SortState`, a :class:`PageState`, and a
6
+ ``preserved`` dict of query params to carry across every link
7
+ (filter survives sort clicks; sort survives pagination clicks;
8
+ per_page survives everything). The macros are shape-agnostic:
9
+ they don't care whether the underlying rows come from an
10
+ in-memory list or a SQL LIMIT/OFFSET query.
11
+
12
+ Layout intent:
13
+
14
+ * The filter form + the pagination live TOGETHER on the card
15
+ header row -- NOT in a separate subnav or a card footer. That
16
+ way the operator's eye lands on "who am I looking at" (the
17
+ title label), "what am I looking for" (the filter), and "how
18
+ many are there" (the pagination) in one horizontal sweep.
19
+ * Column headers are clickable ``<a>``s inside ``<th>``: click
20
+ to sort, click again to reverse. Active column shows a solid
21
+ up/down triangle; inactive columns show a faded up-down glyph
22
+ to hint they are sortable.
23
+
24
+ Macros:
25
+
26
+ * ``search_input(name_or_url, q, preserved, placeholder)`` --
27
+ inline search-and-clear form; sits on the LEFT of the card
28
+ header row.
29
+ * ``sort_header(label, column, sort, preserved, th_attrs)`` --
30
+ a ``<th>`` cell whose contents click to sort.
31
+ * ``pagination_inline(page, preserved, label)`` -- the totals
32
+ line + numbered nav + per-page selector; sits on the RIGHT
33
+ of the card header row via ``ms-auto``. #}
34
+
35
+
36
+ {% macro search_input(action, q, preserved, placeholder='freeform filter') -%}
37
+ {# Inline search + Search + Clear. Uses ``type="search"`` so
38
+ browsers render the built-in clear-X inside the box; the ``m-0``
39
+ + ``flex-grow-1`` shape survives a narrow viewport by wrapping
40
+ below the pagination.
41
+
42
+ ``preserved`` is a dict of {name: value} query params to preserve
43
+ as hidden inputs. ``page`` is intentionally NOT preserved -- a
44
+ fresh query resets to page 1. #}
45
+ <form method="get" action="{{ action }}"
46
+ class="d-flex align-items-center gap-2 m-0 flex-grow-1"
47
+ style="min-width: 16rem;">
48
+ {% for k, v in preserved.items() %}
49
+ {% if v and k not in ('q', 'page') %}
50
+ <input type="hidden" name="{{ k }}" value="{{ v }}">
51
+ {% endif %}
52
+ {% endfor %}
53
+ <label for="filter-q" class="text-nowrap text-muted small mb-0">
54
+ <i class="bi bi-funnel"></i> Filter:
55
+ </label>
56
+ <input type="search" name="q" id="filter-q" value="{{ q or '' }}"
57
+ class="form-control form-control-sm flex-grow-1"
58
+ placeholder="{{ placeholder }}"
59
+ aria-label="Filter this table">
60
+ <button type="submit" class="btn btn-sm btn-outline-secondary"
61
+ title="Search" aria-label="Apply filter">
62
+ <i class="bi bi-search" aria-hidden="true"></i>
63
+ </button>
64
+ {% if q %}
65
+ <a href="?{{ _qs(preserved, {'q': None, 'page': None}) }}"
66
+ class="btn btn-sm btn-link text-nowrap"
67
+ title="Clear filter">Clear</a>
68
+ {% endif %}
69
+ </form>
70
+ {%- endmacro %}
71
+
72
+
73
+ {% macro sort_header(label, column, sort, preserved, th_attrs='') -%}
74
+ {# Sortable column header. Clicking flips direction on the active
75
+ column or starts a fresh asc on any other column.
76
+
77
+ Arrow indicator convention (matches bty):
78
+
79
+ * Active + asc -> filled ▲ in accent colour
80
+ * Active + desc -> filled ▼ in accent colour
81
+ * Inactive -> faded ↕ so the header still telegraphs
82
+ "this is sortable" without competing with the active one. #}
83
+ {% set qs = _qs(preserved, {'sort': column, 'dir': sort.next_direction(column), 'page': None}) %}
84
+ <th{% if th_attrs %} {{ th_attrs|safe }}{% endif %}>
85
+ <a href="?{{ qs }}" class="text-decoration-none text-reset"
86
+ aria-label="Sort by {{ label }}">
87
+ {{ label }}
88
+ {% if sort.is_active(column) %}
89
+ <span class="text-primary" aria-hidden="true">{% if sort.direction == 'asc' %}&#9650;{% else %}&#9660;{% endif %}</span>
90
+ {% else %}
91
+ <span class="text-muted" aria-hidden="true">&#8645;</span>
92
+ {% endif %}
93
+ </a>
94
+ </th>
95
+ {%- endmacro %}
96
+
97
+
98
+ {% macro pagination_inline(page, preserved, label='rows') -%}
99
+ {# Compact pagination that fits on one card-header row alongside a
100
+ filter form. Renders the "1-25 of 200 events" line as inline
101
+ muted text, then the Bootstrap ``pagination pagination-sm`` list
102
+ (« « ‹ 1 2 [3] 4 5 › » »), then the per-page selector.
103
+
104
+ The pagination list follows bty's shape: first/prev/window/next/
105
+ last with ``…`` ellipses when the window doesn't touch the ends.
106
+ Numbered ``.page-link`` inside ``<li class="page-item">`` is the
107
+ Bootstrap-native shape (not a btn-group). #}
108
+ <div class="d-flex flex-wrap align-items-center gap-2 ms-auto">
109
+ <small class="text-muted">
110
+ {% if page.total == 0 %}
111
+ No {{ label }}.
112
+ {% else %}
113
+ <strong>{{ page.first_row }}</strong>&ndash;<strong>{{ page.last_row }}</strong>
114
+ of <strong>{{ page.total }}</strong> {{ label }}
115
+ {% endif %}
116
+ </small>
117
+
118
+ {% if page.last_page > 1 %}
119
+ <ul class="pagination pagination-sm mb-0">
120
+ <li class="page-item{% if not page.has_prev %} disabled{% endif %}">
121
+ <a class="page-link"
122
+ href="?{{ _qs(preserved, {'page': 1}) }}"
123
+ aria-label="First page">&laquo;&laquo;</a>
124
+ </li>
125
+ <li class="page-item{% if not page.has_prev %} disabled{% endif %}">
126
+ <a class="page-link"
127
+ href="?{{ _qs(preserved, {'page': page.page - 1}) }}"
128
+ aria-label="Previous page">&laquo;</a>
129
+ </li>
130
+ {% set nums = page.numbered_pages() %}
131
+ {% if nums[0] > 1 %}
132
+ <li class="page-item">
133
+ <a class="page-link" href="?{{ _qs(preserved, {'page': 1}) }}">1</a>
134
+ </li>
135
+ {% if nums[0] > 2 %}
136
+ <li class="page-item disabled"><span class="page-link">&hellip;</span></li>
137
+ {% endif %}
138
+ {% endif %}
139
+ {% for n in nums %}
140
+ <li class="page-item{% if n == page.page %} active{% endif %}">
141
+ <a class="page-link"
142
+ href="?{{ _qs(preserved, {'page': n}) }}"
143
+ {% if n == page.page %}aria-current="page"{% endif %}>{{ n }}</a>
144
+ </li>
145
+ {% endfor %}
146
+ {% if nums[-1] < page.last_page %}
147
+ {% if nums[-1] < page.last_page - 1 %}
148
+ <li class="page-item disabled"><span class="page-link">&hellip;</span></li>
149
+ {% endif %}
150
+ <li class="page-item">
151
+ <a class="page-link"
152
+ href="?{{ _qs(preserved, {'page': page.last_page}) }}">{{ page.last_page }}</a>
153
+ </li>
154
+ {% endif %}
155
+ <li class="page-item{% if not page.has_next %} disabled{% endif %}">
156
+ <a class="page-link"
157
+ href="?{{ _qs(preserved, {'page': page.page + 1}) }}"
158
+ aria-label="Next page">&raquo;</a>
159
+ </li>
160
+ <li class="page-item{% if not page.has_next %} disabled{% endif %}">
161
+ <a class="page-link"
162
+ href="?{{ _qs(preserved, {'page': page.last_page}) }}"
163
+ aria-label="Last page">&raquo;&raquo;</a>
164
+ </li>
165
+ </ul>
166
+ {% endif %}
167
+
168
+ {# Per-page selector as a GET-form: submits with all preserved
169
+ params as hidden inputs but drops ``page`` so the choice
170
+ always lands on page 1. #}
171
+ <form method="get" class="d-flex align-items-center gap-1 mb-0">
172
+ {% for k, v in preserved.items() %}
173
+ {% if v and k not in ('per_page', 'page') %}
174
+ <input type="hidden" name="{{ k }}" value="{{ v }}">
175
+ {% endif %}
176
+ {% endfor %}
177
+ <select name="per_page"
178
+ class="form-select form-select-sm"
179
+ style="width: auto;"
180
+ onchange="this.form.submit()"
181
+ aria-label="Rows per page">
182
+ {% for choice in (10, 25, 50, 100) %}
183
+ <option value="{{ choice }}"
184
+ {% if choice == page.per_page %}selected{% endif %}>{{ choice }}</option>
185
+ {% endfor %}
186
+ </select>
187
+ </form>
188
+ </div>
189
+ {%- endmacro %}