Compare commits

..

44 commits

Author SHA1 Message Date
94f3065046
new release
All checks were successful
Lint / lint (push) Successful in 9s
2026-06-03 19:48:10 -07:00
8ce81de224
Remove color coding from success rate metric 2026-06-03 19:34:25 -07:00
61cf83ad7a
new docker image location
All checks were successful
Lint / lint (push) Successful in 12s
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:33:49 -07:00
859635b45a
update release script
All checks were successful
Lint / lint (push) Successful in 11s
2026-04-23 23:23:34 -07:00
150e914ed4
fix lint
All checks were successful
Lint / lint (push) Successful in 15s
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:16:01 -07:00
80012a7b6a
update action versions to latest
Some checks failed
Lint / lint (push) Failing after 24s
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:00:49 -07:00
d96dfc24f2
ci: migrate workflows from GitHub Actions to Forgejo 2026-04-23 20:25:44 -07:00
dc9c1c9787
new version 2026-04-12 22:55:49 -07:00
8574172b5b
fix lint 2026-04-12 22:55:14 -07:00
96a2e2beac
UI tweaks via impeccable 2026-04-12 22:52:32 -07:00
dd9f5eee28
fix lint 2026-04-10 18:47:58 -07:00
5045bbfb3f
fix: dashboard.js bug fixes and cleanup, bump go to 1.26 2026-04-10 18:44:56 -07:00
f335be0086 Allow iframe embedding 2026-03-29 22:01:50 -07:00
4ca05c0fdd adding docker health check 2026-03-28 22:08:39 -07:00
8c929c925b remove traffic field, update to go v1.26 2026-03-27 19:17:10 -07:00
69f57ef2e3 watchtower touch ups in docker compose 2026-02-10 16:51:28 -08:00
5f5d54001b fix dockerfile 2026-02-09 23:39:07 -08:00
7bf4b4e757 add biome for linting, watchtower for automatic snowflake updates 2026-02-09 23:30:16 -08:00
103397e606 better tooltips 2026-02-02 23:40:17 -08:00
13e63ca1b2 update screenshots, minor styling updates 2026-02-02 18:04:05 -08:00
57b6e541f3 adding tooltips, shortening labels, style touchups 2026-02-02 16:04:09 -08:00
b01978a060 update screenshots 2026-02-02 15:31:32 -08:00
5b65b10804 major page re-structuring and restyling to better portray the data 2026-02-02 14:42:16 -08:00
379fb7406a one more round of UI touchups 2026-02-02 00:54:32 -08:00
36e0401fc5 more styling touchups 2026-02-01 21:54:59 -08:00
dd291fe901 rename to dashboard server 2026-02-01 21:32:46 -08:00
933bf78678 run dev with air, style touchups, update dev data 2026-02-01 21:25:24 -08:00
9aadd877ca fix lint, update CI versions 2026-02-01 16:48:05 -08:00
9abddf30b7 clean up code, automatically substitute release version 2026-02-01 16:41:18 -08:00
7170a52e45 use correct image 2026-01-31 16:37:23 -08:00
b47bf132ec fix lint 2026-01-31 16:30:20 -08:00
5d9934a860 rewrite in go for better efficiency than bun 2026-01-31 16:28:50 -08:00
0385d8cf20 v1.4.0 release 2026-01-31 03:45:44 -08:00
2a9f7f09fe different debian version 2026-01-31 03:21:21 -08:00
97cd90480b fix docker path 2026-01-31 03:09:23 -08:00
a819f450ac release v1.4.0 2026-01-31 02:56:54 -08:00
3ee426bac9 bunify 2026-01-31 02:53:47 -08:00
6565a230ec try bun 1.3.6 2026-01-31 02:32:52 -08:00
42acd11c3d Revert "test bun docker image"
This reverts commit acf6d540209754a43aa76c5fed0e396ffde0e27b.
2026-01-31 02:21:47 -08:00
43c64945da test bun docker image 2026-01-31 02:11:57 -08:00
dd2950db1e Revert "test bun"
This reverts commit 99026e8737c95c6b400bdec719764f717dfebbac.
2026-01-31 01:38:23 -08:00
dd85d55531 test bun 2026-01-31 01:24:46 -08:00
c8dea45316 update screenshots 2026-01-31 00:43:29 -08:00
ea484ba7a8 better responsiveness and visibility for data fields 2026-01-31 00:14:20 -08:00
26 changed files with 1065 additions and 640 deletions

15
.air.toml Normal file
View file

@ -0,0 +1,15 @@
root = "."
tmp_dir = "tmp"
[build]
bin = "./tmp/main"
cmd = "go build -tags=dev -o ./tmp/main ."
include_ext = ["go", "html", "css", "js"]
exclude_dir = ["tmp", "data", "signal-cli"]
delay = 1000
[log]
time = false
[screen]
clear_on_rebuild = false

View file

@ -0,0 +1,24 @@
name: Lint
on:
push:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.26'
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: latest
- name: Run Biome
run: npx @biomejs/biome@latest check .

View file

