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, read by properties/index.js. The json
3 helper marshals with HTML escaping on, so a page URL containing
4 "</script>" cannot close the block. */}}
5{{ with .Dash }}
6<script id="chart-total-events-data" type="application/json">{{ json .Graph }}</script>
7<script id="chart-total-events-by-browser-data" type="application/json">{{ json .ByBrowser }}</script>
8<script id="chart-total-events-by-screen-size-data" type="application/json">{{ json .ByScreenSize }}</script>
9<script id="chart-total-events-by-device-data" type="application/json">{{ json .ByDevice }}</script>
10<script id="chart-total-events-by-platform-data" type="application/json">{{ json .ByPlatform }}</script>
11<script id="map-session-starts-by-country" type="application/json">{{ json .SessionsByCountry }}</script>
12<script id="map-session-starts-by-country-region" type="application/json">{{ json .SessionsByCountryRegion }}</script>
13{{ end }}
14<script type="module" src="{{ .PageScript }}"></script>
15{{ end }}
16
17{{ define "breadcrumbs" }}
18<nav aria-label="breadcrumb">
19 <ol class="breadcrumb mb-0">
20 <li class="breadcrumb-item"><a href="/">Home</a></li>
21 <li class="breadcrumb-item"><a href="/properties">Properties</a></li>
22 <li class="breadcrumb-item active" aria-current="page">{{ .Title }}</li>
23 </ol>
24</nav>
25{{ end }}
26
27{{ define "main" }}
28{{ $authed := .Authenticated }}
29{{ $baseURL := .BaseURL }}
30{{ $p := .Property }}
31{{ with .Dash }}
32<div class="bg-deep border-bottom border-subtle py-4">
33 <div class="container">
34 <div class="row align-items-center g-3">
35 <div class="col-12 col-lg-6">
36 <div class="section-label mb-2">property · <span class="text-green">{{ .LiveUsers }} live</span></div>
37 <h1 class="mb-1 text-white fw-bolder d-flex align-items-center gap-3" style="letter-spacing: -0.01em; min-width: 0;">
38 <span class="text-truncate">{{ $p.Name }}</span>
39 <span class="status-dot status-dot-lg {{ if .LiveUsers }}is-up{{ else }}is-idle{{ end }}" aria-hidden="true"></span>
40 </h1>
41 <div class="text-muted small" style="font-family: var(--bs-font-monospace);">{{ $p.ID }}</div>
42 </div>
43 <div class="col-12 col-lg-6 d-flex flex-wrap gap-2 justify-content-lg-end align-items-center d-print-none">
44 {{ if $authed }}
45 <form id="is-public-form" method="POST" action="/properties/{{ $p.ID }}/public" class="d-inline-flex align-items-center gap-2">
46 <span class="toggle-label" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Anyone with the URL can view a public property, and no login is needed.">Visibility</span>
47 <label class="toggle-pill">
48 <input type="checkbox" name="is_public" id="is-public-switch" {{ if $p.IsPublic }}checked{{ end }}>
49 <span class="toggle-pill-seg toggle-pill-private">Private</span>
50 <span class="toggle-pill-seg toggle-pill-public">Public</span>
51 </label>
52 </form>
53 <button type="button" class="btn btn-sm btn-outline-light" data-bs-toggle="modal" data-bs-target="#siteTagModal">
54 Site tag
55 </button>
56 <a href="/{{ $p.ID }}?report" target="_blank" class="btn btn-sm btn-outline-light">Report · pdf</a>
57 <a href="/{{ $p.ID }}?report=md" target="_blank" class="btn btn-sm btn-outline-light">Report · md</a>
58 {{ if not $p.IsProtected }}
59 <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ $p.ID }}">
60 Delete
61 </button>
62 <div class="modal fade" id="delete-modal-{{ $p.ID }}" tabindex="-1" aria-hidden="true">
63 <div class="modal-dialog">
64 <div class="modal-content">
65 <div class="modal-header">
66 <h5 class="modal-title text-white">Confirm delete</h5>
67 <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
68 </div>
69 <div class="modal-body">
70 Delete <strong class="text-white">{{ $p.Name }}</strong>? All collected events will be removed.
71 </div>
72 <div class="modal-footer">
73 <button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
74 <form method="post" action="/properties/{{ $p.ID }}/delete" class="d-inline">
75 <button type="submit" class="btn btn-outline-danger">Delete</button>
76 </form>
77 </div>
78 </div>
79 </div>
80 </div>
81 {{ end }}
82 {{ end }}
83 </div>
84 </div>
85
86 <div class="row mt-3 g-3 align-items-end d-print-none">
87 {{ if .FilterURL }}
88 <div class="col-12">
89 <span class="filter-chip">
90 <span class="section-label" style="letter-spacing: 0.1em;">filter · url</span>
91 <span class="filter-chip-label">{{ .FilterURL }}</span>
92 <button type="button" class="filter-chip-clear btn-filter-clear" data-filter-key="filter_url" data-filter-value="{{ .FilterURL }}" aria-label="Clear filter">✕</button>
93 </span>
94 </div>
95 {{ end }}
96 <div class="col-12 col-lg-8 offset-lg-4">
97 <form method="GET">
98 <div class="date-range-form">
99 <div class="form-floating">
100 <input type="date" name="date_start" id="date-start" class="form-control" value="{{ .DateStart }}" />
101 <label for="date-start">Date start</label>
102 </div>
103 <div class="form-floating">
104 <input type="date" name="date_end" id="date-end" class="form-control" value="{{ .DateEnd }}" />
105 <label for="date-end">Date end</label>
106 </div>
107 <div class="form-floating">
108 <select name="date_range" id="date-range" class="form-select">
109 <option value="custom">Custom</option>
110 {{ $r := .DateRange }}
111 <option value="7" {{ if eq $r 7 }}selected{{ end }}>7 days</option>
112 <option value="14" {{ if eq $r 14 }}selected{{ end }}>14 days</option>
113 <option value="28" {{ if eq $r 28 }}selected{{ end }}>28 days</option>
114 <option value="90" {{ if eq $r 90 }}selected{{ end }}>3 months</option>
115 <option value="180" {{ if eq $r 180 }}selected{{ end }}>6 months</option>
116 <option value="365" {{ if eq $r 365 }}selected{{ end }}>1 year</option>
117 </select>
118 <label for="date-range">Date range</label>
119 </div>
120 </div>
121 </form>
122 </div>
123 </div>
124 </div>
125</div>
126
127<div class="container my-4 position-relative">
128 <div class="d-flex align-items-center justify-content-between mb-2">
129 <div class="section-label">metrics · period vs previous</div>
130 {{ if and $authed .CustomEvents }}
131 <div class="dropdown d-print-none">
132 <button class="btn btn-sm btn-outline-light dropdown-toggle" type="button" id="customCards" data-bs-toggle="dropdown" aria-expanded="false">
133 Custom cards
134 </button>
135 <div class="dropdown-menu dropdown-menu-end p-3" style="width: 300px;" aria-labelledby="customCards">
136 <form id="custom-card-form" method="POST" action="/properties/{{ $p.ID }}/cards">
137 {{ range .CustomEvents }}
138 <div class="form-check form-switch">
139 <input class="form-check-input" type="checkbox" role="switch" name="{{ .Event }}" id="{{ .Event }}-switch" {{ if .Active }}checked{{ end }}>
140 <label class="form-check-label small" for="{{ .Event }}-switch">{{ .Event }}</label>
141 </div>
142 {{ end }}
143 </form>
144 </div>
145 </div>
146 {{ end }}
147 </div>
148
149 <div class="row g-3">
150 {{ range .EventCards }}
151 <div class="col-6 col-md-4 col-lg-3">
152 <div class="metric-tile">
153 <div class="metric-header">
154 <div class="metric-label text-truncate" {{ if .HelpText }}data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ .HelpText }}"{{ end }}>
155 {{ .Name }}
156 </div>
157 {{ if eq .PercentChange 0 }}
158 <span class="metric-delta is-flat" title="No change vs previous period">·</span>
159 {{ else if lt .PercentChange 0 }}
160 <span class="metric-delta is-down">{{ .PercentChange }}%</span>
161 {{ else }}
162 <span class="metric-delta is-up">+{{ .PercentChange }}%</span>
163 {{ end }}
164 </div>
165 <div class="metric-value">{{ .Value }}</div>
166 </div>
167 </div>
168 {{ end }}
169 </div>
170</div>
171
172<div class="container my-4">
173 <div class="row g-3">
174 <div class="col-12 col-md-8">
175 <div class="chart-panel mb-3">
176 <div class="chart-panel-header">
177 <span class="chart-panel-title">events over time</span>
178 </div>
179 <div class="chart-panel-body" style="height: 320px;">
180 <canvas id="chart-total-events"></canvas>
181 </div>
182 </div>
183 <div class="chart-panel">
184 <div class="chart-panel-header chart-panel-header-with-action">
185 <span class="chart-panel-title" id="map-title">sessions · world</span>
186 <button type="button" id="map-back" class="chart-panel-action" hidden>← back to world</button>
187 </div>
188 <div class="chart-panel-body">
189 <div id="map" style="min-height: 320px; position: relative;"></div>
190 </div>
191 </div>
192 </div>
193 <div id="doughnut-graphs" class="col-12 col-md-4">
194 <div class="chart-panel mb-3">
195 <div class="chart-panel-header"><span class="chart-panel-title">device</span></div>
196 <div class="chart-panel-body"><canvas id="chart-total-events-by-device"></canvas></div>
197 </div>
198 <div class="chart-panel mb-3">
199 <div class="chart-panel-header"><span class="chart-panel-title">browser</span></div>
200 <div class="chart-panel-body"><canvas id="chart-total-events-by-browser"></canvas></div>
201 </div>
202 <div class="chart-panel mb-3">
203 <div class="chart-panel-header"><span class="chart-panel-title">platform</span></div>
204 <div class="chart-panel-body"><canvas id="chart-total-events-by-platform"></canvas></div>
205 </div>
206 <div class="chart-panel mb-3">
207 <div class="chart-panel-header"><span class="chart-panel-title">screen size</span></div>
208 <div class="chart-panel-body"><canvas id="chart-total-events-by-screen-size"></canvas></div>
209 </div>
210 </div>
211 </div>
212</div>
213
214<div class="container my-4">
215 <div class="section-label mb-2">top lists · ranked</div>
216 <div id="top-lists" class="row g-3">
217 {{ template "rankList" dict "title" "top pages · page views" "col" "count" "items" .PageViewsByPageURL "filterFor" $p.ID }}
218 {{ template "rankList" dict "title" "top pages · all events" "col" "count" "items" .ByPageURL "filterFor" $p.ID }}
219 {{ template "rankList" dict "title" "top referrers" "col" "sessions" "items" .SessionsByReferrer "filterFor" "" }}
220 {{ template "rankList" dict "title" "top custom events" "col" "count" "items" .ByCustomEvent "filterFor" "" }}
221 {{ template "rankList" dict "title" "utm medium" "col" "views" "items" .PageViewsByUTMMedium "filterFor" "" }}
222 {{ template "rankList" dict "title" "utm source" "col" "views" "items" .PageViewsByUTMSource "filterFor" "" }}
223 {{ template "rankList" dict "title" "utm campaign" "col" "views" "items" .PageViewsByUTMCampaig "filterFor" "" }}
224 </div>
225</div>
226
227<div class="container my-4">
228 <div class="section-label mb-2">bot traffic · excluded from metrics above</div>
229 {{ if .Bots.Total }}
230 <div class="row g-3">
231 <div class="col-12 col-md-4">
232 <div class="metric-tile">
233 <div class="metric-label">Total bot events</div>
234 <div class="metric-value">{{ .Bots.Total }}</div>
235 </div>
236 </div>
237 {{ template "rankList" dict "title" "top bots" "col" "events" "items" .Bots.TopBots "filterFor" "" }}
238 {{ template "rankList" dict "title" "top pages · bot hits" "col" "events" "items" .Bots.TopPages "filterFor" "" }}
239 </div>
240 {{ else }}
241 <div class="text-muted small">No bot events in this range.</div>
242 {{ end }}
243</div>
244{{ end }}
245
246<div class="modal fade" id="siteTagModal" tabindex="-1" aria-hidden="true">
247 <div class="modal-dialog modal-lg">
248 <div class="modal-content">
249 <div class="modal-header">
250 <h5 class="modal-title text-white">Collector site tag</h5>
251 <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
252 </div>
253 <div class="modal-body">
254 <p class="text-muted small mb-3">Drop this into the <code class="text-amber"><head></code> of your site. The collector ID below is scoped to <strong class="text-white">{{ $p.Name }}</strong>.</p>
255 <textarea class="codebox" rows="7" readonly><script>
256(function(m,e,t,r,i,c,s){m.collectorQueue = m.collectorQueue || r;
257m.collectorServer = c; m.collectorId = s; var script = e.createElement(t);
258script.src = c + i; e.head.appendChild(script);
259})(window,document,'script',[],'/static/collector.js',
260'{{ $baseURL }}','{{ $p.ID }}');
261</script></textarea>
262 </div>
263 </div>
264 </div>
265</div>
266{{ end }}