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