@ -0,0 +1,56 @@
name: Build and Push Docker Image
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v6
- name: Get version from VERSION file
id: version
run: echo "VERSION=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Create tag
run: |
git config user.name forgejo-actions
git config user.email forgejo-actions@lonecloud.dev
git tag -a v${{ steps.version.outputs.VERSION }} -m "Release v${{ steps.version.outputs.VERSION }}"
git push origin v${{ steps.version.outputs.VERSION }}
- name: Create Forgejo Release
run: |
curl -s -X POST "https://git.lonecloud.dev/api/v1/repos/${{ github.repository }}/releases" \
-H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v${{ steps.version.outputs.VERSION }}\",\"name\":\"v${{ steps.version.outputs.VERSION }}\",\"generate_release_notes\":true}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Forgejo Container Registry
uses: docker/login-action@v4
with:
registry: git.lonecloud.dev
username: ${{ secrets.FORGEJO_USERNAME }}
password: ${{ secrets.FORGEJO_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
no-cache: true
tags: |
git.lonecloud.dev/lone-cloud/snowflake-dashboard:latest
git.lonecloud.dev/lone-cloud/snowflake-dashboard:v${{ steps.version.outputs.VERSION }}

View file

@ -9,10 +9,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-go@v6
with:
go-version: '1.26'
- run: bun install
- name: golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: latest
- run: bun run check
- name: Run Biome
run: npx @biomejs/biome@latest check .

View file

@ -11,11 +11,11 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Get version from package.json
- name: Get version from VERSION file
id: version
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
run: echo "VERSION=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Create tag
run: |
@ -25,7 +25,7 @@ jobs:
git push origin v${{ steps.version.outputs.VERSION }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.VERSION }}
generate_release_notes: true
@ -44,7 +44,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
push: true

9
.gitignore vendored
View file

@ -1,3 +1,8 @@
.env
node_modules
.DS_Store
.DS_Store
server
*.test
*.out
tmp/main
.agents/
.impeccable.md

9
.golangci.yml Normal file
View file

@ -0,0 +1,9 @@
version: "2"
run:
timeout: 5m
tests: false
linters:
disable:
- errcheck

View file

@ -1,19 +1,27 @@
FROM node:alpine
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache nginx docker-cli
WORKDIR /app
COPY dashboard-server.go .
RUN go build -ldflags="-s -w" -o server dashboard-server.go
COPY nginx.conf /etc/nginx/nginx.conf
COPY index.html /usr/share/nginx/html/index.html
COPY styles.css /usr/share/nginx/html/styles.css
COPY favicon.svg /usr/share/nginx/html/favicon.svg
COPY favicon-dark.svg /usr/share/nginx/html/favicon-dark.svg
COPY logs-server.js /app/logs-server.js
FROM alpine:latest
RUN echo '#!/bin/sh' > /start.sh && \
echo 'nginx' >> /start.sh && \
echo 'cd /app && node logs-server.js' >> /start.sh && \
chmod +x /start.sh
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /app/server
COPY index.html /tmp/index.html
COPY styles.css /app/static/styles.css
COPY dashboard.js /app/static/dashboard.js
COPY favicon.svg /app/static/favicon.svg
COPY favicon-dark.svg /app/static/favicon-dark.svg
COPY fonts/ /app/static/fonts/
COPY VERSION /tmp/VERSION
RUN VERSION=$(cat /tmp/VERSION) && \
sed "s/?v=VERSION/?v=$VERSION/g" /tmp/index.html > /app/static/index.html
EXPOSE 8888
CMD ["/start.sh"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8888/health || exit 1
CMD ["/app/server"]

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
lint:
~/go/bin/golangci-lint run
fix:
gofmt -s -w .
~/go/bin/golangci-lint run --fix
npx @biomejs/biome@latest check --write --unsafe .
dev:
@test -f ~/go/bin/air || (echo "Installing air..." && go install github.com/air-verse/air@latest)
~/go/bin/air
build:
go build -ldflags="-s -w" -o server dashboard-server.go
docker:
docker build -t snowflake-dashboard:latest .
test:
go test -v ./...
release:
curl -s -X POST "https://git.lonecloud.dev/api/v1/repos/lone-cloud/snowflake-dashboard/actions/workflows/release.yml/dispatches" \
-H "Authorization: token $$(cat ~/.config/forgejo-token)" \
-H "Content-Type: application/json" \
-d '{"ref":"master"}'
.PHONY: lint fix check dev build docker test release

View file

@ -1,8 +1,8 @@
# Snowflake Dashboard
A simple [snowflake](https://snowflake.torproject.org/) dashboard to portray runtime metrics.
A simple [snowflake](https://snowflake.torproject.org/) dashboard to portray runtime metrics. Both qumulative and hourly metrics are displayed.
The dashboard is build using data from snowflake's /internal/metrics endpoint to get the totals as well as its docker logs to get its hourly totals.
The dashboard uses data from snowflake's /internal/metrics endpoint and docker logs to display hourly totals. Its Built with Go and Alpine Linux for minimal resource usage (~23MB image, ~3MB RAM).
## Light Mode
@ -31,12 +31,3 @@ Open the dashboard in your browser:
```plaintext
http://localhost:8888
```
## Updating
Pull the latest image and restart:
```bash
docker compose pull
docker compose up -d
```

1
VERSION Normal file
View file

@ -0,0 +1 @@
1.10.3

3
biome.json Normal file
View file

@ -0,0 +1,3 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json"
}

147
dashboard-server.go Normal file
View file

@ -0,0 +1,147 @@
//go:build !dev
package main
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"regexp"
"strings"
"time"
)
var (
dockerClient = &http.Client{
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", "/var/run/docker.sock")
},
},
}
natRegex = regexp.MustCompile(`\bNAT type:\s*([^\r\n]+)\s*$`)
)
func main() {
mux := http.NewServeMux()
fs := http.FileServer(http.Dir("/app/static"))
mux.Handle("/", fs)
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
mux.HandleFunc("/api/nat", handleNAT)
mux.HandleFunc("/api/logs", handleLogs)
mux.HandleFunc("/api/metrics", handleMetrics)
log.Println("Server running on port 8888")
server := &http.Server{
Addr: ":8888",
Handler: addSecurityHeaders(mux),
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
}
log.Fatal(server.ListenAndServe())
}
func getDockerLogs() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost/containers/snowflake-proxy/logs?stdout=true&stderr=true&tail=500", nil)
if err != nil {
return "", err
}
resp, err := dockerClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("docker API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
return string(body), err
}
func handleNAT(w http.ResponseWriter, _ *http.Request) {
output, err := getDockerLogs()
if err != nil {
log.Printf("Failed to fetch logs: %v", err)
http.Error(w, "Logs unavailable", 500)
return
}
lines := strings.Split(output, "\n")
natType := "Unknown"
for i := len(lines) - 1; i >= 0; i-- {
if match := natRegex.FindStringSubmatch(lines[i]); match != nil {
natType = strings.TrimSpace(match[1])
break
}
}
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, natType)
}
func handleLogs(w http.ResponseWriter, _ *http.Request) {
output, err := getDockerLogs()
if err != nil {
log.Printf("Failed to fetch logs: %v", err)
http.Error(w, "Logs unavailable", 500)
return
}
var filtered []string
for _, line := range strings.Split(output, "\n") {
if strings.Contains(line, "In the last") {
filtered = append(filtered, line)
}
}
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, strings.Join(filtered, "\n"))
}
func handleMetrics(w http.ResponseWriter, _ *http.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:9999/internal/metrics", nil)
if err != nil {
log.Printf("Failed to create request: %v", err)
http.Error(w, "Metrics unavailable", 500)
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("Failed to fetch metrics: %v", err)
http.Error(w, "Metrics unavailable", 500)
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
http.Error(w, "Metrics unavailable", resp.StatusCode)
return
}
w.Header().Set("Content-Type", "text/plain")
io.Copy(w, resp.Body)
}
func addSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors *")
next.ServeHTTP(w, r)
})
}

274
dashboard.js Normal file
View file

