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.
- pixie/__init__.py +20 -0
- pixie/_util.py +38 -0
- pixie/catalog/__init__.py +25 -0
- pixie/catalog/_fetcher.py +513 -0
- pixie/catalog/_routes.py +392 -0
- pixie/catalog/_schema.py +263 -0
- pixie/catalog/_store.py +234 -0
- pixie/deploy/__init__.py +26 -0
- pixie/deploy/_main.py +321 -0
- pixie/deploy/_templates.py +153 -0
- pixie/disks.py +85 -0
- pixie/events/__init__.py +28 -0
- pixie/events/_kinds.py +203 -0
- pixie/events/_log.py +210 -0
- pixie/events/_routes.py +42 -0
- pixie/exports/__init__.py +17 -0
- pixie/exports/_routes.py +211 -0
- pixie/exports/_store.py +156 -0
- pixie/exports/_supervisor.py +240 -0
- pixie/flash.py +1971 -0
- pixie/images.py +473 -0
- pixie/machines/__init__.py +16 -0
- pixie/machines/_routes.py +190 -0
- pixie/machines/_store.py +623 -0
- pixie/oras.py +547 -0
- pixie/pivot/__init__.py +180 -0
- pixie/pivot/nbdboot +348 -0
- pixie/pxe/__init__.py +19 -0
- pixie/pxe/_renderer.py +244 -0
- pixie/pxe/_routes.py +386 -0
- pixie/tftp/__init__.py +21 -0
- pixie/tftp/_supervisor.py +129 -0
- pixie/tui/__init__.py +185 -0
- pixie/tui/_app.py +2219 -0
- pixie/tui_catalog.py +657 -0
- pixie/web/__init__.py +6 -0
- pixie/web/_auth.py +70 -0
- pixie/web/_settings_store.py +211 -0
- pixie/web/_static/.gitkeep +0 -0
- pixie/web/_static/bootstrap-icons.min.css +5 -0
- pixie/web/_static/bootstrap.min.css +12 -0
- pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
- pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
- pixie/web/_static/htmx.min.js +1 -0
- pixie/web/_static/pixie-favicon.png +0 -0
- pixie/web/_static/sse.js +290 -0
- pixie/web/_table_state.py +261 -0
- pixie/web/_templates/_partials/page_description.html +22 -0
- pixie/web/_templates/_partials/recent_events.html +47 -0
- pixie/web/_templates/_partials/table_helpers.html +189 -0
- pixie/web/_templates/catalog.html +364 -0
- pixie/web/_templates/catalog_detail.html +386 -0
- pixie/web/_templates/dashboard.html +256 -0
- pixie/web/_templates/events.html +125 -0
- pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
- pixie/web/_templates/ipxe/exit.j2 +10 -0
- pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
- pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
- pixie/web/_templates/ipxe/unavailable.j2 +14 -0
- pixie/web/_templates/layout.html +345 -0
- pixie/web/_templates/login.html +40 -0
- pixie/web/_templates/machine_detail.html +786 -0
- pixie/web/_templates/machines.html +186 -0
- pixie/web/_templates/settings.html +265 -0
- pixie/web/main.py +1910 -0
- pixie_lab-0.1.0.dist-info/METADATA +107 -0
- pixie_lab-0.1.0.dist-info/RECORD +70 -0
- pixie_lab-0.1.0.dist-info/WHEEL +4 -0
- pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
- 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' %}▲{% else %}▼{% endif %}</span>
|
|
90
|
+
{% else %}
|
|
91
|
+
<span class="text-muted" aria-hidden="true">⇅</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>–<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">««</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">«</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">…</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">…</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">»</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">»»</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 %}
|