Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.
axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite
1{# Property report rendered by minijinja into Typst markup, then compiled to
2 PDF by src/pdf.rs::PdfRenderer. Mirrors analytics/templates/properties/property_report.typ
3 in look-and-feel: monochrome, hairline rules, tracking-letterspaced uppercase
4 section headers, mono for technical strings. #}
5
6#let dim = rgb("#555")
7#let muted = rgb("#888")
8#let mono = ("JetBrains Mono", "DejaVu Sans Mono", "Liberation Mono")
9
10#set page(
11 paper: "a4",
12 margin: (top: 14mm, bottom: 18mm, left: 14mm, right: 14mm),
13 footer: context {
14 set text(size: 7.5pt, fill: dim)
15 grid(
16 columns: (1fr, auto),
17 align: (left + horizon, right + horizon),
18 [Status · self-hosted{% if base_url %} · {{ base_url | typst_md }}{% endif %} · {{ property.name | typst_md }}],
19 [Page #counter(page).display() of #counter(page).final().first()],
20 )
21 },
22)
23
24#set text(
25 font: ("DejaVu Sans", "Liberation Sans", "Arial"),
26 size: 9.5pt,
27 fill: black,
28)
29#set par(leading: 0.5em, justify: false)
30
31#show heading.where(level: 1): set text(size: 22pt, weight: "bold")
32#show heading.where(level: 1): set block(above: 8pt, below: 4pt)
33
34#show heading.where(level: 2): it => block(
35 above: 16pt,
36 below: 6pt,
37 width: 100%,
38 stroke: (bottom: 0.6pt + black),
39 inset: (bottom: 3pt),
40)[#text(size: 11pt, weight: "bold", tracking: 0.6pt, upper(it.body))]
41
42#show heading.where(level: 3): it => block(
43 above: 8pt,
44 below: 3pt,
45)[#text(size: 8.5pt, weight: "bold", tracking: 0.5pt, fill: rgb("#333"), upper(it.body))]
46
47// Header strip
48#grid(
49 columns: (1fr, auto),
50 align: (left + top, right + top),
51 text(size: 8.5pt, tracking: 0.9pt, fill: dim, upper("// Status · property report")),
52 text(size: 8pt, fill: dim)[Generated {{ generated_at | typst_md }}],
53)
54
55= {{ property.name | typst_md }}
56
57// Meta dl: 2-col with thin rules above and below.
58#block(
59 above: 8pt,
60 below: 0pt,
61 width: 100%,
62 stroke: (top: 0.6pt + black, bottom: 0.6pt + black),
63 inset: (top: 6pt, bottom: 6pt),
64)[
65 #grid(
66 columns: (1fr, 1fr),
67 column-gutter: 16pt,
68 row-gutter: 4pt,
69 [
70 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Property ID")) \
71 #text(font: mono, size: 8.5pt)[{{ property.id | typst_md }}]
72 ],
73 [
74 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("URL")) \
75 #text(font: mono, size: 8.5pt)[{{ property.url | typst_md }}]
76 ],
77 [
78 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Alert state")) \
79 #text(weight: "semibold")[{{ property.alert_state | typst_md }}]
80 ],
81 [
82 #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Visibility")) \
83 #text(weight: "semibold")[{% if property.is_public %}public{% else %}private{% endif %}]
84 ],
85 )
86]
87
88== Live signals
89
90#grid(
91 columns: (1fr, 1fr, 1fr, 1fr),
92 gutter: 4pt,
93 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
94 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[STATUS]
95 #v(2pt)
96 #text(size: 14pt, weight: "bold")[{{ property.current_status }}]
97 ],
98 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
99 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[AVG RESPONSE]
100 #v(2pt)
101 #text(size: 14pt, weight: "bold")[{{ property.avg_response_time }} ms]
102 ],
103 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
104 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[UPTIME]
105 #v(2pt)
106 #text(size: 14pt, weight: "bold")[{% if property.recent_uptime_pct is none %}—{% else %}{{ property.recent_uptime_pct }}%{% endif %}]
107 ],
108 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
109 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[CHECKS]
110 #v(2pt)
111 #text(size: 14pt, weight: "bold")[{{ property.total_checks }}]
112 ],
113)
114
115== Lighthouse
116
117{% if property.lighthouse_scores %}
118#grid(
119 columns: (1fr, 1fr, 1fr, 1fr),
120 gutter: 4pt,
121 {% for k, v in property.lighthouse_scores | items %}
122 rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
123 #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[{{ k | upper | typst_md }}]
124 #v(2pt)
125 #text(size: 14pt, weight: "bold")[{{ v }}]
126 ],
127 {% endfor %}
128)
129
130{% if property.lighthouse_details and property.lighthouse_details.metrics %}
131=== Performance metrics
132
133#block(breakable: false, table(
134 columns: (auto, 1fr, auto),
135 align: (left + top, left + top, right + top),
136 inset: (x: 3pt, y: 2pt),
137 stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
138 table.header(
139 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Metric")),
140 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Title")),
141 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Value")),
142 ),
143 {% for m in property.lighthouse_details.metrics %}
144 text(font: mono, size: 7pt, weight: "bold")[{{ m.acronym | typst_md }}], text(size: 7.5pt)[{{ m.title | typst_md }}], text(size: 7.5pt)[{{ m.display_value | typst_md }}],
145 {% endfor %}
146))
147{% endif %}
148
149{% if property.lighthouse_details and property.lighthouse_details.opportunities %}
150=== Top opportunities
151
152#block(breakable: false, table(
153 columns: (1fr, auto),
154 align: (left + top, right + top),
155 inset: (x: 3pt, y: 2pt),
156 stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
157 table.header(
158 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Opportunity")),
159 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Savings")),
160 ),
161 {% for o in property.lighthouse_details.opportunities %}
162 text(size: 7.5pt)[{{ o.title | typst_md }}], text(size: 7.5pt)[{{ o.savings_ms | format_ms_savings | typst_md }}],
163 {% endfor %}
164))
165{% endif %}
166{% else %}
167#text(size: 8pt, fill: muted, style: "italic")[No lighthouse data yet.]
168{% endif %}
169
170== Security
171
172#block(breakable: false, table(
173 columns: (1fr, auto),
174 align: (left + top, right + top),
175 inset: (x: 3pt, y: 2pt),
176 stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
177 table.header(
178 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Check")),
179 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Result")),
180 ),
181 text(size: 7.5pt)[HTTPS], text(size: 7.5pt)[{% if property.is_https %}OK{% else %}Issue{% endif %}],
182 text(size: 7.5pt)[TLS certificate valid], text(size: 7.5pt)[{% if property.invalid_cert %}Issue{% else %}OK{% endif %}],
183 text(size: 7.5pt)[HSTS (≥1y max-age)], text(size: 7.5pt)[{% if property.has_hsts %}OK{% else %}Issue{% endif %}],
184 text(size: 7.5pt)[HSTS preload], text(size: 7.5pt)[{% if property.has_hsts_preload %}OK{% else %}Issue{% endif %}],
185 text(size: 7.5pt)[X-Frame-Options], text(size: 7.5pt)[{% if property.has_clickjack_protection %}OK{% else %}Issue{% endif %}],
186 text(size: 7.5pt)[X-Content-Type-Options], text(size: 7.5pt)[{% if property.has_content_sniffing_protection %}OK{% else %}Issue{% endif %}],
187 text(size: 7.5pt)[Server header hidden], text(size: 7.5pt)[{% if property.hides_server_version %}OK{% else %}Issue{% endif %}],
188))
189
190== SEO insights
191
192{% if insights_groups and insights_groups | length > 0 %}
193{% for group in insights_groups %}
194=== {{ group.type | typst_md }}
195
196#block(breakable: false, table(
197 columns: (auto, 1fr, 1fr),
198 align: (left + top, left + top, left + top),
199 inset: (x: 3pt, y: 2pt),
200 stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
201 table.header(
202 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Severity")),
203 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Issue")),
204 text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("URL")),
205 ),
206 {% for i in group.items %}
207 text(size: 7.5pt, weight: "bold")[{{ i.severity | typst_md }}], text(size: 7.5pt)[{{ i.issue | typst_md }}], text(font: mono, size: 7pt, fill: dim)[{{ i.url | typst_md }}],
208 {% endfor %}
209))
210
211{% endfor %}
212{% else %}
213#text(size: 8pt, fill: muted, style: "italic")[No crawl results yet.]
214{% endif %}