@ -0,0 +1,274 @@
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
}
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
const regionNames =
typeof Intl !== "undefined" && Intl.DisplayNames
? new Intl.DisplayNames([navigator.language || "en"], { type: "region" })
: null;
function normalizeCode(countryCode) {
if (!countryCode || countryCode === "Unknown") return null;
const normalized = String(countryCode).trim().toUpperCase();
return normalized.length === 2 ? normalized : null;
}
function getCountryName(countryCode) {
const normalized = normalizeCode(countryCode);
if (!normalized) return "Unknown";
try {
return regionNames?.of(normalized) || normalized;
} catch {
return normalized;
}
}
function getFlag(countryCode) {
const normalized = normalizeCode(countryCode);
if (!normalized) return "🌐";
const codePoints = normalized
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function formatKB(kbString) {
const kb = parseFloat(kbString);
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(2)} GB`;
if (kb >= 1024) return `${(kb / 1024).toFixed(2)} MB`;
return `${kb.toFixed(2)} KB`;
}
async function fetchLogs() {
try {
const response = await fetch("/api/logs");
const text = await response.text();
const logDiv = document.getElementById("activity-log");
const lines = text
.split("\n")
.filter((line) => line.includes("In the last"))
.reverse();
if (lines.length === 0) {
logDiv.innerHTML =
'<div class="loading">No activity yet. Check back soon!</div>';
return;
}
const rows = lines
.flatMap((line) => {
const match = line.match(
/(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}).*?(\d+) completed.*?↓ ([\d.]+) KB \(([\d.]+) KB\/s\).*?↑ ([\d.]+) KB \(([\d.]+) KB\/s\)/,
);
if (!match) return [];
const [, timestamp, connections, dlTotal, dlRate, ulTotal, ulRate] =
match;
const utcDate = new Date(`${timestamp.replace(/\//g, "-")}Z`);
const time = utcDate.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const fullDate = utcDate.toLocaleDateString([], {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const isoDate = utcDate.toISOString();
return [
`<tr>
<td><time datetime="${isoDate}" aria-label="${fullDate}">${time}</time></td>
<td>${connections}</td>
<td>${formatKB(dlTotal)} <span class="rate">(${formatKB(dlRate)}/s)</span></td>
<td>${formatKB(ulTotal)} <span class="rate">(${formatKB(ulRate)}/s)</span></td>
</tr>`,
];
})
.join("");
logDiv.innerHTML = `<table class="activity-table" aria-labelledby="activity-heading">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Conns</th>
<th scope="col">Download</th>
<th scope="col">Upload</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>`;
pulse(logDiv);
} catch (_e) {
document.getElementById("activity-log").innerHTML =
'<div class="error">Error loading logs</div>';
}
}
async function fetchNatType() {
try {
const response = await fetch("/api/nat");
const text = await response.text();
const natType = text.trim() || "Unknown";
const el = document.getElementById("nat-type");
const row = document.getElementById("nat-row");
const isUnknown = !natType || natType.toLowerCase() === "unknown";
if (row) row.hidden = isUnknown;
if (el && !isUnknown) {
const capitalized =
natType.charAt(0).toUpperCase() + natType.slice(1).toLowerCase();
el.textContent = capitalized;
const natLower = natType.toLowerCase();
let natDesc = "";
if (natLower === "unrestricted") {
natDesc =
"Address-independent NAT mapping. Compatible with most other NATs and can connect to restricted proxies.";
} else if (natLower === "restricted") {
natDesc =
"Address-dependent NAT mapping. May have limited compatibility with other restricted NATs.";
}
if (natDesc) {
el.title = natDesc;
const descId = "nat-type-desc";
let descEl = document.getElementById(descId);
if (!descEl) {
descEl = document.createElement("span");
descEl.id = descId;
descEl.className = "sr-only";
el.insertAdjacentElement("afterend", descEl);
}
descEl.textContent = natDesc;
el.setAttribute("aria-describedby", descId);
}
}
} catch (_e) {
const el = document.getElementById("nat-type");
const row = document.getElementById("nat-row");
if (row) row.hidden = false;
if (el) el.innerHTML = '<span class="error">Error</span>';
}
}
async function fetchStats() {
try {
const response = await fetch("/api/metrics");
const text = await response.text();
let total = 0;
let timeouts = 0;
let torInbound = 0;
let torOutbound = 0;
let memory = 0;
let startTime = 0;
const countries = {};
text.split("\n").forEach((line) => {
if (line.startsWith("tor_snowflake_proxy_connections_total")) {
const match = line.match(/country="([^"]*)".*?(\d+)$/);
if (match) {
const country = match[1] || "Unknown";
const count = parseInt(match[2], 10);
countries[country] = count;
total += count;
}
} else if (
line.startsWith("tor_snowflake_proxy_connection_timeouts_total")
) {
const match = line.match(/(\d+)$/);
if (match) timeouts = parseInt(match[1], 10);
} else if (
line.startsWith("tor_snowflake_proxy_traffic_inbound_bytes_total")
) {
const match = line.match(/(\d+\.?\d*e?\+?\d*)$/);
if (match) torInbound = parseFloat(match[1]) * 1024;
} else if (
line.startsWith("tor_snowflake_proxy_traffic_outbound_bytes_total")
) {
const match = line.match(/(\d+\.?\d*e?\+?\d*)$/);
if (match) torOutbound = parseFloat(match[1]) * 1024;
} else if (line.startsWith("process_resident_memory_bytes")) {
const match = line.match(/(\d+\.?\d*e?\+?\d*)$/);
if (match) memory = parseFloat(match[1]);
} else if (line.startsWith("process_start_time_seconds")) {
const match = line.match(/(\d+\.?\d*e?\+?\d*)$/);
if (match) startTime = parseFloat(match[1]);
}
});
const attempts = total + timeouts;
const successRate = attempts > 0 ? (total / attempts) * 100 : 0;
const uptime = startTime > 0 ? Date.now() / 1000 - startTime : 0;
document.getElementById("total").textContent = total;
document.getElementById("timeouts").textContent = timeouts;
const rateEl = document.getElementById("success-rate-small");
rateEl.textContent = `${successRate.toFixed(1)}%`;
rateEl.className = "";
document.getElementById("tor-traffic").textContent =
`${formatBytes(torInbound)} / ${formatBytes(torOutbound)}`;
document.getElementById("memory").textContent = formatBytes(memory);
document.getElementById("uptime").textContent = formatUptime(uptime);
pulse(document.getElementById("metrics-stat"));
const table = document.getElementById("countries");
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
Object.entries(countries)
.sort((a, b) => b[1] - a[1])
.forEach(([country, count]) => {
const row = tbody.insertRow();
const displayName = getCountryName(country);
const percentage = total > 0 ? ((count / total) * 100).toFixed(1) : 0;
const countryCell = row.insertCell(0);
const flagSpan = document.createElement("span");
flagSpan.className = "flag";
flagSpan.textContent = getFlag(country);
countryCell.appendChild(flagSpan);
countryCell.appendChild(document.createTextNode(displayName));
row.insertCell(1).textContent = count;
row.insertCell(2).textContent = `${percentage}%`;
});
} catch (_e) {
for (const id of ["total", "timeouts", "tor-traffic", "memory", "uptime"]) {
const el = document.getElementById(id);
if (el) el.innerHTML = '<span class="error">Error</span>';
}
}
}
function pulse(el) {
if (!el) return;
el.classList.remove("refreshed");
// force reflow so the animation restarts
void el.offsetWidth;
el.classList.add("refreshed");
}
fetchStats();
fetchLogs();
fetchNatType();
setInterval(fetchStats, 300000);
setInterval(fetchLogs, 300000);

179
dev-server.go Normal file
View file

