Compare commits

..

No commits in common. "master" and "v1.3.1" have entirely different histories.

26 changed files with 641 additions and 1066 deletions

View file

@ -1,15 +0,0 @@
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

@ -1,24 +0,0 @@
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

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

View file

@ -11,11 +11,11 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Get version from VERSION file
- name: Get version from package.json
id: version
run: echo "VERSION=$(cat VERSION)" >> $GITHUB_OUTPUT
run: echo "VERSION=$(node -p "require('./package.json').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@v2
uses: softprops/action-gh-release@v1
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@v6
uses: docker/build-push-action@v5
with:
context: .
push: true

7
.gitignore vendored
View file

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

View file

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

View file

@ -1,27 +1,19 @@
FROM golang:1.26-alpine AS builder
FROM node:alpine
WORKDIR /app
COPY dashboard-server.go .
RUN go build -ldflags="-s -w" -o server dashboard-server.go
RUN apk add --no-cache nginx docker-cli
FROM alpine:latest
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
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
RUN echo '#!/bin/sh' > /start.sh && \
echo 'nginx' >> /start.sh && \
echo 'cd /app && node logs-server.js' >> /start.sh && \
chmod +x /start.sh
EXPOSE 8888
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8888/health || exit 1
CMD ["/app/server"]
CMD ["/start.sh"]

View file

@ -1,28 +0,0 @@
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. Both qumulative and hourly metrics are displayed.
A simple [snowflake](https://snowflake.torproject.org/) dashboard to portray runtime metrics.
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).
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.
## Light Mode
@ -31,3 +31,12 @@ 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
```

View file

@ -1 +0,0 @@
1.10.3

View file

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

View file

@ -1,147 +0,0 @@
//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)
})
}

View file

@ -1,274 +0,0 @@
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);

View file

@ -1,179 +0,0 @@
//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))
}

143
dev-server.js Normal file
View file

@ -0,0 +1,143 @@
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: git.lonecloud.dev/lone-cloud/snowflake-dashboard:latest
image: ghcr.io/lone-cloud/snowflake-dashboard:latest
restart: unless-stopped
network_mode: host
volumes:
@ -14,17 +14,4 @@ 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
View file

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

View file

@ -1,93 +1,278 @@
<!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=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">
<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">
</head>
<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="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="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 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>
</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>
</div>
<div class="stat">
<h2 class="label" id="activity-heading">Recent Activity (Hourly)</h2>
<div id="activity-log" aria-live="polite" aria-atomic="false">
<div class="label">Recent Activity (Hourly)</div>
<div id="activity-log">
<div class="loading">Loading...</div>
</div>
</div>
</div>
</main>
<script src="dashboard.js?v=VERSION"></script>
<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`;
}
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>
</body>
</html>

59
logs-server.js Normal file
View file

@ -0,0 +1,59 @@
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"));

45
nginx.conf Normal file
View file

@ -0,0 +1,45 @@
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 *;
}
}
}

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"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: 20 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -1,95 +1,55 @@
:root {
--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;
--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);
}
@media (prefers-color-scheme: light) {
:root {
--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);
--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);
}
}
body {
font-family: var(--font-body);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
max-width: 75rem;
margin: 0 auto;
padding: 1rem;
padding: 1rem 2rem;
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;
@ -98,50 +58,44 @@ h1 {
.stat {
background: var(--bg-secondary);
padding: 1rem 1.875rem;
padding: 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: var(--text-sm);
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 var(--space-3) 0;
letter-spacing: 0.0625em;
margin-bottom: 0.9375rem;
}
.metric-row {
display: grid;
grid-template-columns: minmax(auto, 11.25rem) auto;
gap: clamp(var(--space-4), 6vw, 7.5rem);
min-height: 1.875rem;
align-items: center;
padding: 0.125rem 0;
gap: 5rem;
padding: 0.5em 0;
border-bottom: 1px solid var(--border-color);
}
.metric-label {
font: inherit;
.metric-row > span:first-child {
color: var(--text-secondary);
font-size: var(--text-sm);
align-self: stretch;
display: flex;
align-items: center;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.03125em;
}
.metric-label:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
border-radius: 2px;
}
.metric-row > [role="definition"] {
.metric-row > span:last-child {
font-weight: 600;
font-size: var(--text-base);
font-variant-numeric: tabular-nums;
font-size: 0.9375rem;
}
.metric-row:last-child {
@ -202,21 +156,17 @@ table {
th,
td {
padding: var(--space-3);
padding: 0.75rem;
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;
font-size: var(--text-xs);
text-transform: uppercase;
font-size: 0.75rem;
}
tr:hover {
@ -231,9 +181,13 @@ tr:last-child td {
color: var(--error-color);
}
.success-rate {
color: var(--success-color);
}
.flag {
font-size: 1.25rem;
margin-right: var(--space-2);
margin-right: 0.5rem;
}
.left-column {
@ -243,7 +197,8 @@ tr:last-child td {
}
#activity-log {
font-size: var(--text-sm);
font-family: "Courier New", monospace;
font-size: 0.8125rem;
line-height: 1.8;
}
@ -253,42 +208,17 @@ 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 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 {
.activity-table th {
color: var(--text-secondary);
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);
}
font-size: 0.6875rem;
text-transform: uppercase;
text-align: left;
padding: 0.5rem 0.25rem;
}
@media (min-width: 769px) {
@ -313,14 +243,7 @@ tr:last-child td {
min-height: 0;
}
.grid-bottom > .stat:last-child {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.grid-bottom > .stat:last-child #activity-log {
.left-column > .stat:last-child #activity-log {
flex: 1;
min-height: 0;
}
@ -341,7 +264,7 @@ tr:last-child td {
@media (max-width: 768px) {
body {
padding: 0.625rem;
padding: 0.25rem 0.625rem;
}
.grid {
@ -350,74 +273,41 @@ tr:last-child td {
}
.left-column {
gap: var(--space-2);
gap: 0.625rem;
}
.grid-bottom {
display: flex;
flex-direction: column;
grid-template-columns: 1fr;
height: auto;
}
.left-column {
display: contents;
}
.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);
padding: 0.9375rem;
}
.big {
font-size: 2rem;
}
.label {
font-size: var(--text-base);
margin-bottom: var(--space-3);
font-size: 0.75rem;
}
th,
td {
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;
padding: 0.5rem 0.25rem;
font-size: 0.8125rem;
}
.metric-row {
gap: var(--space-4);
}
.metric-row > [role="definition"] {
text-align: right;
flex-wrap: nowrap;
}
#activity-log {
max-height: clamp(50vh, 60vh, 70vh);
max-height: clamp(15.625rem, 35vh, 25rem);
}
.table-scroll {
max-height: clamp(45vh, 55vh, 65vh);
max-height: clamp(21.875rem, 55vh, 34.375rem);
}
}