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{% extends "base.html" %}
2
3{% block extra_css %}
4<link rel="stylesheet" href="{{ vite_asset('static_src/properties/index.js', 'css') }}">
5{% endblock %}
6
7{% block extra_js %}
8<script id="chart-status-response-times-data" type="application/json">{{ status_response_times_graph | tojson }}</script>
9<script id="chart-status-codes-data" type="application/json">{{ status_codes_graph | tojson }}</script>
10<script id="chart-uptime-data" type="application/json">{{ uptime_graph | tojson }}</script>
11<script id="property-data" type="application/json">{{ property | tojson }}</script>
12<script type="module" src="{{ vite_asset('static_src/properties/index.js') }}"></script>
13{% endblock %}
14
15{% block breadcrumbs %}
16<nav aria-label="breadcrumb">
17 <ol class="breadcrumb mb-0">
18 <li class="breadcrumb-item"><a href="/">Home</a></li>
19 {% if user.is_authenticated %}<li class="breadcrumb-item"><a href="/properties">Properties</a></li>{% endif %}
20 <li class="breadcrumb-item active" aria-current="page">{{ property.name }}</li>
21 </ol>
22</nav>
23{% endblock %}
24
25{% block main %}
26<div class="bg-deep border-bottom border-subtle py-4">
27 <div class="container">
28 <div class="row align-items-center g-3">
29 <div class="col-12 col-lg-7">
30 <div class="section-label mb-2">property · <span class="{% if property.current_status == 200 %}text-green{% else %}text-terracotta{% endif %}">{% if property.current_status == 200 %}online{% else %}offline{% endif %}</span></div>
31 <h1 class="mb-2 text-white fw-bolder d-flex align-items-center gap-3" style="letter-spacing: -0.01em; min-width: 0;">
32 <span class="text-truncate">{{ property.name }}</span>
33 <span class="status-dot status-dot-lg {% if property.current_status == 200 %}is-up{% else %}is-down{% endif %}" aria-hidden="true"></span>
34 </h1>
35 <a href="{{ property.url }}" target="_blank" rel="noopener" class="property-url-link">
36 <span class="property-url-text">{{ property.url }}</span>
37 <span class="property-url-arrow" aria-hidden="true">↗</span>
38 </a>
39 </div>
40 <div class="col-12 col-lg-5 d-flex flex-wrap gap-2 justify-content-lg-end align-items-center d-print-none">
41 {% if user.is_authenticated %}
42 <span id="is-public-form" class="d-inline-flex align-items-center gap-2" data-property-id="{{ property.id }}">
43 <span class="toggle-label" title="Public properties are viewable by anyone with the URL; no login required.">Visibility</span>
44 <label class="toggle-pill">
45 <input type="checkbox" name="is_public" id="is-public-switch"{% if property.is_public %} checked{% endif %}>
46 <span class="toggle-pill-seg toggle-pill-private">Private</span>
47 <span class="toggle-pill-seg toggle-pill-public">Public</span>
48 </label>
49 </span>
50 <a href="/{{ property.id }}?report" target="_blank" class="btn btn-sm btn-outline-light">Report · pdf</a>
51 <a href="/{{ property.id }}?report=md" target="_blank" class="btn btn-sm btn-outline-light">Report · md</a>
52 {% if not property.is_protected %}
53 <form method="POST" action="/properties/{{ property.id }}/delete" class="d-inline" onsubmit="return confirm('Delete {{ property.url }}?');">
54 <button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
55 </form>
56 {% endif %}
57 {% endif %}
58 </div>
59 </div>
60 </div>
61</div>
62
63<div class="container my-4">
64 <div class="section-label mb-2">live signals</div>
65 <div class="row g-3 mb-4">
66 <div class="col-6 col-md-3">
67 <div class="metric-tile {% if property.current_status == 200 %}metric-accent-green{% else %}metric-accent-danger{% endif %}">
68 <div class="metric-label">http status</div>
69 <div class="metric-value">{{ property.current_status }}</div>
70 <div class="metric-sub">{% if property.current_status == 200 %}OK · all clear{% else %}failing{% endif %}</div>
71 </div>
72 </div>
73 <div class="col-6 col-md-3">
74 <div class="metric-tile {% if property.invalid_cert %}metric-accent-danger{% else %}metric-accent-green{% endif %}">
75 <div class="metric-label">tls cert</div>
76 <div class="metric-value" style="font-size: 1.4rem;">{% if property.invalid_cert %}invalid{% else %}valid{% endif %}</div>
77 <div class="metric-sub">chain + trust</div>
78 </div>
79 </div>
80 <div class="col-6 col-md-3">
81 <div class="metric-tile {% if property.has_security_issue %}metric-accent-amber{% else %}metric-accent-green{% endif %}">
82 <div class="metric-label">security</div>
83 <div class="metric-value" style="font-size: 1.4rem;">{% if property.has_security_issue %}issues{% else %}pass{% endif %}</div>
84 <div class="metric-sub">header scan</div>
85 </div>
86 </div>
87 <div class="col-6 col-md-3">
88 <div class="metric-tile {% if property.avg_response_time > 500 %}metric-accent-danger{% else %}metric-accent-green{% endif %}">
89 <div class="metric-label">response</div>
90 <div class="metric-value">{{ property.avg_response_time }}<span class="text-muted" style="font-size: 0.9rem;"> ms</span></div>
91 <div class="metric-sub">rolling avg</div>
92 </div>
93 </div>
94 </div>
95
96 {% if property.lighthouse_scores %}
97 <div class="section-label mb-2">lighthouse</div>
98 <div class="row g-3 mb-4">
99 {% for category, score in property.lighthouse_scores | items %}
100 <div class="col-6 col-md-3">
101 <div class="metric-tile {% if score >= 90 %}metric-accent-green{% elif score >= 50 %}metric-accent-amber{% else %}metric-accent-danger{% endif %}">
102 <div class="metric-label">{{ category }}</div>
103 <div class="metric-value">{{ score }}<span class="text-muted" style="font-size: 0.9rem;">%</span></div>
104 </div>
105 </div>
106 {% endfor %}
107 </div>
108 {% endif %}
109</div>
110
111<div class="container my-4">
112 <div class="row g-3">
113 <div class="col-12 col-md-8">
114 <div class="chart-panel mb-3">
115 <div class="chart-panel-header">
116 <span class="chart-panel-title">response time · last 31 checks</span>
117 </div>
118 <div style="position: relative; height: 300px;">
119 <canvas id="chart-response-times"></canvas>
120 </div>
121 </div>
122 <div class="row g-3">
123 <div class="col-12 col-md-6">
124 <div class="chart-panel">
125 <div class="chart-panel-header">
126 <span class="chart-panel-title">status codes</span>
127 </div>
128 <canvas id="chart-status-codes"></canvas>
129 </div>
130 </div>
131 <div class="col-12 col-md-6">
132 <div class="chart-panel">
133 <div class="chart-panel-header">
134 <span class="chart-panel-title">uptime split</span>
135 </div>
136 <canvas id="chart-uptime"></canvas>
137 </div>
138 </div>
139 </div>
140 </div>
141 <div class="col-12 col-md-4">
142 <div class="check-list">
143 <div class="check-list-header">security · header scan</div>
144 <div class="check-list-item">
145 <span class="check-name">Use HTTPS</span>
146 <span class="chip {% if property.is_https %}chip-ok{% else %}chip-down{% endif %}">{% if property.is_https %}pass{% else %}fail{% endif %}</span>
147 </div>
148 <div class="check-list-item">
149 <span class="check-name">Set MIME types</span>
150 <span class="chip {% if property.has_mime_type %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_mime_type %}pass{% else %}fail{% endif %}</span>
151 </div>
152 <div class="check-list-item">
153 <span class="check-name">Content sniff protection</span>
154 <span class="chip {% if property.has_content_sniffing_protection %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_content_sniffing_protection %}pass{% else %}fail{% endif %}</span>
155 </div>
156 <div class="check-list-item">
157 <span class="check-name">Clickjack protection</span>
158 <span class="chip {% if property.has_clickjack_protection %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_clickjack_protection %}pass{% else %}fail{% endif %}</span>
159 </div>
160 <div class="check-list-item">
161 <span class="check-name">Hide server version</span>
162 <span class="chip {% if property.hides_server_version %}chip-ok{% else %}chip-down{% endif %}">{% if property.hides_server_version %}pass{% else %}fail{% endif %}</span>
163 </div>
164 <div class="check-list-item">
165 <span class="check-name">HSTS enabled</span>
166 <span class="chip {% if property.has_hsts %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_hsts %}pass{% else %}fail{% endif %}</span>
167 </div>
168 <div class="check-list-item">
169 <span class="check-name">HSTS preload</span>
170 <span class="chip {% if property.has_hsts_preload %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_hsts_preload %}pass{% else %}fail{% endif %}</span>
171 </div>
172 </div>
173 </div>
174 </div>
175</div>
176
177{% if user.is_authenticated %}
178<div class="container my-4 d-print-none" id="monitoring-status" data-property-id="{{ property.id }}">
179 <div class="section-label mb-2">scheduled audits</div>
180 <div class="row g-3">
181 <div class="col-12 col-md-6">
182 <div class="monitor-card h-100">
183 <div class="monitor-header">
184 <span class="monitor-title">Crawler</span>
185 <span class="d-flex align-items-center gap-2">
186 <span class="chip chip-muted" data-field="crawler.state_badge"> </span>
187 <button type="button" id="recrawl-btn" class="btn btn-sm btn-outline-light monitor-btn" title="Recrawl now">
188 <span class="recrawl-btn-label">Recrawl</span>
189 <span class="recrawl-btn-spinner spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
190 </button>
191 </span>
192 </div>
193 <div class="monitor-body">
194 <div class="progress mb-3 d-none" data-field="crawler.progress_wrap" style="height: 4px;">
195 <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" data-field="crawler.progress_bar" style="width: 0%"></div>
196 </div>
197 <div class="alert alert-danger py-2 mb-3 d-none small" data-field="crawler.error_box" role="alert">
198 <strong>Last crawl failed:</strong>
199 <code data-field="crawler.error_text"></code>
200 </div>
201 <dl>
202 <dt>Last success</dt>
203 <dd data-field="crawler.last_success">—</dd>
204 <dt>Last attempt</dt>
205 <dd data-field="crawler.last_attempt">—</dd>
206 <dt>Pages crawled</dt>
207 <dd data-field="crawler.pages">—</dd>
208 <dt>Duration</dt>
209 <dd data-field="crawler.duration">—</dd>
210 <dt>Issues found</dt>
211 <dd data-field="crawler.insights">—</dd>
212 <dt>Next run</dt>
213 <dd data-field="crawler.next_run">—</dd>
214 </dl>
215 </div>
216 </div>
217 </div>
218 <div class="col-12 col-md-6">
219 <div class="monitor-card h-100">
220 <div class="monitor-header">
221 <span class="monitor-title">Lighthouse</span>
222 <span class="d-flex align-items-center gap-2">
223 <span class="chip chip-muted" data-field="lighthouse.state_badge"> </span>
224 <button type="button" id="rerun-lighthouse-btn" class="btn btn-sm btn-outline-light monitor-btn" title="Rerun Lighthouse now">
225 <span class="rerun-lh-label">Rerun</span>
226 <span class="rerun-lh-spinner spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
227 </button>
228 </span>
229 </div>
230 <div class="monitor-body">
231 <div class="alert alert-warning py-2 mb-3 d-none small" data-field="lighthouse.error_box" role="alert">
232 <strong>Last Lighthouse run failed:</strong>
233 <code data-field="lighthouse.error_text"></code>
234 </div>
235 <dl>
236 <dt>Last success</dt>
237 <dd data-field="lighthouse.last_success">—</dd>
238 <dt>Last attempt</dt>
239 <dd data-field="lighthouse.last_attempt">—</dd>
240 <dt>Duration</dt>
241 <dd data-field="lighthouse.duration">—</dd>
242 <dt>Next run</dt>
243 <dd data-field="lighthouse.next_run">—</dd>
244 </dl>
245 </div>
246 </div>
247 </div>
248 </div>
249</div>
250{% endif %}
251
252{% if property.lighthouse_details %}
253<div class="container my-4">
254 <div class="section-label mb-2">performance breakdown</div>
255 <p class="text-muted small mb-3">
256 Lighthouse combines these weighted metrics to produce the Performance score. Opportunities below are the biggest wins; savings are estimated against the audited URL only.
257 </p>
258
259 {% if property.lighthouse_details.metrics %}
260 <div class="row g-3 mb-4">
261 {% for metric in property.lighthouse_details.metrics %}
262 <div class="col-6 col-md d-flex">
263 <div class="metric-tile w-100 {% if metric.score >= 0.9 %}metric-accent-green{% elif metric.score >= 0.5 %}metric-accent-amber{% else %}metric-accent-danger{% endif %}">
264 <div class="metric-label">{{ metric.acronym }} · {{ metric.weight }}%</div>
265 <div class="metric-value" style="font-size: 1.2rem;">{{ metric.display_value or "—" }}</div>
266 <div class="metric-sub" title="{{ metric.title }}">{{ metric.title }}</div>
267 </div>
268 </div>
269 {% endfor %}
270 </div>
271 {% endif %}
272
273 {% if property.lighthouse_details.opportunities %}
274 <div class="section-label mb-2">top opportunities</div>
275 <div class="insight-header insight--3col">
276 <span>Audit</span>
277 <span>Detail</span>
278 <span class="text-end">Est. savings</span>
279 </div>
280 {% for opp in property.lighthouse_details.opportunities %}
281 <div class="insight-row insight--3col">
282 <span class="insight-col-trunc" title="{{ opp.title }}">{{ opp.title }}</span>
283 <span class="insight-col-trunc text-muted small" title="{{ opp.display_value or '' }}">
284 {{ opp.display_value or "—" }}
285 </span>
286 <span class="text-end small">
287 {% set saved = opp.savings_ms | format_ms_savings %}
288 {% if saved %}<strong class="text-amber">{{ saved }}</strong>{% else %}<span class="text-muted">—</span>{% endif %}
289 </span>
290 </div>
291 {% endfor %}
292 {% else %}
293 <div class="alert alert-info py-2 small">No actionable opportunities; everything passing at this URL.</div>
294 {% endif %}
295</div>
296{% endif %}
297
298{% if insights_groups and insights_groups | length > 0 %}
299<div class="container my-4">
300 {% for group in insights_groups %}
301 <div class="section-label mb-2">{{ group.type }} · <span class="text-muted">{{ group.items | length }}</span></div>
302 <div class="insight-header">
303 <span>Severity</span>
304 <span>URL</span>
305 <span>Issue</span>
306 <span>Item</span>
307 </div>
308 {% for insight in group.items %}
309 <div class="insight-row">
310 <span>
311 <span class="chip {% if insight.severity == 'error' %}chip-down{% elif insight.severity == 'warning' %}chip-warn{% else %}chip-info{% endif %}">
312 {{ insight.severity | upper }}
313 </span>
314 </span>
315 <span class="insight-col-trunc">
316 <a href="{{ insight.url }}" target="_blank" rel="noopener" class="text-muted">{{ insight.url | url_path }}</a>
317 </span>
318 <span class="insight-col-trunc" title="{{ insight.issue }}">{{ insight.issue }}</span>
319 <span class="insight-col-trunc text-muted small" title="{{ insight.item or '' }}">{{ insight.item or "" }}</span>
320 </div>
321 {% endfor %}
322 {% endfor %}
323</div>
324{% elif property.last_crawl_error %}
325<div class="container my-4">
326 <div class="alert alert-danger py-2 small mb-0">Last crawl failed: <code>{{ property.last_crawl_error }}</code></div>
327</div>
328{% endif %}
329{% endblock %}