@ -0,0 +1,179 @@
//go:build dev
package main
import (
"log"
"net/http"
"os"
)
const mockMetrics = `# HELP tor_snowflake_proxy_connection_timeouts_total The total number of client connection attempts that failed after successful rendezvous. Note that failures can occur for reasons outside of the proxy's control, such as the client's NAT and censorship situation.
# TYPE tor_snowflake_proxy_connection_timeouts_total counter
tor_snowflake_proxy_connection_timeouts_total 1649
# HELP tor_snowflake_proxy_connections_total The total number of successful connections handled by the snowflake proxy
# TYPE tor_snowflake_proxy_connections_total counter
tor_snowflake_proxy_connections_total{country=""} 110
tor_snowflake_proxy_connections_total{country="??"} 11
tor_snowflake_proxy_connections_total{country="AE"} 4
tor_snowflake_proxy_connections_total{country="AM"} 1
tor_snowflake_proxy_connections_total{country="AO"} 1
tor_snowflake_proxy_connections_total{country="AR"} 1
tor_snowflake_proxy_connections_total{country="AT"} 3
tor_snowflake_proxy_connections_total{country="AU"} 9
tor_snowflake_proxy_connections_total{country="AZ"} 1
tor_snowflake_proxy_connections_total{country="BD"} 2
tor_snowflake_proxy_connections_total{country="BE"} 1
tor_snowflake_proxy_connections_total{country="BF"} 2
tor_snowflake_proxy_connections_total{country="BG"} 1
tor_snowflake_proxy_connections_total{country="BR"} 1
tor_snowflake_proxy_connections_total{country="BY"} 3
tor_snowflake_proxy_connections_total{country="CA"} 4
tor_snowflake_proxy_connections_total{country="CD"} 1
tor_snowflake_proxy_connections_total{country="CG"} 1
tor_snowflake_proxy_connections_total{country="CH"} 3
tor_snowflake_proxy_connections_total{country="CI"} 4
tor_snowflake_proxy_connections_total{country="CN"} 15
tor_snowflake_proxy_connections_total{country="CO"} 1
tor_snowflake_proxy_connections_total{country="CY"} 1
tor_snowflake_proxy_connections_total{country="DE"} 11
tor_snowflake_proxy_connections_total{country="DK"} 1
tor_snowflake_proxy_connections_total{country="EG"} 7
tor_snowflake_proxy_connections_total{country="EU"} 1
tor_snowflake_proxy_connections_total{country="FI"} 6
tor_snowflake_proxy_connections_total{country="FR"} 10
tor_snowflake_proxy_connections_total{country="GA"} 3
tor_snowflake_proxy_connections_total{country="GB"} 16
tor_snowflake_proxy_connections_total{country="GH"} 3
tor_snowflake_proxy_connections_total{country="GR"} 2
tor_snowflake_proxy_connections_total{country="HK"} 1
tor_snowflake_proxy_connections_total{country="HN"} 1
tor_snowflake_proxy_connections_total{country="ID"} 1
tor_snowflake_proxy_connections_total{country="IL"} 1
tor_snowflake_proxy_connections_total{country="IN"} 6
tor_snowflake_proxy_connections_total{country="IR"} 169
tor_snowflake_proxy_connections_total{country="IT"} 4
tor_snowflake_proxy_connections_total{country="JP"} 2
tor_snowflake_proxy_connections_total{country="KE"} 5
tor_snowflake_proxy_connections_total{country="KH"} 1
tor_snowflake_proxy_connections_total{country="KR"} 1
tor_snowflake_proxy_connections_total{country="LT"} 1
tor_snowflake_proxy_connections_total{country="LV"} 2
tor_snowflake_proxy_connections_total{country="LY"} 1
tor_snowflake_proxy_connections_total{country="MA"} 32
tor_snowflake_proxy_connections_total{country="MG"} 1
tor_snowflake_proxy_connections_total{country="ML"} 1
tor_snowflake_proxy_connections_total{country="MO"} 2
tor_snowflake_proxy_connections_total{country="MU"} 66
tor_snowflake_proxy_connections_total{country="MW"} 5
tor_snowflake_proxy_connections_total{country="MX"} 3
tor_snowflake_proxy_connections_total{country="MY"} 3
tor_snowflake_proxy_connections_total{country="NG"} 9
tor_snowflake_proxy_connections_total{country="NL"} 2
tor_snowflake_proxy_connections_total{country="PE"} 1
tor_snowflake_proxy_connections_total{country="PH"} 2
tor_snowflake_proxy_connections_total{country="PK"} 1
tor_snowflake_proxy_connections_total{country="PL"} 3
tor_snowflake_proxy_connections_total{country="RE"} 1
tor_snowflake_proxy_connections_total{country="RO"} 3
tor_snowflake_proxy_connections_total{country="RS"} 1
tor_snowflake_proxy_connections_total{country="RU"} 327
tor_snowflake_proxy_connections_total{country="RW"} 1
tor_snowflake_proxy_connections_total{country="SA"} 2
tor_snowflake_proxy_connections_total{country="SD"} 8
tor_snowflake_proxy_connections_total{country="SE"} 2
tor_snowflake_proxy_connections_total{country="SG"} 2
tor_snowflake_proxy_connections_total{country="SK"} 1
tor_snowflake_proxy_connections_total{country="SO"} 1
tor_snowflake_proxy_connections_total{country="TH"} 1
tor_snowflake_proxy_connections_total{country="TM"} 5
tor_snowflake_proxy_connections_total{country="TN"} 60
tor_snowflake_proxy_connections_total{country="TR"} 1
tor_snowflake_proxy_connections_total{country="TW"} 1
tor_snowflake_proxy_connections_total{country="UA"} 5
tor_snowflake_proxy_connections_total{country="UG"} 8
tor_snowflake_proxy_connections_total{country="US"} 946
tor_snowflake_proxy_connections_total{country="VN"} 1
tor_snowflake_proxy_connections_total{country="ZA"} 23
tor_snowflake_proxy_connections_total{country="ZM"} 10
# HELP tor_snowflake_proxy_traffic_inbound_bytes_total The total in bound traffic by the snowflake proxy (KB)
# TYPE tor_snowflake_proxy_traffic_inbound_bytes_total counter
tor_snowflake_proxy_traffic_inbound_bytes_total 2.7179552e+07
# HELP tor_snowflake_proxy_traffic_outbound_bytes_total The total out bound traffic by the snowflake proxy (KB)
# TYPE tor_snowflake_proxy_traffic_outbound_bytes_total counter
tor_snowflake_proxy_traffic_outbound_bytes_total 4.204604e+06
# HELP process_network_receive_bytes_total Number of bytes received by the process over the network
# TYPE process_network_receive_bytes_total counter
process_network_receive_bytes_total 4.1371771232e+10
# HELP process_network_transmit_bytes_total Number of bytes sent by the process over the network
# TYPE process_network_transmit_bytes_total counter
process_network_transmit_bytes_total 4.1382248284e+10
# HELP process_resident_memory_bytes Resident memory size in bytes
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 9.5772672e+07
# HELP process_start_time_seconds Start time of the process since unix epoch in seconds
# TYPE process_start_time_seconds gauge
process_start_time_seconds 1.76985915868e+09
# HELP go_goroutines Number of goroutines that currently exist
# TYPE go_goroutines gauge
go_goroutines 360
# HELP process_open_fds Number of open file descriptors
# TYPE process_open_fds gauge
process_open_fds 45
# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 27268.99
`
const mockLogs = `2026/01/31 12:32:40 In the last 1h0m0s, there were 134 completed successful connections. Traffic Relayed 183457 KB (50.96 KB/s), 37294 KB (10.36 KB/s).
2026/01/31 13:32:40 In the last 1h0m0s, there were 114 completed successful connections. Traffic Relayed 352507 KB (97.92 KB/s), 101225 KB (28.12 KB/s).
2026/01/31 14:32:40 In the last 1h0m0s, there were 106 completed successful connections. Traffic Relayed 857188 KB (238.11 KB/s), 119983 KB (33.33 KB/s).
2026/01/31 15:32:40 In the last 1h0m0s, there were 93 completed successful connections. Traffic Relayed 561858 KB (156.07 KB/s), 119705 KB (33.25 KB/s).
2026/01/31 16:32:40 In the last 1h0m0s, there were 86 completed successful connections. Traffic Relayed 733619 KB (203.78 KB/s), 127004 KB (35.28 KB/s).
2026/01/31 17:32:40 In the last 1h0m0s, there were 128 completed successful connections. Traffic Relayed 597328 KB (165.92 KB/s), 121182 KB (33.66 KB/s).
2026/01/31 18:32:40 In the last 1h0m0s, there were 121 completed successful connections. Traffic Relayed 348280 KB (96.74 KB/s), 59854 KB (16.63 KB/s).
2026/01/31 19:32:40 In the last 1h0m0s, there were 120 completed successful connections. Traffic Relayed 372636 KB (103.51 KB/s), 50047 KB (13.90 KB/s).
2026/01/31 20:32:40 In the last 1h0m0s, there were 113 completed successful connections. Traffic Relayed 474974 KB (131.94 KB/s), 93429 KB (25.95 KB/s).
2026/01/31 21:32:40 In the last 1h0m0s, there were 48 completed successful connections. Traffic Relayed 729185 KB (202.55 KB/s), 92820 KB (25.78 KB/s).
2026/01/31 22:32:40 In the last 1h0m0s, there were 16 completed successful connections. Traffic Relayed 1187745 KB (329.93 KB/s), 113682 KB (31.58 KB/s).
2026/01/31 23:32:40 In the last 1h0m0s, there were 7 completed successful connections. Traffic Relayed 715292 KB (198.69 KB/s), 59830 KB (16.62 KB/s).
2026/02/01 00:32:40 In the last 1h0m0s, there were 8 completed successful connections. Traffic Relayed 517076 KB (143.63 KB/s), 32023 KB (8.90 KB/s).
2026/02/01 01:32:40 In the last 1h0m0s, there were 5 completed successful connections. Traffic Relayed 536223 KB (148.95 KB/s), 35168 KB (9.77 KB/s).
2026/02/01 02:32:40 In the last 1h0m0s, there were 9 completed successful connections. Traffic Relayed 635409 KB (176.50 KB/s), 36176 KB (10.05 KB/s).
2026/02/01 03:32:40 In the last 1h0m0s, there were 12 completed successful connections. Traffic Relayed 602633 KB (167.40 KB/s), 36340 KB (10.09 KB/s).
2026/02/01 04:32:40 In the last 1h0m0s, there were 22 completed successful connections. Traffic Relayed 588310 KB (163.42 KB/s), 42610 KB (11.84 KB/s).
2026/02/01 05:32:40 In the last 1h0m0s, there were 19 completed successful connections. Traffic Relayed 1207049 KB (335.29 KB/s), 87281 KB (24.24 KB/s).
2026/02/01 06:32:40 In the last 1h0m0s, there were 19 completed successful connections. Traffic Relayed 862143 KB (239.48 KB/s), 84283 KB (23.41 KB/s).
2026/02/01 07:32:40 In the last 1h0m0s, there were 25 completed successful connections. Traffic Relayed 994962 KB (276.38 KB/s), 87620 KB (24.34 KB/s).
2026/02/01 08:32:40 In the last 1h0m0s, there were 27 completed successful connections. Traffic Relayed 821964 KB (228.32 KB/s), 105267 KB (29.24 KB/s).
2026/02/01 09:32:40 In the last 1h0m0s, there were 28 completed successful connections. Traffic Relayed 811943 KB (225.54 KB/s), 91503 KB (25.42 KB/s).
2026/02/01 10:32:40 In the last 1h0m0s, there were 36 completed successful connections. Traffic Relayed 1523789 KB (423.27 KB/s), 108411 KB (30.11 KB/s).
2026/02/01 11:32:40 In the last 1h0m0s, there were 19 completed successful connections. Traffic Relayed 1858789 KB (516.33 KB/s), 129278 KB (35.91 KB/s).`
func main() {
fs := http.FileServer(http.Dir("."))
http.Handle("/", fs)
http.HandleFunc("/api/metrics", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(mockMetrics))
})
http.HandleFunc("/api/logs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(mockLogs))
})
http.HandleFunc("/api/nat", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("restricted"))
})
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
log.Printf("Dev server running at http://localhost:%s\n", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}

View file

