feat: add charts, world map, and filters to web dashboard

Add Chart.js line/bar charts for attack trends (attempts over time,
hourly pattern), an SVG world map choropleth colored by attack origin
country, and a collapsible filter form (date range, IP, country,
username) that narrows both charts and top-N tables.

New store methods: GetAttemptsOverTime, GetHourlyPattern, GetCountryStats,
and filtered variants of dashboard stats/top-N queries. New JSON API
endpoints at /api/charts/* and an htmx fragment at
/fragments/dashboard-content for filtered table updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 20:27:15 +01:00
parent 8a631af0d2
commit 7c90c9ed4a
13 changed files with 1480 additions and 41 deletions

View File

@@ -4,6 +4,8 @@ import (
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"time"
"git.t-juice.club/torjus/oubliette/internal/storage"
)
@@ -180,6 +182,186 @@ type apiEventsResponse struct {
Events []apiEvent `json:"events"`
}
// parseDateParam parses a "YYYY-MM-DD" query parameter into a *time.Time.
func parseDateParam(r *http.Request, name string) *time.Time {
v := r.URL.Query().Get(name)
if v == "" {
return nil
}
t, err := time.Parse("2006-01-02", v)
if err != nil {
return nil
}
// For "until" dates, set to end of day.
if name == "until" {
t = t.Add(24*time.Hour - time.Second)
}
return &t
}
func parseDashboardFilter(r *http.Request) storage.DashboardFilter {
return storage.DashboardFilter{
Since: parseDateParam(r, "since"),
Until: parseDateParam(r, "until"),
IP: r.URL.Query().Get("ip"),
Country: r.URL.Query().Get("country"),
Username: r.URL.Query().Get("username"),
}
}
type apiTimeSeriesPoint struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
type apiAttemptsOverTimeResponse struct {
Points []apiTimeSeriesPoint `json:"points"`
}
func (s *Server) handleAPIAttemptsOverTime(w http.ResponseWriter, r *http.Request) {
days := 30
if v := r.URL.Query().Get("days"); v != "" {
if d, err := strconv.Atoi(v); err == nil && d > 0 && d <= 365 {
days = d
}
}
since := parseDateParam(r, "since")
until := parseDateParam(r, "until")
points, err := s.store.GetAttemptsOverTime(r.Context(), days, since, until)
if err != nil {
s.logger.Error("failed to get attempts over time", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
resp := apiAttemptsOverTimeResponse{Points: make([]apiTimeSeriesPoint, len(points))}
for i, p := range points {
resp.Points[i] = apiTimeSeriesPoint{
Date: p.Timestamp.Format("2006-01-02"),
Count: p.Count,
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.logger.Error("failed to encode attempts over time", "err", err)
}
}
type apiHourlyCount struct {
Hour int `json:"hour"`
Count int64 `json:"count"`
}
type apiHourlyPatternResponse struct {
Hours []apiHourlyCount `json:"hours"`
}
func (s *Server) handleAPIHourlyPattern(w http.ResponseWriter, r *http.Request) {
since := parseDateParam(r, "since")
until := parseDateParam(r, "until")
counts, err := s.store.GetHourlyPattern(r.Context(), since, until)
if err != nil {
s.logger.Error("failed to get hourly pattern", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
resp := apiHourlyPatternResponse{Hours: make([]apiHourlyCount, len(counts))}
for i, c := range counts {
resp.Hours[i] = apiHourlyCount{Hour: c.Hour, Count: c.Count}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.logger.Error("failed to encode hourly pattern", "err", err)
}
}
type apiCountryCount struct {
Country string `json:"country"`
Count int64 `json:"count"`
}
type apiCountryStatsResponse struct {
Countries []apiCountryCount `json:"countries"`
}
func (s *Server) handleAPICountryStats(w http.ResponseWriter, r *http.Request) {
counts, err := s.store.GetCountryStats(r.Context())
if err != nil {
s.logger.Error("failed to get country stats", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
resp := apiCountryStatsResponse{Countries: make([]apiCountryCount, len(counts))}
for i, c := range counts {
resp.Countries[i] = apiCountryCount{Country: c.Country, Count: c.Count}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.logger.Error("failed to encode country stats", "err", err)
}
}
func (s *Server) handleFragmentDashboardContent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
f := parseDashboardFilter(r)
stats, err := s.store.GetFilteredDashboardStats(ctx, f)
if err != nil {
s.logger.Error("failed to get filtered stats", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
topUsernames, err := s.store.GetFilteredTopUsernames(ctx, 10, f)
if err != nil {
s.logger.Error("failed to get filtered top usernames", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
topPasswords, err := s.store.GetFilteredTopPasswords(ctx, 10, f)
if err != nil {
s.logger.Error("failed to get filtered top passwords", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
topIPs, err := s.store.GetFilteredTopIPs(ctx, 10, f)
if err != nil {
s.logger.Error("failed to get filtered top IPs", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
topCountries, err := s.store.GetFilteredTopCountries(ctx, 10, f)
if err != nil {
s.logger.Error("failed to get filtered top countries", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := dashboardData{
Stats: stats,
TopUsernames: topUsernames,
TopPasswords: topPasswords,
TopIPs: topIPs,
TopCountries: topCountries,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.dashboard.ExecuteTemplate(w, "dashboard_content", data); err != nil {
s.logger.Error("failed to render dashboard content fragment", "err", err)
}
}
func (s *Server) handleAPISessionEvents(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionID := r.PathValue("id")

14
internal/web/static/chart.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,271 @@
(function() {
'use strict';
// Chart.js theme for Pico dark mode
Chart.defaults.color = '#b0b0b8';
Chart.defaults.borderColor = '#3a3a4a';
var attemptsChart = null;
var hourlyChart = null;
function getFilterParams() {
var form = document.getElementById('filter-form');
if (!form) return '';
var params = new URLSearchParams();
var since = form.elements['since'].value;
var until = form.elements['until'].value;
if (since) params.set('since', since);
if (until) params.set('until', until);
return params.toString();
}
function initAttemptsChart() {
var canvas = document.getElementById('chart-attempts');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var qs = getFilterParams();
var url = '/api/charts/attempts-over-time' + (qs ? '?' + qs : '');
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
var labels = data.points.map(function(p) { return p.date; });
var values = data.points.map(function(p) { return p.count; });
if (attemptsChart) {
attemptsChart.data.labels = labels;
attemptsChart.data.datasets[0].data = values;
attemptsChart.update();
return;
}
attemptsChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Attempts',
data: values,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false } },
y: { beginAtZero: true }
}
}
});
});
}
function initHourlyChart() {
var canvas = document.getElementById('chart-hourly');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var qs = getFilterParams();
var url = '/api/charts/hourly-pattern' + (qs ? '?' + qs : '');
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
// Fill all 24 hours, defaulting to 0
var hourMap = {};
data.hours.forEach(function(h) { hourMap[h.hour] = h.count; });
var labels = [];
var values = [];
for (var i = 0; i < 24; i++) {
labels.push(i + ':00');
values.push(hourMap[i] || 0);
}
if (hourlyChart) {
hourlyChart.data.labels = labels;
hourlyChart.data.datasets[0].data = values;
hourlyChart.update();
return;
}
hourlyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Attempts',
data: values,
backgroundColor: 'rgba(99, 102, 241, 0.6)',
borderColor: '#6366f1',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false } },
y: { beginAtZero: true }
}
}
});
});
}
function initWorldMap() {
var container = document.getElementById('world-map');
if (!container) return;
fetch('/static/world.svg')
.then(function(r) { return r.text(); })
.then(function(svgText) {
container.innerHTML = svgText;
fetch('/api/charts/country-stats')
.then(function(r) { return r.json(); })
.then(function(data) {
colorMap(container, data.countries);
});
});
}
function colorMap(container, countries) {
if (!countries || countries.length === 0) return;
var maxCount = countries[0].count; // already sorted DESC
var logMax = Math.log(maxCount + 1);
// Build lookup
var lookup = {};
countries.forEach(function(c) {
lookup[c.country.toLowerCase()] = c.count;
});
// Create tooltip element
var tooltip = document.createElement('div');
tooltip.id = 'map-tooltip';
tooltip.style.cssText = 'position:fixed;display:none;background:#1a1a2e;color:#e0e0e8;padding:4px 8px;border-radius:4px;font-size:13px;pointer-events:none;z-index:1000;border:1px solid #3a3a4a;';
document.body.appendChild(tooltip);
var svg = container.querySelector('svg');
if (!svg) return;
var paths = svg.querySelectorAll('path[id]');
paths.forEach(function(path) {
var id = path.id.toLowerCase();
if (id.charAt(0) === '_') return; // skip non-country paths
var count = lookup[id];
if (count) {
var intensity = Math.log(count + 1) / logMax;
var r = Math.round(30 + intensity * 69); // 30 -> 99
var g = Math.round(30 + intensity * 72); // 30 -> 102
var b = Math.round(62 + intensity * 179); // 62 -> 241
path.style.fill = 'rgb(' + r + ',' + g + ',' + b + ')';
}
path.addEventListener('mouseenter', function(e) {
var cc = id.toUpperCase();
var n = lookup[id] || 0;
tooltip.textContent = cc + ': ' + n.toLocaleString() + ' attempts';
tooltip.style.display = 'block';
});
path.addEventListener('mousemove', function(e) {
tooltip.style.left = (e.clientX + 12) + 'px';
tooltip.style.top = (e.clientY - 10) + 'px';
});
path.addEventListener('mouseleave', function() {
tooltip.style.display = 'none';
});
path.addEventListener('click', function() {
var input = document.querySelector('#filter-form input[name="country"]');
if (input) {
input.value = id.toUpperCase();
applyFilters();
}
});
path.style.cursor = 'pointer';
});
}
function applyFilters() {
// Re-fetch charts with filter params
initAttemptsChart();
initHourlyChart();
// Re-fetch dashboard content via htmx
var form = document.getElementById('filter-form');
if (!form) return;
var params = new URLSearchParams();
['since', 'until', 'ip', 'country', 'username'].forEach(function(name) {
var val = form.elements[name].value;
if (val) params.set(name, val);
});
var target = document.getElementById('dashboard-content');
if (target) {
var url = '/fragments/dashboard-content?' + params.toString();
htmx.ajax('GET', url, {target: '#dashboard-content', swap: 'innerHTML'});
}
// Client-side filter for recent sessions table
filterSessionsTable(form);
}
function filterSessionsTable(form) {
var ip = form.elements['ip'].value.toLowerCase();
var country = form.elements['country'].value.toLowerCase();
var username = form.elements['username'].value.toLowerCase();
var rows = document.querySelectorAll('#recent-sessions-table tbody tr');
rows.forEach(function(row) {
var cells = row.querySelectorAll('td');
if (cells.length < 4) { row.style.display = ''; return; }
var show = true;
if (ip && cells[1].textContent.toLowerCase().indexOf(ip) === -1) show = false;
if (country && cells[2].textContent.toLowerCase().indexOf(country) === -1) show = false;
if (username && cells[3].textContent.toLowerCase().indexOf(username) === -1) show = false;
row.style.display = show ? '' : 'none';
});
}
window.clearFilters = function() {
var form = document.getElementById('filter-form');
if (form) {
form.reset();
applyFilters();
}
};
window.applyFilters = applyFilters;
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', function() {
initAttemptsChart();
initHourlyChart();
initWorldMap();
var form = document.getElementById('filter-form');
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
applyFilters();
});
}
});
})();

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -3,6 +3,94 @@
{{template "stats" .Stats}}
</section>
<details>
<summary>Filters</summary>
<form id="filter-form">
<div class="grid">
<label>Since <input type="date" name="since"></label>
<label>Until <input type="date" name="until"></label>
<label>IP <input type="text" name="ip" placeholder="10.0.0.1"></label>
<label>Country <input type="text" name="country" placeholder="CN" maxlength="2"></label>
<label>Username <input type="text" name="username" placeholder="root"></label>
</div>
<button type="submit">Apply</button>
<button type="button" class="secondary" onclick="clearFilters()">Clear</button>
</form>
</details>
<section>
<h3>Attack Trends</h3>
<div class="grid">
<article>
<header>Attempts Over Time</header>
<canvas id="chart-attempts"></canvas>
</article>
<article>
<header>Hourly Pattern (UTC)</header>
<canvas id="chart-hourly"></canvas>
</article>
</div>
</section>
<section>
<h3>Attack Origins</h3>
<article>
<div id="world-map"></div>
</article>
</section>
<div id="dashboard-content">
{{template "dashboard_content" .}}
</div>
<section>
<h3>Active Sessions</h3>
<div id="active-sessions" hx-get="/fragments/active-sessions" hx-trigger="every 10s" hx-swap="innerHTML">
{{template "active_sessions" .ActiveSessions}}
</div>
</section>
<section>
<h3>Recent Sessions</h3>
<table id="recent-sessions-table">
<thead>
<tr>
<th>ID</th>
<th>IP</th>
<th>Country</th>
<th>Username</th>
<th>Type</th>
<th>Score</th>
<th>Connected</th>
<th>Disconnected</th>
</tr>
</thead>
<tbody>
{{range .RecentSessions}}
<tr>
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a>{{if gt .EventCount 0}} <mark>replay</mark>{{end}}</td>
<td>{{.IP}}</td>
<td>{{.Country}}</td>
<td>{{.Username}}</td>
<td>{{if .ExecCommand}}<mark>exec</mark>{{else}}{{.ShellName}}{{end}}</td>
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
<td>{{formatTime .ConnectedAt}}</td>
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="8">No sessions</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{define "scripts"}}
<script src="/static/chart.min.js"></script>
<script src="/static/dashboard.js"></script>
{{end}}
{{define "dashboard_content"}}
<section>
<h3>Top Credentials & IPs</h3>
<div class="top-grid">
@@ -83,45 +171,4 @@
</article>
</div>
</section>
<section>
<h3>Active Sessions</h3>
<div id="active-sessions" hx-get="/fragments/active-sessions" hx-trigger="every 10s" hx-swap="innerHTML">
{{template "active_sessions" .ActiveSessions}}
</div>
</section>
<section>
<h3>Recent Sessions</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>IP</th>
<th>Country</th>
<th>Username</th>
<th>Type</th>
<th>Score</th>
<th>Connected</th>
<th>Disconnected</th>
</tr>
</thead>
<tbody>
{{range .RecentSessions}}
<tr>
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a>{{if gt .EventCount 0}} <mark>replay</mark>{{end}}</td>
<td>{{.IP}}</td>
<td>{{.Country}}</td>
<td>{{.Username}}</td>
<td>{{if .ExecCommand}}<mark>exec</mark>{{else}}{{.ShellName}}{{end}}</td>
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
<td>{{formatTime .ConnectedAt}}</td>
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="8">No sessions</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}

View File

@@ -36,6 +36,9 @@
overflow: hidden;
min-width: 0;
}
#world-map svg { width: 100%; height: auto; }
#world-map svg path { fill: #2a2a3e; stroke: #555; stroke-width: 0.5; transition: fill 0.2s; }
#world-map svg path:hover { stroke: #fff; stroke-width: 1; }
nav h1 {
margin: 0;
}
@@ -56,5 +59,6 @@
<main class="container">
{{block "content" .}}{{end}}
</main>
{{block "scripts" .}}{{end}}
</body>
</html>

View File

@@ -40,9 +40,13 @@ func NewServer(store storage.Store, logger *slog.Logger, metricsHandler http.Han
s.mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux.HandleFunc("GET /sessions/{id}", s.handleSessionDetail)
s.mux.HandleFunc("GET /api/sessions/{id}/events", s.handleAPISessionEvents)
s.mux.HandleFunc("GET /api/charts/attempts-over-time", s.handleAPIAttemptsOverTime)
s.mux.HandleFunc("GET /api/charts/hourly-pattern", s.handleAPIHourlyPattern)
s.mux.HandleFunc("GET /api/charts/country-stats", s.handleAPICountryStats)
s.mux.HandleFunc("GET /", s.handleDashboard)
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
s.mux.HandleFunc("GET /fragments/dashboard-content", s.handleFragmentDashboardContent)
if metricsHandler != nil {
h := metricsHandler

View File

@@ -395,6 +395,135 @@ func TestDashboardExecCommands(t *testing.T) {
}
}
func TestAPIAttemptsOverTime(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/charts/attempts-over-time", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var resp apiAttemptsOverTimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
// Seeded data inserted today -> at least 1 point.
if len(resp.Points) == 0 {
t.Error("expected at least one data point")
}
}
func TestAPIHourlyPattern(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/charts/hourly-pattern", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp apiHourlyPatternResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(resp.Hours) == 0 {
t.Error("expected at least one hourly data point")
}
}
func TestAPICountryStats(t *testing.T) {
store := storage.NewMemoryStore()
ctx := context.Background()
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1", "CN"); err != nil {
t.Fatalf("seeding: %v", err)
}
if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2", "RU"); err != nil {
t.Fatalf("seeding: %v", err)
}
srv, err := NewServer(store, slog.Default(), nil, "")
if err != nil {
t.Fatalf("NewServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/charts/country-stats", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp apiCountryStatsResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(resp.Countries) != 2 {
t.Fatalf("len = %d, want 2", len(resp.Countries))
}
}
func TestFragmentDashboardContent(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/fragments/dashboard-content", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "<!DOCTYPE html>") {
t.Error("dashboard content fragment should not contain full HTML document")
}
if !strings.Contains(body, "Top Usernames") {
t.Error("dashboard content fragment should contain 'Top Usernames'")
}
}
func TestFragmentDashboardContentWithFilter(t *testing.T) {
store := storage.NewMemoryStore()
ctx := context.Background()
for range 5 {
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1", "CN"); err != nil {
t.Fatalf("seeding: %v", err)
}
}
for range 3 {
if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2", "RU"); err != nil {
t.Fatalf("seeding: %v", err)
}
}
srv, err := NewServer(store, slog.Default(), nil, "")
if err != nil {
t.Fatalf("NewServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/fragments/dashboard-content?country=CN", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// When filtered by CN, should show root but not admin.
if !strings.Contains(body, "root") {
t.Error("response should contain 'root' when filtered by CN")
}
}
func TestStaticAssets(t *testing.T) {
srv := newTestServer(t)
@@ -404,6 +533,9 @@ func TestStaticAssets(t *testing.T) {
}{
{"/static/pico.min.css", "text/css"},
{"/static/htmx.min.js", "text/javascript"},
{"/static/chart.min.js", "text/javascript"},
{"/static/dashboard.js", "text/javascript"},
{"/static/world.svg", "image/svg+xml"},
}
for _, tt := range tests {