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 "breadcrumbs" }}
2<nav aria-label="breadcrumb">
3 <ol class="breadcrumb mb-0">
4 <li class="breadcrumb-item"><a href="/">Home</a></li>
5 <li class="breadcrumb-item active" aria-current="page">{{ .Title }}</li>
6 </ol>
7</nav>
8{{ end }}
9
10{{ define "main" }}
11<div class="container my-4">
12 <div class="row align-items-center g-3 mb-4">
13 <div class="col-md-5">
14 <div class="section-label mb-1">operator</div>
15 <h1 class="fw-bolder text-white mb-0" style="letter-spacing: -0.01em;">{{ .Title }}</h1>
16 <p class="text-muted mb-0 small mt-1">Every URL you've registered, current probe state, and audit signals.</p>
17 </div>
18 <div class="col-md-7">
19 <div class="dashboard-toolbar">
20 <form method="get" class="search-form flex-grow-1">
21 <input type="text" class="form-control search-input" name="q" id="id_search" placeholder="grep properties..." value="{{ .Query }}" />
22 </form>
23 <button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePropertyAdd" aria-expanded="false" aria-controls="collapsePropertyAdd">
24 + new
25 </button>
26 </div>
27 </div>
28 </div>
29
30 <div class="collapse mb-4" id="collapsePropertyAdd">
31 <div class="bg-surface border-subtle rounded border p-3">
32 <div class="section-label mb-2">register property</div>
33 {{/* https:// is not a preference. The checker speaks HTTP/2 over TLS and
34 nothing else, so an http:// property could never pass a check. */}}
35 <p class="text-muted small mb-3">Any public https:// URL. Probe begins within 3 minutes.</p>
36 <form method="POST" action="/properties" class="dashboard-toolbar">
37 <input type="url" name="url" id="id_url" class="form-control flex-grow-1" placeholder="https://example.com" pattern="https://.*" required>
38 <button type="submit" class="btn btn-primary">Add →</button>
39 </form>
40 </div>
41 </div>
42
43 <div class="section-label mb-2">registered properties</div>
44 {{ range .Properties }}
45 <div class="property-row {{ if ne .CurrentStatus 200 }}is-down{{ end }}">
46 <div class="pr-main">
47 <div class="pr-title">
48 <span class="status-dot {{ if eq .CurrentStatus 200 }}is-up{{ else }}is-down{{ end }}" aria-hidden="true"></span>
49 <a href="/{{ .ID }}" class="text-truncate">{{ .Name }}</a>
50 </div>
51 <div class="pr-url">{{ .URL }}</div>
52 {{ template "uptimeStrip" . }}
53 </div>
54 <div class="pr-side">
55 <div class="pr-meta">
56 <div class="pr-meta-cell">
57 <span class="pr-meta-k">status</span>
58 {{ if eq .CurrentStatus 200 }}
59 <span class="chip chip-ok">ok · 200</span>
60 {{ else }}
61 <span class="chip chip-down">down · {{ .CurrentStatus }}</span>
62 {{ end }}
63 </div>
64 <div class="pr-meta-cell">
65 <span class="pr-meta-k">uptime</span>
66 {{ if .RecentUptimePct }}
67 <span class="chip chip-{{ uptimeClass .RecentUptimePct }}">{{ pct1 .RecentUptimePct }}%</span>
68 {{ else }}
69 <span class="chip chip-muted">—</span>
70 {{ end }}
71 </div>
72 <div class="pr-meta-cell">
73 <span class="pr-meta-k">sec</span>
74 {{ if .HasSecurityIssue }}
75 <span class="chip chip-warn">issues</span>
76 {{ else }}
77 <span class="chip chip-ok">ok</span>
78 {{ end }}
79 </div>
80 <div class="pr-meta-cell">
81 <span class="pr-meta-k">seo</span>
82 <span class="chip chip-{{ countClass (len .CrawlerInsights) 25 100 }}">{{ len .CrawlerInsights }}</span>
83 </div>
84 <div class="pr-meta-cell">
85 <span class="pr-meta-k">lh</span>
86 {{ if .AvgLighthouseScore }}
87 <span class="chip chip-{{ lighthouseClass .AvgLighthouseScore }}">{{ .AvgLighthouseScore }}%</span>
88 {{ else }}
89 <span class="chip chip-muted">—</span>
90 {{ end }}
91 </div>
92 </div>
93 <div class="pr-actions">
94 {{ if not .IsProtected }}
95 <form method="POST" action="/properties/{{ .ID }}/delete" class="d-inline" onsubmit="return confirm('Delete {{ .URL }} and all its checks?');">
96 <button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
97 </form>
98 {{ end }}
99 <a href="/{{ .ID }}" class="btn btn-sm btn-outline-light">View →</a>
100 </div>
101 </div>
102 </div>
103 {{ else }}
104 <div class="text-center py-5 text-muted">
105 <div class="section-label mb-3" style="justify-content:center;">
106 {{ if .Query }}no properties match{{ else }}no properties yet{{ end }}
107 </div>
108 {{ if .Query }}
109 <p class="mb-0 small">Nothing registered matches <strong>{{ .Query }}</strong>. <a href="/properties" class="link-footer">Clear the filter</a>.</p>
110 {{ else }}
111 <p class="mb-0 small">Click <strong>+ new</strong> above to register your first URL.</p>
112 {{ end }}
113 </div>
114 {{ end }}
115</div>
116{{ end }}