@ -1,143 +0,0 @@
const mockMetrics = `# HELP tor_snowflake_proxy_connection_timeouts_total The total number of client connection attempts that failed after successful rendezvous
# TYPE tor_snowflake_proxy_connection_timeouts_total counter
tor_snowflake_proxy_connection_timeouts_total 765
# HELP tor_snowflake_proxy_connections_total The total number of successful connections handled by the snowflake proxy
# TYPE tor_snowflake_proxy_connections_total counter
tor_snowflake_proxy_connections_total{country=""} 46
tor_snowflake_proxy_connections_total{country="??"} 1
tor_snowflake_proxy_connections_total{country="AE"} 4
tor_snowflake_proxy_connections_total{country="AU"} 3
tor_snowflake_proxy_connections_total{country="BF"} 3
tor_snowflake_proxy_connections_total{country="BR"} 1
tor_snowflake_proxy_connections_total{country="BY"} 1
tor_snowflake_proxy_connections_total{country="CA"} 4
tor_snowflake_proxy_connections_total{country="CH"} 2
tor_snowflake_proxy_connections_total{country="CI"} 5
tor_snowflake_proxy_connections_total{country="CM"} 1
tor_snowflake_proxy_connections_total{country="CN"} 8
tor_snowflake_proxy_connections_total{country="CV"} 1
tor_snowflake_proxy_connections_total{country="DE"} 8
tor_snowflake_proxy_connections_total{country="DK"} 3
tor_snowflake_proxy_connections_total{country="EG"} 8
tor_snowflake_proxy_connections_total{country="ES"} 6
tor_snowflake_proxy_connections_total{country="FI"} 1
tor_snowflake_proxy_connections_total{country="FR"} 7
tor_snowflake_proxy_connections_total{country="GA"} 1
tor_snowflake_proxy_connections_total{country="GB"} 7
tor_snowflake_proxy_connections_total{country="GM"} 1
tor_snowflake_proxy_connections_total{country="IE"} 1
tor_snowflake_proxy_connections_total{country="IL"} 1
tor_snowflake_proxy_connections_total{country="IN"} 4
tor_snowflake_proxy_connections_total{country="IR"} 84
tor_snowflake_proxy_connections_total{country="KE"} 3
tor_snowflake_proxy_connections_total{country="LT"} 1
tor_snowflake_proxy_connections_total{country="LY"} 1
tor_snowflake_proxy_connections_total{country="MA"} 9
tor_snowflake_proxy_connections_total{country="MU"} 33
tor_snowflake_proxy_connections_total{country="MW"} 1
tor_snowflake_proxy_connections_total{country="NG"} 6
tor_snowflake_proxy_connections_total{country="NL"} 4
tor_snowflake_proxy_connections_total{country="PK"} 1
tor_snowflake_proxy_connections_total{country="PL"} 2
tor_snowflake_proxy_connections_total{country="RU"} 140
tor_snowflake_proxy_connections_total{country="RW"} 1
tor_snowflake_proxy_connections_total{country="SD"} 5
tor_snowflake_proxy_connections_total{country="SG"} 1
tor_snowflake_proxy_connections_total{country="SO"} 1
tor_snowflake_proxy_connections_total{country="TG"} 3
tor_snowflake_proxy_connections_total{country="TM"} 4
tor_snowflake_proxy_connections_total{country="TN"} 21
tor_snowflake_proxy_connections_total{country="TZ"} 1
tor_snowflake_proxy_connections_total{country="UA"} 2
tor_snowflake_proxy_connections_total{country="UG"} 5
tor_snowflake_proxy_connections_total{country="US"} 445
tor_snowflake_proxy_connections_total{country="ZA"} 8
tor_snowflake_proxy_connections_total{country="ZM"} 7
# HELP tor_snowflake_proxy_traffic_inbound_bytes_total The total in bound traffic by the snowflake proxy (KB)
# TYPE tor_snowflake_proxy_traffic_inbound_bytes_total counter
tor_snowflake_proxy_traffic_inbound_bytes_total 3.226803e+06
# HELP tor_snowflake_proxy_traffic_outbound_bytes_total The total out bound traffic by the snowflake proxy (KB)
# TYPE tor_snowflake_proxy_traffic_outbound_bytes_total counter
tor_snowflake_proxy_traffic_outbound_bytes_total 672663
# HELP process_network_receive_bytes_total Number of bytes received by the process over the network
# TYPE process_network_receive_bytes_total counter
process_network_receive_bytes_total 6.066517003e+09
# HELP process_network_transmit_bytes_total Number of bytes sent by the process over the network
# TYPE process_network_transmit_bytes_total counter
process_network_transmit_bytes_total 6.091533673e+09
# HELP process_resident_memory_bytes Resident memory size in bytes
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 1.02674432e+08
# HELP process_start_time_seconds Start time of the process since unix epoch in seconds
# TYPE process_start_time_seconds gauge
process_start_time_seconds 1.76976251641e+09
# HELP go_goroutines Number of goroutines that currently exist
# TYPE go_goroutines gauge
go_goroutines 416
# HELP process_open_fds Number of open file descriptors
# TYPE process_open_fds gauge
process_open_fds 47
# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 5244.22
`;
const server = Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/internal/metrics") {
return new Response(mockMetrics, {
headers: { "Content-Type": "text/plain" },
});
}
if (url.pathname === "/internal/logs") {
const mockLogs = `2026/01/29 19:36:17 In the last 1h0m0s, there were 150 completed successful connections. Traffic Relayed ↓ 358643 KB (99.62 KB/s), ↑ 87018 KB (24.17 KB/s).
2026/01/29 20:36:17 In the last 1h0m0s, there were 137 completed successful connections. Traffic Relayed 705340 KB (195.93 KB/s), 137061 KB (38.07 KB/s).
2026/01/29 21:36:17 In the last 1h0m0s, there were 101 completed successful connections. Traffic Relayed 489758 KB (136.04 KB/s), 106948 KB (29.71 KB/s).
2026/01/29 22:36:17 In the last 1h0m0s, there were 34 completed successful connections. Traffic Relayed 571290 KB (158.69 KB/s), 113590 KB (31.55 KB/s).
2026/01/29 23:36:17 In the last 1h0m0s, there were 31 completed successful connections. Traffic Relayed 565185 KB (157.00 KB/s), 43956 KB (12.21 KB/s).
2026/01/30 00:36:17 In the last 1h0m0s, there were 8 completed successful connections. Traffic Relayed 209866 KB (58.30 KB/s), 86463 KB (24.02 KB/s).
2026/01/30 01:36:17 In the last 1h0m0s, there were 6 completed successful connections. Traffic Relayed 150602 KB (41.83 KB/s), 72347 KB (20.10 KB/s).
2026/01/30 02:36:17 In the last 1h0m0s, there were 5 completed successful connections. Traffic Relayed 111978 KB (31.11 KB/s), 62467 KB (17.35 KB/s).
2026/01/30 03:36:17 In the last 1h0m0s, there were 8 completed successful connections. Traffic Relayed 66912 KB (18.59 KB/s), 50987 KB (14.16 KB/s).
2026/01/30 04:36:17 In the last 1h0m0s, there were 11 completed successful connections. Traffic Relayed 307124 KB (85.31 KB/s), 39730 KB (11.04 KB/s).
2026/01/30 05:36:17 In the last 1h0m0s, there were 18 completed successful connections. Traffic Relayed 219477 KB (60.97 KB/s), 23241 KB (6.46 KB/s).
2026/01/30 06:36:17 In the last 1h0m0s, there were 13 completed successful connections. Traffic Relayed 483875 KB (134.41 KB/s), 52759 KB (14.66 KB/s).
2026/01/30 07:36:17 In the last 1h0m0s, there were 18 completed successful connections. Traffic Relayed 608490 KB (169.03 KB/s), 75063 KB (20.85 KB/s).
2026/01/30 08:36:17 In the last 1h0m0s, there were 22 completed successful connections. Traffic Relayed 585820 KB (162.73 KB/s), 95537 KB (26.54 KB/s).
2026/01/30 09:41:59 In the last 1h0m0s, there were 51 completed successful connections. Traffic Relayed 528797 KB (146.89 KB/s), 90265 KB (25.07 KB/s).
2026/01/30 10:41:59 In the last 1h0m0s, there were 31 completed successful connections. Traffic Relayed 585162 KB (162.54 KB/s), 92028 KB (25.56 KB/s).
2026/01/30 11:41:59 In the last 1h0m0s, there were 97 completed successful connections. Traffic Relayed 287205 KB (79.78 KB/s), 64850 KB (18.01 KB/s).
2026/01/30 12:41:59 In the last 1h0m0s, there were 105 completed successful connections. Traffic Relayed 154081 KB (42.80 KB/s), 34662 KB (9.63 KB/s).
2026/01/30 13:41:59 In the last 1h0m0s, there were 54 completed successful connections. Traffic Relayed 330043 KB (91.68 KB/s), 58548 KB (16.26 KB/s).
2026/01/30 14:41:59 In the last 1h0m0s, there were 61 completed successful connections. Traffic Relayed 283171 KB (78.66 KB/s), 51404 KB (14.28 KB/s).
2026/01/30 15:41:59 In the last 1h0m0s, there were 75 completed successful connections. Traffic Relayed 232058 KB (64.46 KB/s), 58970 KB (16.38 KB/s).
2026/01/30 16:41:59 In the last 1h0m0s, there were 95 completed successful connections. Traffic Relayed 283368 KB (78.71 KB/s), 82115 KB (22.81 KB/s).
2026/01/30 17:41:59 In the last 1h0m0s, there were 126 completed successful connections. Traffic Relayed 251105 KB (69.75 KB/s), 74885 KB (20.80 KB/s).
2026/01/30 18:41:59 In the last 1h0m0s, there were 110 completed successful connections. Traffic Relayed 291813 KB (81.06 KB/s), 64936 KB (18.04 KB/s).`;
return new Response(mockLogs, {
headers: { "Content-Type": "text/plain" },
});
}
if (url.pathname === "/internal/nat") {
return new Response("restricted", {
headers: { "Content-Type": "text/plain" },
});
}
const filePath = url.pathname === "/" ? "./index.html" : `.${url.pathname}`;
const file = Bun.file(filePath);
if (await file.exists()) {
return new Response(file);
}
return new Response("Not Found", { status: 404 });
},
});
console.log(`Dev server running at http://localhost:${server.port}`);

