repos
/ analytics-rust master

analytics-rust

mirror archived upstream

Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.

analyticsaxumdockerrustself-hostedsqliteviteweb-analytics

17.3 KB · 363 lines · HTML Raw History
  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">&lt;head&gt;</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>&lt;script&gt;
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&lt;/script&gt;</textarea>
358      </div>
359    </div>
360  </div>
361</div>
362{% endblock %}