Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1{% extends 'base.html' %}
2
3{% block extra_js %}
4<script id="chart-total-events-data" type="application/json">{{ total_events_graph | tojson | safe }}</script>
5<script id="chart-total-events-by-browser-data" type="application/json">{{ total_events_by_browser | tojson | safe }}</script>
6<script id="chart-total-events-by-screen-size-data" type="application/json">{{ total_events_by_screen_size | tojson | safe }}</script>
7<script id="chart-total-events-by-device-data" type="application/json">{{ total_events_by_device | tojson | safe }}</script>
8<script id="chart-total-events-by-platform-data" type="application/json">{{ total_events_by_platform | tojson | safe }}</script>
9<script id="map-session-starts-by-country" type="application/json">{{ session_starts_by_country | tojson | safe }}</script>
10<script id="map-session-starts-by-country-region" type="application/json">{{ session_starts_by_country_region | tojson | safe }}</script>
11<script type="module" src="{{ vite_asset('static_src/properties/index.js') }}"></script>
12{% endblock %}
13
14{% block breadcrumbs %}
15<nav aria-label="breadcrumb">
16 <ol class="breadcrumb mb-0">
17 <li class="breadcrumb-item"><a href="/">Home</a></li>
18 <li class="breadcrumb-item"><a href="/properties">Properties</a></li>
19 <li class="breadcrumb-item active" aria-current="page">{{ page.title }}</li>
20 </ol>
21</nav>
22{% endblock %}
23
24{% block main %}
25<div class="bg-deep border-bottom border-subtle py-4">
26 <div class="container">
27 <div class="row align-items-center g-3">
28 <div class="col-12 col-lg-6">
29 <div class="section-label mb-2">property · <span class="text-green">{{ total_live_users }} live</span></div>
30 <h1 class="mb-1 text-white fw-bolder d-flex align-items-center gap-3" style="letter-spacing: -0.01em; min-width: 0;">
31 <span class="text-truncate">{{ property.name }}</span>
32 <span class="status-dot status-dot-lg {% if total_live_users %}is-up{% else %}is-idle{% endif %}" aria-hidden="true"></span>
33 </h1>
34 <div class="text-muted small" style="font-family: var(--bs-font-monospace);">{{ property.id }}</div>
35 </div>
36 <div class="col-12 col-lg-6 d-flex flex-wrap gap-2 justify-content-lg-end align-items-center d-print-none">
37 {% if user.is_authenticated %}
38 <form id="is-public-form" method="POST" action="/properties/{{ property.id }}/public" class="d-inline-flex align-items-center gap-2">
39 <span class="toggle-label" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Public properties are viewable by anyone with the URL — no login required.">Visibility</span>
40 <label class="toggle-pill">
41 <input type="checkbox" name="is_public" id="is-public-switch" {% if property.is_public %}checked{% endif %}>
42 <span class="toggle-pill-seg toggle-pill-private">Private</span>
43 <span class="toggle-pill-seg toggle-pill-public">Public</span>
44 </label>
45 </form>
46 <button type="button" class="btn btn-sm btn-outline-light" data-bs-toggle="modal" data-bs-target="#siteTagModal">
47 Site tag
48 </button>
49 <a href="/{{ property.id }}?report" target="_blank" class="btn btn-sm btn-outline-light">Report · pdf</a>
50 <a href="/{{ property.id }}?report=md" target="_blank" class="btn btn-sm btn-outline-light">Report · md</a>
51 {% if not property.is_protected %}
52 <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}">
53 Delete
54 </button>
55 <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-hidden="true">
56 <div class="modal-dialog">
57 <div class="modal-content">
58 <div class="modal-header">
59 <h5 class="modal-title text-white">Confirm delete</h5>
60 <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
61 </div>
62 <div class="modal-body">
63 Delete <strong class="text-white">{{ property.name }}</strong>? All collected events will be removed.
64 </div>
65 <div class="modal-footer">
66 <button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
67 <form method="post" action="/properties/{{ property.id }}/delete" class="d-inline">
68 <button type="submit" class="btn btn-outline-danger">Delete</button>
69 </form>
70 </div>
71 </div>
72 </div>
73 </div>
74 {% endif %}
75 {% endif %}
76 </div>
77 </div>
78
79 <div class="row mt-3 g-3 align-items-end d-print-none">
80 {% if filter_url %}
81 <div class="col-12">
82 <span class="filter-chip">
83 <span class="section-label" style="letter-spacing: 0.1em;">filter · url</span>
84 <span class="filter-chip-label">{{ filter_url }}</span>
85 <button type="button" class="filter-chip-clear btn-filter-clear" data-filter-key="filter_url" data-filter-value="{{ filter_url }}" aria-label="Clear filter">✕</button>
86 </span>
87 </div>
88 {% endif %}
89 <div class="col-12 col-lg-8 offset-lg-4">
90 <form method="GET">
91 <div class="date-range-form">
92 <div class="form-floating">
93 <input type="date" name="date_start" id="date-start" class="form-control" value="{{ date_start | default('') }}" />
94 <label for="date-start">Date start</label>
95 </div>
96 <div class="form-floating">
97 <input type="date" name="date_end" id="date-end" class="form-control" value="{{ date_end | default('') }}" />
98 <label for="date-end">Date end</label>
99 </div>
100 <div class="form-floating">
101 <select name="date_range" id="date-range" class="form-select">
102 <option value="custom">Custom</option>
103 <option value="7" {% if date_range == 7 %}selected{% endif %}>7 days</option>
104 <option value="14" {% if date_range == 14 %}selected{% endif %}>14 days</option>
105 <option value="28" {% if date_range == 28 %}selected{% endif %}>28 days</option>
106 <option value="90" {% if date_range == 90 %}selected{% endif %}>3 months</option>
107 <option value="180" {% if date_range == 180 %}selected{% endif %}>6 months</option>
108 <option value="365" {% if date_range == 365 %}selected{% endif %}>1 year</option>
109 </select>
110 <label for="date-range">Date range</label>
111 </div>
112 </div>
113 </form>
114 </div>
115 </div>
116 </div>
117</div>
118
119<div class="container my-4 position-relative">
120 <div class="d-flex align-items-center justify-content-between mb-2">
121 <div class="section-label">metrics · period vs previous</div>
122 {% if user.is_authenticated and custom_events | length > 0 %}
123 <div class="dropdown d-print-none">
124 <button class="btn btn-sm btn-outline-light dropdown-toggle" type="button" id="customCards" data-bs-toggle="dropdown" aria-expanded="false">
125 Custom cards
126 </button>
127 <div class="dropdown-menu dropdown-menu-end p-3" style="width: 300px;" aria-labelledby="customCards">
128 <form id="custom-card-form" method="POST" action="/properties/{{ property.id }}/cards">
129 {% for ce in custom_events %}
130 <div class="form-check form-switch">
131 <input class="form-check-input" type="checkbox" role="switch" name="{{ ce.event }}" id="{{ ce.event }}-switch" {% if ce.active %}checked{% endif %}>
132 <label class="form-check-label small" for="{{ ce.event }}-switch">{{ ce.event }}</label>
133 </div>
134 {% endfor %}
135 </form>
136 </div>
137 </div>
138 {% endif %}
139 </div>
140
141 <div class="row g-3">
142 {% for c in event_cards %}
143 <div class="col-6 col-md-4 col-lg-3">
144 <div class="metric-tile">
145 <div class="metric-header">
146 <div class="metric-label text-truncate" {% if c.help_text %}data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ c.help_text }}"{% endif %}>
147 {{ c.name }}
148 </div>
149 {% if c.percent_change == 0 %}
150 <span class="metric-delta is-flat" title="No change vs previous period">·</span>
151 {% elif c.percent_change < 0 %}
152 <span class="metric-delta is-down">{{ c.percent_change }}%</span>
153 {% else %}
154 <span class="metric-delta is-up">+{{ c.percent_change }}%</span>
155 {% endif %}
156 </div>
157 <div class="metric-value">{{ c.value }}</div>
158 </div>
159 </div>
160 {% endfor %}
161 </div>
162</div>
163
164<div class="container my-4">
165 <div class="row g-3">
166 <div class="col-12 col-md-8">
167 <div class="chart-panel mb-3">
168 <div class="chart-panel-header">
169 <span class="chart-panel-title">events over time</span>
170 </div>
171 <div class="chart-panel-body" style="height: 320px;">
172 <canvas id="chart-total-events"></canvas>
173 </div>
174 </div>
175 <div class="chart-panel">
176 <div class="chart-panel-header chart-panel-header-with-action">
177 <span class="chart-panel-title" id="map-title">sessions · world</span>
178 <button type="button" id="map-back" class="chart-panel-action" hidden>← back to world</button>
179 </div>
180 <div class="chart-panel-body">
181 <div id="map" style="min-height: 320px; position: relative;"></div>
182 </div>
183 </div>
184 </div>
185 <div id="doughnut-graphs" class="col-12 col-md-4">
186 <div class="chart-panel mb-3">
187 <div class="chart-panel-header"><span class="chart-panel-title">device</span></div>
188 <div class="chart-panel-body"><canvas id="chart-total-events-by-device"></canvas></div>
189 </div>
190 <div class="chart-panel mb-3">
191 <div class="chart-panel-header"><span class="chart-panel-title">browser</span></div>
192 <div class="chart-panel-body"><canvas id="chart-total-events-by-browser"></canvas></div>
193 </div>
194 <div class="chart-panel mb-3">
195 <div class="chart-panel-header"><span class="chart-panel-title">platform</span></div>
196 <div class="chart-panel-body"><canvas id="chart-total-events-by-platform"></canvas></div>
197 </div>
198 <div class="chart-panel mb-3">
199 <div class="chart-panel-header"><span class="chart-panel-title">screen size</span></div>
200 <div class="chart-panel-body"><canvas id="chart-total-events-by-screen-size"></canvas></div>
201 </div>
202 </div>
203 </div>
204</div>
205
206<div class="container my-4">
207 <div class="section-label mb-2">top lists · ranked</div>
208 <div id="top-lists" class="row g-3">
209 {% if total_page_views_by_page_url | length > 0 %}
210 <div class="col-12 col-sm-6 col-lg-4">
211 <div class="rank-list">
212 <div class="rank-list-header"><span class="rank-list-title">top pages · page views</span><span class="rank-list-col">count</span></div>
213 <div class="rank-list-body">
214 {% for item in total_page_views_by_page_url %}
215 <div class="rank-list-row">
216 <span class="rank-label"><a href="/{{ property.id }}?filter_url={{ item.label | urlencode }}">{{ item.label }}</a></span>
217 <span class="rank-count">{{ item.count }}</span>
218 </div>
219 {% endfor %}
220 </div>
221 </div>
222 </div>
223 {% endif %}
224 {% if total_events_by_page_url | length > 0 %}
225 <div class="col-12 col-sm-6 col-lg-4">
226 <div class="rank-list">
227 <div class="rank-list-header"><span class="rank-list-title">top pages · all events</span><span class="rank-list-col">count</span></div>
228 <div class="rank-list-body">
229 {% for item in total_events_by_page_url %}
230 <div class="rank-list-row">
231 <span class="rank-label"><a href="/{{ property.id }}?filter_url={{ item.label | urlencode }}">{{ item.label }}</a></span>
232 <span class="rank-count">{{ item.count }}</span>
233 </div>
234 {% endfor %}
235 </div>
236 </div>
237 </div>
238 {% endif %}
239 {% if total_session_starts_by_referrer | length > 0 %}
240 <div class="col-12 col-sm-6 col-lg-4">
241 <div class="rank-list">
242 <div class="rank-list-header"><span class="rank-list-title">top referrers</span><span class="rank-list-col">sessions</span></div>
243 <div class="rank-list-body">
244 {% for item in total_session_starts_by_referrer %}
245 <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>
246 {% endfor %}
247 </div>
248 </div>
249 </div>
250 {% endif %}
251 {% if total_events_by_custom_event | length > 0 %}
252 <div class="col-12 col-sm-6 col-lg-4">
253 <div class="rank-list">
254 <div class="rank-list-header"><span class="rank-list-title">top custom events</span><span class="rank-list-col">count</span></div>
255 <div class="rank-list-body">
256 {% for item in total_events_by_custom_event %}
257 <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>
258 {% endfor %}
259 </div>
260 </div>
261 </div>
262 {% endif %}
263 {% if total_page_views_by_utm_medium | length > 0 %}
264 <div class="col-12 col-sm-6 col-lg-4">
265 <div class="rank-list">
266 <div class="rank-list-header"><span class="rank-list-title">utm medium</span><span class="rank-list-col">views</span></div>
267 <div class="rank-list-body">
268 {% for item in total_page_views_by_utm_medium %}
269 <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>
270 {% endfor %}
271 </div>
272 </div>
273 </div>
274 {% endif %}
275 {% if total_page_views_by_utm_source | length > 0 %}
276 <div class="col-12 col-sm-6 col-lg-4">
277 <div class="rank-list">
278 <div class="rank-list-header"><span class="rank-list-title">utm source</span><span class="rank-list-col">views</span></div>
279 <div class="rank-list-body">
280 {% for item in total_page_views_by_utm_source %}
281 <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>
282 {% endfor %}
283 </div>
284 </div>
285 </div>
286 {% endif %}
287 {% if total_page_views_by_utm_campaign | length > 0 %}
288 <div class="col-12 col-sm-6 col-lg-4">
289 <div class="rank-list">
290 <div class="rank-list-header"><span class="rank-list-title">utm campaign</span><span class="rank-list-col">views</span></div>
291 <div class="rank-list-body">
292 {% for item in total_page_views_by_utm_campaign %}
293 <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>
294 {% endfor %}
295 </div>
296 </div>
297 </div>
298 {% endif %}
299 </div>
300</div>
301
302<div class="container my-4">
303 <div class="section-label mb-2">bot traffic · excluded from metrics above</div>
304 {% if bot_traffic.total > 0 %}
305 <div class="row g-3">
306 <div class="col-12 col-md-4">
307 <div class="metric-tile">
308 <div class="metric-label">Total bot events</div>
309 <div class="metric-value">{{ bot_traffic.total }}</div>
310 </div>
311 </div>
312 {% if bot_traffic.top_bots | length > 0 %}
313 <div class="col-12 col-md-4">
314 <div class="rank-list">
315 <div class="rank-list-header"><span class="rank-list-title">top bots</span><span class="rank-list-col">events</span></div>
316 <div class="rank-list-body">
317 {% for item in bot_traffic.top_bots %}
318 <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>
319 {% endfor %}
320 </div>
321 </div>
322 </div>
323 {% endif %}
324 {% if bot_traffic.top_pages | length > 0 %}
325 <div class="col-12 col-md-4">
326 <div class="rank-list">
327 <div class="rank-list-header"><span class="rank-list-title">top pages · bot hits</span><span class="rank-list-col">events</span></div>
328 <div class="rank-list-body">
329 {% for item in bot_traffic.top_pages %}
330 <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>
331 {% endfor %}
332 </div>
333 </div>
334 </div>
335 {% endif %}
336 </div>
337 {% else %}
338 <div class="text-muted small">No bot events in this range.</div>
339 {% endif %}
340</div>
341
342<div class="modal fade" id="siteTagModal" tabindex="-1" aria-hidden="true">
343 <div class="modal-dialog modal-lg">
344 <div class="modal-content">
345 <div class="modal-header">
346 <h5 class="modal-title text-white">Collector site tag</h5>
347 <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
348 </div>
349 <div class="modal-body">
350 <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">{{ property.name }}</strong>.</p>
351 <textarea class="codebox" rows="7" readonly><script>
352(function(m,e,t,r,i,c,s){m.collectorQueue = m.collectorQueue || r;
353m.collectorServer = c; m.collectorId = s; var script = e.createElement(t);
354script.src = c + i; e.head.appendChild(script);
355})(window,document,'script',[],'/static/collector.js',
356'{{ collector_server }}','{{ property.id }}');
357</script></textarea>
358 </div>
359 </div>
360 </div>
361</div>
362{% endblock %}