View file

@ -1,7 +1,7 @@
services:
snowflake-dashboard:
container_name: snowflake-dashboard
image: ghcr.io/lone-cloud/snowflake-dashboard:latest
image: git.lonecloud.dev/lone-cloud/snowflake-dashboard:latest
restart: unless-stopped
network_mode: host
volumes:
@ -14,4 +14,17 @@ services:
image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest
restart: unless-stopped
network_mode: host
# For a full list of Snowflake Proxy CLI parameters see
# https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/tree/main/proxy?ref_type=heads#running-a-standalone-snowflake-proxy
command: ["-metrics", "-metrics-address", "0.0.0.0", "-metrics-port", "9999"]
# Optionally limit ephemeral port range for firewall rules:
# command: ["-metrics", "-metrics-address", "0.0.0.0", "-metrics-port", "9999", "-ephemeral-ports-range", "30000:60000"]
watchtower:
container_name: watchtower
image: nickfedor/watchtower:latest
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: snowflake-proxy snowflake-dashboard

Binary file not shown.

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/lone-cloud/snowflake-dashboard
go 1.26

View file

@ -1,278 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snowflake Dashboard</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg?v=1.3.1" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/svg+xml" href="favicon-dark.svg?v=1.3.1" media="(prefers-color-scheme: dark)">
<link rel="stylesheet" href="styles.css?v=1.3.1">
<link rel="icon" type="image/svg+xml" href="favicon.svg?v=VERSION" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/svg+xml" href="favicon-dark.svg?v=VERSION" media="(prefers-color-scheme: dark)">
<link rel="stylesheet" href="styles.css?v=VERSION">
</head>
<body>
<div class="grid grid-top">
<div class="stat">
<div class="label">Total Connections</div>
<div class="big" id="total">-</div>
</div>
<div class="stat">
<div class="label">Connection Timeouts</div>
<div class="big" id="timeouts">-</div>
</div>
<div class="stat">
<div class="label">Success Rate</div>
<div class="big success-rate" id="success-rate">-</div>
</div>
</div>
<div class="grid grid-bottom">
<div class="left-column">
<div class="stat">
<div class="label">Metrics</div>
<div class="metric-row">
<span>Total Traffic In / Out</span>
<span id="total-traffic">-</span>
<body>
<main>
<div class="grid grid-bottom">
<div class="left-column">
<div class="stat" id="metrics-stat">
<h2 class="label">Metrics</h2>
<div class="metric-row">
<span tabindex="0" role="term" class="metric-label" aria-describedby="tip-success-rate" title="Percentage of successful connections vs total attempts">Success
Rate</span>
<span id="tip-success-rate" class="sr-only">Percentage of successful connections vs total
attempts</span>
<span role="definition" id="success-rate-small">-</span>
</div>
<div class="metric-row">
<span tabindex="0" role="term" class="metric-label" aria-describedby="tip-uptime" title="How long the Snowflake proxy has been running">Uptime</span>
<span id="tip-uptime" class="sr-only">How long the Snowflake proxy has been running</span>
<span role="definition" id="uptime">-</span>
</div>
<div class="metric-row">
<span tabindex="0" role="term" class="metric-label" aria-describedby="tip-relay" title="Data relayed from Tor to clients / from clients to Tor">Relay ↓ /
</span>
<span id="tip-relay" class="sr-only">Data relayed from Tor to clients / from clients to
Tor</span>
<span role="definition" id="tor-traffic">-</span>
</div>
<div class="metric-row" id="nat-row" hidden>
<span tabindex="0" role="term" class="metric-label" aria-describedby="tip-nat" title="Your network's NAT configuration - determines proxy compatibility">NAT Type</span>
<span id="tip-nat" class="sr-only">Your network's NAT configuration - determines proxy
compatibility</span>
<span role="definition" id="nat-type">-</span>
</div>
<div class="metric-row">
<span tabindex="0" role="term" class="metric-label"
aria-describedby="tip-connections" title="Successful client connections completed">Connections</span>
<span id="tip-connections" class="sr-only">Successful client connections completed</span>
<span role="definition" id="total">-</span>
</div>
<div class="metric-row">
<span tabindex="0" role="term" class="metric-label"
aria-describedby="tip-timeouts" title="Connection attempts that failed or timed out">Timeouts</span>
<span id="tip-timeouts" class="sr-only">Connection attempts that failed or timed out</span>
<span role="definition" id="timeouts">-</span>
</div>
<div class="metric-row">
<span tabindex="0" role="term" class="metric-label" aria-describedby="tip-memory" title="Current memory consumption of the proxy process">Memory
Usage</span>
<span id="tip-memory" class="sr-only">Current memory consumption of the proxy process</span>
<span role="definition" id="memory">-</span>
</div>
</div>
<div class="metric-row">
<span>Tor Relay In / Out</span>
<span id="tor-traffic">-</span>
</div>
<div class="metric-row" id="nat-row" hidden>
<span>NAT Type</span>
<span id="nat-type">-</span>
</div>
<div class="metric-row">
<span>Memory Usage</span>
<span id="memory">-</span>
</div>
<div class="metric-row">
<span>Uptime</span>
<span id="uptime">-</span>
<div class="stat">
<h2 class="label" id="countries-heading">Connections by Country</h2>
<div class="table-scroll">
<table id="countries" aria-labelledby="countries-heading">
<thead>
<tr>
<th scope="col">Country</th>
<th scope="col">Count</th>
<th scope="col">%</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="stat">
<div class="label">Recent Activity (Hourly)</div>
<div id="activity-log">
<h2 class="label" id="activity-heading">Recent Activity (Hourly)</h2>
<div id="activity-log" aria-live="polite" aria-atomic="false">
<div class="loading">Loading...</div>
</div>
</div>
</div>
<div class="stat">
<div class="label">Connections by Country</div>
<div class="table-scroll">
<table id="countries">
<tr><th>Country</th><th>Count</th></tr>
</table>
</div>
</div>
</div>
<script>
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
}
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
</main>
const regionNames = (typeof Intl !== 'undefined' && Intl.DisplayNames)
? new Intl.DisplayNames([navigator.language || 'en'], { type: 'region' })
: null;
function getCountryName(countryCode) {
if (!countryCode || countryCode === 'Unknown') return 'Unknown';
const normalized = String(countryCode).trim().toUpperCase();
if (!normalized || normalized.length !== 2) return 'Unknown';
try {
const name = regionNames?.of(normalized);
return name || normalized;
} catch {
return normalized;
}
}
function getFlag(countryCode) {
if (!countryCode || countryCode === 'Unknown') return '🌐';
const normalized = String(countryCode).trim().toUpperCase();
if (normalized.length !== 2) return '🌐';
const codePoints = normalized
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function formatKB(kbString) {
const kb = parseFloat(kbString);
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(2)} GB`;
if (kb >= 1024) return `${(kb / 1024).toFixed(2)} MB`;
return `${kb.toFixed(2)} KB`;
}
async function fetchLogs() {
try {
const response = await fetch('/internal/logs');
const text = await response.text();
const logDiv = document.getElementById('activity-log');
const lines = text.split('\n')
.filter(line => line.includes('In the last'))
.reverse();
if (lines.length === 0) {
logDiv.innerHTML = '<div class="loading">No activity logs available</div>';
return;
}
const rows = lines.map(line => {
const match = line.match(/(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}).*?(\d+) completed.*?↓ ([\d.]+) KB \(([\d.]+) KB\/s\).*?↑ ([\d.]+) KB \(([\d.]+) KB\/s\)/);
if (!match) return '';
const [, timestamp, connections, dlTotal, dlRate, ulTotal, ulRate] = match;
const utcDate = new Date(`${timestamp.replace(/\//g, '-')}Z`);
const time = utcDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const fullDate = utcDate.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
return `<tr>
<td title="${fullDate}">${time}</td>
<td>${connections}</td>
<td>${formatKB(dlTotal)} <span style="color: var(--text-secondary); font-size: 12px;">(${formatKB(dlRate)}/s)</span></td>
<td>${formatKB(ulTotal)} <span style="color: var(--text-secondary); font-size: 12px;">(${formatKB(ulRate)}/s)</span></td>
</tr>`;
}).join('');
logDiv.innerHTML = `<table class="activity-table">
<tr>
<th>Time</th>
<th>Conns</th>
<th>Download</th>
<th>Upload</th>
</tr>
${rows}
</table>`;
} catch (_e) {
document.getElementById('activity-log').innerHTML = '<div class="error">Error loading logs</div>';
}
}
async function fetchNatType() {
try {
const response = await fetch('/internal/nat');
const text = await response.text();
const natType = text.trim() || 'Unknown';
const el = document.getElementById('nat-type');
const row = document.getElementById('nat-row');
const isUnknown = !natType || natType.toLowerCase() === 'unknown';
if (row) row.hidden = isUnknown;
if (el && !isUnknown) {
const capitalized = natType.charAt(0).toUpperCase() + natType.slice(1).toLowerCase();
el.textContent = capitalized;
}
} catch (_e) {
const el = document.getElementById('nat-type');
const row = document.getElementById('nat-row');
if (row) row.hidden = false;
if (el) el.innerHTML = '<span class="error">Error</span>';
}
}
async function fetchStats() {
try {
const response = await fetch('/internal/metrics');
const text = await response.text();
let total = 0;
let timeouts = 0;
let inbound = 0;
let outbound = 0;
let torInbound = 0;
let torOutbound = 0;
let memory = 0;
let startTime = 0;
const countries = {};
text.split('\n').forEach(line => {
if (line.startsWith('tor_snowflake_proxy_connections_total')) {
const match = line.match(/country="([^"]*)".*?(\d+)$/);
if (match) {
const country = match[1] || 'Unknown';
const count = parseInt(match[2], 10);
countries[country] = count;
total += count;
}
} else if (line.startsWith('tor_snowflake_proxy_connection_timeouts_total')) {
const match = line.match(/(\d+)$/);
if (match) timeouts = parseInt(match[1], 10);
} else if (line.startsWith('process_network_receive_bytes_total')) {
const match = line.match(/(\d+\.?\d*e?\+?\d*)$/);
if (match) inbound = parseFloat(match[1]);
} else if (line.startsWith('process_network_transmit_bytes_total')) {
const match = line.match(/(\d+\.?\d*e?\+?\d*)$/);
if (match) outbound = parseFloat(match[1]);
} else if (line.startsWith('tor_snowflake_proxy_traffic_inbound_bytes_total')) {
const match = line.match(/(\d+\.?\d*e?\+?\d*)$/);
if (match) torInbound = parseFloat(match[1]) * 1024;
} else if (line.startsWith('tor_snowflake_proxy_traffic_outbound_bytes_total')) {
const match = line.match(/(\d+\.?\d*e?\+?\d*)$/);
if (match) torOutbound = parseFloat(match[1]) * 1024;
} else if (line.startsWith('process_resident_memory_bytes')) {
const match = line.match(/(\d+\.?\d*e?\+?\d*)$/);
if (match) memory = parseFloat(match[1]);
} else if (line.startsWith('process_start_time_seconds')) {
const match = line.match(/(\d+\.?\d*e?\+?\d*)$/);
if (match) startTime = parseFloat(match[1]);
}
});
const attempts = total + timeouts;
const successRate = attempts > 0 ? ((total / attempts) * 100).toFixed(1) : 0;
const uptime = startTime > 0 ? Date.now() / 1000 - startTime : 0;
document.getElementById('total').textContent = total;
document.getElementById('timeouts').textContent = timeouts;
document.getElementById('success-rate').textContent = `${successRate}%`;
document.getElementById('total-traffic').textContent = `${formatBytes(inbound)} / ${formatBytes(outbound)}`;
document.getElementById('tor-traffic').textContent = `${formatBytes(torInbound)} / ${formatBytes(torOutbound)}`;
document.getElementById('memory').textContent = formatBytes(memory);
document.getElementById('uptime').textContent = formatUptime(uptime);
const table = document.getElementById('countries');
table.innerHTML = '<tr><th>Country</th><th>Count</th></tr>';
Object.entries(countries).sort((a, b) => b[1] - a[1]).forEach(([country, count]) => {
const row = table.insertRow();
const displayName = getCountryName(country);
const countryCell = row.insertCell(0);
const flagSpan = document.createElement('span');
flagSpan.className = 'flag';
flagSpan.textContent = getFlag(country);
countryCell.appendChild(flagSpan);
countryCell.appendChild(document.createTextNode(displayName));
row.insertCell(1).textContent = count;
});
} catch (_e) {
document.getElementById('total').innerHTML = '<span class="error">Error</span>';
}
}
fetchStats();
fetchLogs();
fetchNatType();
setInterval(fetchStats, 300000);
setInterval(fetchLogs, 300000);
</script>
<script src="dashboard.js?v=VERSION"></script>
</body>
</html>
</html>

