repos
/ status-rust master

status-rust

mirror archived upstream

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

7.8 KB · 215 lines · Typst Raw History
  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 %}