View file

@ -1,59 +0,0 @@
const http = require("node:http");
const { spawn } = require("node:child_process");
function getPath(req) {
try {
return new URL(req.url, "http://localhost").pathname;
} catch {
return req.url;
}
}
http
.createServer((req, res) => {
const path = getPath(req);
const proc = spawn("docker", ["logs", "--tail", "500", "snowflake-proxy"]);
let output = "";
proc.stdout.on("data", (data) => {
output += data;
});
proc.stderr.on("data", (data) => {
output += data;
});
proc.on("close", () => {
if (path === "/internal/nat") {
const natMatch = output
.split("\n")
.reverse()
.map((line) => line.match(/\bNAT type:\s*([^\r\n]+)\s*$/))
.find(Boolean);
const natType = natMatch?.[1]?.trim() || "Unknown";
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(natType);
return;
}
if (path === "/internal/logs" || path === "/") {
const logs = output
.split("\n")
.filter((line) => line.includes("In the last"))
.join("\n");
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(logs);
return;
}
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
});
proc.on("error", () => {
res.writeHead(500);
res.end("Logs unavailable");
});
})
.listen(3001, () => console.log("Logs server running on port 3001"));

View file

@ -1,45 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
gzip on;
gzip_types text/css application/javascript;
gzip_min_length 256;
server {
listen 8888;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'unsafe-inline' 'self'; style-src 'unsafe-inline' 'self'; connect-src 'self' http://127.0.0.1:3001 http://127.0.0.1:9999" always;
location / {
root /usr/share/nginx/html;
index index.html;
}
location /internal/metrics {
proxy_pass http://127.0.0.1:9999/internal/metrics;
proxy_set_header Host $host;
add_header Access-Control-Allow-Origin *;
}
location /internal/logs {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
add_header Access-Control-Allow-Origin *;
}
location /internal/nat {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
add_header Access-Control-Allow-Origin *;
}
}
}

View file

@ -1,15 +0,0 @@
{
"name": "snowflake-dashboard",
"version": "1.3.1",
"type": "module",
"license": "AGPL-3.0",
"scripts": {
"dev": "bun dev-server.js",
"check": "biome check .",
"fix": "biome check --write --unsafe .",
"release": "gh workflow run release.yml"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -1,55 +1,95 @@
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-hover: #1c2128;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--border-color: #30363d;
--accent-color: #58a6ff;
--success-color: #3fb950;
--error-color: #f85149;
--bottom-grid-height: clamp(43.75rem, 85vh, 62.5rem);
--scrollbar-track: rgba(255, 255, 255, 0.04);
--scrollbar-thumb: rgba(201, 209, 217, 0.22);
--scrollbar-thumb-hover: rgba(201, 209, 217, 0.35);
--bg-primary: oklch(11% 0.013 305);
--bg-secondary: oklch(20% 0.015 305);
--bg-hover: oklch(24% 0.015 305);
--text-primary: oklch(87% 0.009 305);
--text-secondary: oklch(65% 0.011 305);
--border-color: oklch(24% 0.017 305);
--accent-color: oklch(72% 0.18 305);
--success-color: oklch(66% 0.19 145);
--warn-color: oklch(71% 0.16 73);
--error-color: oklch(61% 0.22 22);
--bottom-grid-height: clamp(80vh, 95vh, 100vh);
--scrollbar-track: color-mix(in oklch, var(--text-primary) 8%, transparent);
--scrollbar-thumb: color-mix(in oklch, var(--text-primary) 22%, transparent);
--scrollbar-thumb-hover: color-mix(
in oklch,
var(--text-primary) 35%,
transparent
);
--font-body: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
--text-xs: 0.6875rem;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-md: 1.125rem;
--text-lg: 1.75rem;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
}
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-hover: #f0f3f6;
--text-primary: #24292f;
--text-secondary: #57606a;
--border-color: #d0d7de;
--accent-color: #0969da;
--success-color: #1a7f37;
--error-color: #cf222e;
--scrollbar-track: rgba(0, 0, 0, 0.04);
--scrollbar-thumb: rgba(36, 41, 47, 0.18);
--scrollbar-thumb-hover: rgba(36, 41, 47, 0.3);
--bg-primary: oklch(97.5% 0.007 305);
--bg-secondary: oklch(94% 0.009 305);
--bg-hover: oklch(91% 0.011 305);
--text-primary: oklch(17% 0.013 305);
--text-secondary: oklch(40% 0.013 305);
--border-color: oklch(84% 0.014 305);
--accent-color: oklch(38% 0.18 305);
--success-color: oklch(40% 0.18 145);
--warn-color: oklch(46% 0.16 73);
--error-color: oklch(44% 0.22 22);
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
font-family: var(--font-body);
max-width: 75rem;
margin: 0 auto;
padding: 1rem 2rem;
padding: 1rem;
background: var(--bg-primary);
color: var(--text-primary);
}
header {
margin-bottom: 0.75rem;
padding-bottom: 0.625rem;
border-bottom: 1px solid var(--border-color);
}
h1 {
font-family: var(--font-body);
font-size: var(--text-lg);
font-weight: 600;
color: var(--accent-color);
letter-spacing: 0.04em;
text-transform: uppercase;
margin: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.grid {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
.grid-top {
grid-template-columns: repeat(3, 1fr);
}
.grid-bottom {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
align-items: stretch;
@ -58,44 +98,50 @@ body {
.stat {
background: var(--bg-secondary);
padding: 1.875rem;
padding: 1rem 1.875rem;
border-radius: 0.75rem;
border: 1px solid var(--border-color);
}
.big {
font-size: 3rem;
font-weight: bold;
color: var(--accent-color);
margin-top: 0.625rem;
}
.label {
font-family: var(--font-body);
color: var(--text-secondary);
font-size: 0.875rem;
font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.0625em;
margin-bottom: 0.9375rem;
letter-spacing: 0.08em;
margin: 0 0 var(--space-3) 0;
}
.metric-row {
display: grid;
grid-template-columns: minmax(auto, 11.25rem) auto;
gap: 5rem;
padding: 0.5em 0;
gap: clamp(var(--space-4), 6vw, 7.5rem);
min-height: 1.875rem;
align-items: center;
padding: 0.125rem 0;
border-bottom: 1px solid var(--border-color);
}
.metric-row > span:first-child {
.metric-label {
font: inherit;
color: var(--text-secondary);
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.03125em;
font-size: var(--text-sm);
align-self: stretch;
display: flex;
align-items: center;
}
.metric-row > span:last-child {
.metric-label:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
border-radius: 2px;
}
.metric-row > [role="definition"] {
font-weight: 600;
font-size: 0.9375rem;
font-size: var(--text-base);
font-variant-numeric: tabular-nums;
}
.metric-row:last-child {
@ -156,17 +202,21 @@ table {
th,
td {
padding: 0.75rem;
padding: var(--space-3);
text-align: left;
border-bottom: 1px solid var(--border-color);
font-size: var(--text-base);
}
td {
font-variant-numeric: tabular-nums;
}
th {
background: var(--bg-primary);
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
font-size: var(--text-xs);
}
tr:hover {
@ -181,13 +231,9 @@ tr:last-child td {
color: var(--error-color);
}
.success-rate {
color: var(--success-color);
}
.flag {
font-size: 1.25rem;
margin-right: 0.5rem;
margin-right: var(--space-2);
}
.left-column {
@ -197,8 +243,7 @@ tr:last-child td {
}
#activity-log {
font-family: "Courier New", monospace;
font-size: 0.8125rem;
font-size: var(--text-sm);
line-height: 1.8;
}
@ -208,17 +253,42 @@ tr:last-child td {
.activity-table {
width: 100%;
margin-top: 0.625rem;
font-family: "Courier New", monospace;
font-size: 0.8125rem;
}
.activity-table th {
.activity-table th,
.activity-table td {
font-family: var(--font-body);
}
.activity-table td {
color: var(--text-primary);
font-weight: 600;
font-size: var(--text-base);
}
.activity-table td:first-child {
color: var(--text-secondary);
font-size: 0.6875rem;
text-transform: uppercase;
text-align: left;
padding: 0.5rem 0.25rem;
font-weight: 400;
}
.activity-table .rate {
color: var(--text-secondary);
font-size: var(--text-xs);
display: block;
}
@media (prefers-reduced-motion: no-preference) {
@keyframes data-refresh {
from {
opacity: 0.55;
}
to {
opacity: 1;
}
}
.refreshed {
animation: data-refresh 350ms cubic-bezier(0.25, 0, 0, 1);
}
}
@media (min-width: 769px) {
@ -243,7 +313,14 @@ tr:last-child td {
min-height: 0;
}
.left-column > .stat:last-child #activity-log {
.grid-bottom > .stat:last-child {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.grid-bottom > .stat:last-child #activity-log {
flex: 1;
min-height: 0;
}
@ -264,7 +341,7 @@ tr:last-child td {
@media (max-width: 768px) {
body {
padding: 0.25rem 0.625rem;
padding: 0.625rem;
}
.grid {
@ -273,41 +350,74 @@ tr:last-child td {
}
.left-column {
gap: 0.625rem;
gap: var(--space-2);
}
.grid-bottom {
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
height: auto;
}
.stat {
padding: 0.9375rem;
.left-column {
display: contents;
}
.big {
font-size: 2rem;
.left-column > .stat:first-child {
order: 1;
}
.left-column > .stat:last-child {
order: 3;
}
.grid-bottom > .stat:last-child {
order: 2;
}
.stat {
padding: var(--space-2) var(--space-3);
}
.label {
font-size: 0.75rem;
font-size: var(--text-base);
margin-bottom: var(--space-3);
}
th,
td {
padding: 0.5rem 0.25rem;
font-size: 0.8125rem;
padding: var(--space-2) var(--space-1);
}
#countries td:nth-child(3) {
color: var(--text-secondary);
font-size: var(--text-sm);
}
#countries td,
.activity-table td {
font-size: var(--text-base);
font-weight: 600;
font-family: var(--font-body);
}
.activity-table td:first-child {
font-weight: 400;
}
.metric-row {
flex-wrap: nowrap;
gap: var(--space-4);
}
.metric-row > [role="definition"] {
text-align: right;
}
#activity-log {
max-height: clamp(15.625rem, 35vh, 25rem);
max-height: clamp(50vh, 60vh, 70vh);
}
.table-scroll {
max-height: clamp(21.875rem, 55vh, 34.375rem);
max-height: clamp(45vh, 55vh, 65vh);
}
}