Compare commits

...

18 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
18 changed files with 601 additions and 345 deletions

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,13 +9,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: actions/setup-go@v6 - uses: actions/setup-go@v6
with: with:
go-version: '1.23' go-version: '1.26'
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v7 uses: golangci/golangci-lint-action@v7
with: with:
version: latest version: latest
- name: Run Biome
run: npx @biomejs/biome@latest check .

View file

@ -11,7 +11,7 @@ jobs:
packages: write packages: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Get version from VERSION file - name: Get version from VERSION file
id: version id: version
@ -25,7 +25,7 @@ jobs:
git push origin v${{ steps.version.outputs.VERSION }} git push origin v${{ steps.version.outputs.VERSION }}
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
with: with:
tag_name: v${{ steps.version.outputs.VERSION }} tag_name: v${{ steps.version.outputs.VERSION }}
generate_release_notes: true generate_release_notes: true
@ -44,7 +44,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: true push: true

2
.gitignore vendored
View file

@ -4,3 +4,5 @@ server
*.test *.test
*.out *.out
tmp/main tmp/main
.agents/
.impeccable.md

View file

@ -1,4 +1,4 @@
FROM golang:1.23-alpine AS builder FROM golang:1.26-alpine AS builder
WORKDIR /app WORKDIR /app
COPY dashboard-server.go . COPY dashboard-server.go .
@ -11,13 +11,17 @@ RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /app/server COPY --from=builder /app/server /app/server
COPY index.html /tmp/index.html COPY index.html /tmp/index.html
COPY styles.css /app/static/styles.css COPY styles.css /app/static/styles.css
COPY script.js /app/static/script.js COPY dashboard.js /app/static/dashboard.js
COPY favicon.svg /app/static/favicon.svg COPY favicon.svg /app/static/favicon.svg
COPY favicon-dark.svg /app/static/favicon-dark.svg COPY favicon-dark.svg /app/static/favicon-dark.svg
COPY fonts/ /app/static/fonts/
COPY VERSION /tmp/VERSION COPY VERSION /tmp/VERSION
RUN VERSION=$(cat /tmp/VERSION) && \ RUN VERSION=$(cat /tmp/VERSION) && \
sed "s/?v=VERSION/?v=$VERSION/g" /tmp/index.html > /app/static/index.html sed "s/?v=VERSION/?v=$VERSION/g" /tmp/index.html > /app/static/index.html
EXPOSE 8888 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 ["/app/server"]

View file

@ -1,6 +1,11 @@
lint: lint:
~/go/bin/golangci-lint run ~/go/bin/golangci-lint run
fix:
gofmt -s -w .
~/go/bin/golangci-lint run --fix
npx @biomejs/biome@latest check --write --unsafe .
dev: dev:
@test -f ~/go/bin/air || (echo "Installing air..." && go install github.com/air-verse/air@latest) @test -f ~/go/bin/air || (echo "Installing air..." && go install github.com/air-verse/air@latest)
~/go/bin/air ~/go/bin/air
@ -15,6 +20,9 @@ test:
go test -v ./... go test -v ./...
release: release:
gh workflow run release.yml 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 dev build docker test release .PHONY: lint fix check dev build docker test release

View file

@ -1,6 +1,6 @@
# Snowflake Dashboard # 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 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 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).

View file

@ -1 +1 @@
1.6.3 1.10.3

3
biome.json Normal file
View file

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

View file

@ -30,6 +30,7 @@ func main() {
fs := http.FileServer(http.Dir("/app/static")) fs := http.FileServer(http.Dir("/app/static"))
mux.Handle("/", fs) mux.Handle("/", fs)
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
mux.HandleFunc("/api/nat", handleNAT) mux.HandleFunc("/api/nat", handleNAT)
mux.HandleFunc("/api/logs", handleLogs) mux.HandleFunc("/api/logs", handleLogs)
mux.HandleFunc("/api/metrics", handleMetrics) mux.HandleFunc("/api/metrics", handleMetrics)
@ -137,11 +138,10 @@ func handleMetrics(w http.ResponseWriter, _ *http.Request) {
func addSecurityHeaders(next http.Handler) http.Handler { func addSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") 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'") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors *")
next.ServeHTTP(w, r) 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);

View file

@ -1,7 +1,7 @@
services: services:
snowflake-dashboard: snowflake-dashboard:
container_name: 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 restart: unless-stopped
network_mode: host network_mode: host
volumes: volumes:
@ -14,4 +14,17 @@ services:
image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest image: containers.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake:latest
restart: unless-stopped restart: unless-stopped
network_mode: host 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"] 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.

2
go.mod
View file

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

View file

@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -8,63 +9,85 @@
<link rel="icon" type="image/svg+xml" href="favicon-dark.svg?v=VERSION" media="(prefers-color-scheme: dark)"> <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="stylesheet" href="styles.css?v=VERSION">
</head> </head>
<body> <body>
<main>
<div class="grid grid-bottom"> <div class="grid grid-bottom">
<div class="left-column"> <div class="left-column">
<div class="stat"> <div class="stat" id="metrics-stat">
<div class="label">Metrics</div> <h2 class="label">Metrics</h2>
<div class="metric-row"> <div class="metric-row">
<span title="Percentage of successful connections vs total attempts">Success Rate</span> <span tabindex="0" role="term" class="metric-label" aria-describedby="tip-success-rate" title="Percentage of successful connections vs total attempts">Success
<span class="success-rate" id="success-rate-small">-</span> 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>
<div class="metric-row"> <div class="metric-row">
<span title="How long the Snowflake proxy has been running">Uptime</span> <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="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>
<div class="metric-row"> <div class="metric-row">
<span title="Data relayed from Tor to clients / from clients to Tor">Relay ↓ / ↑</span> <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 id="tor-traffic">-</span> </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>
<div class="metric-row" id="nat-row" hidden> <div class="metric-row" id="nat-row" hidden>
<span title="Your network's NAT configuration - determines proxy compatibility">NAT Type</span> <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="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>
<div class="metric-row"> <div class="metric-row">
<span title="Successful client connections completed">Connections</span> <span tabindex="0" role="term" class="metric-label"
<span id="total">-</span> 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>
<div class="metric-row"> <div class="metric-row">
<span title="Connection attempts that failed or timed out">Timeouts</span> <span tabindex="0" role="term" class="metric-label"
<span id="timeouts">-</span> 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>
<div class="metric-row"> <div class="metric-row">
<span title="Total proxy network traffic received / sent">Traffic ↓ / ↑</span> <span tabindex="0" role="term" class="metric-label" aria-describedby="tip-memory" title="Current memory consumption of the proxy process">Memory
<span id="total-traffic">-</span> Usage</span>
</div> <span id="tip-memory" class="sr-only">Current memory consumption of the proxy process</span>
<div class="metric-row"> <span role="definition" id="memory">-</span>
<span title="Current memory consumption of the proxy process">Memory Usage</span>
<span id="memory">-</span>
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="label">Connections by Country</div> <h2 class="label" id="countries-heading">Connections by Country</h2>
<div class="table-scroll"> <div class="table-scroll">
<table id="countries"> <table id="countries" aria-labelledby="countries-heading">
<tr><th>Country</th><th>Count</th><th>%</th></tr> <thead>
<tr>
<th scope="col">Country</th>
<th scope="col">Count</th>
<th scope="col">%</th>
</tr>
</thead>
<tbody></tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="label">Recent Activity (Hourly)</div> <h2 class="label" id="activity-heading">Recent Activity (Hourly)</h2>
<div id="activity-log"> <div id="activity-log" aria-live="polite" aria-atomic="false">
<div class="loading">Loading...</div> <div class="loading">Loading...</div>
</div> </div>
</div> </div>
</div> </div>
</main>
<script src="script.js?v=VERSION"></script> <script src="dashboard.js?v=VERSION"></script>
</body> </body>
</html> </html>

215
script.js
View file

@ -1,215 +0,0 @@
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('/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.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 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">
<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('/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();
if (natLower === 'unrestricted') {
el.title = 'Address-independent NAT mapping. Compatible with most other NATs and can connect to restricted proxies.';
} else if (natLower === 'restricted') {
el.title = 'Address-dependent NAT mapping. May have limited compatibility with other restricted NATs.';
} else if (natLower === 'unknown') {
el.title = 'NAT type could not be determined. Probe test may have failed or not completed yet.';
}
}
} 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 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-small').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><th>%</th></tr>';
Object.entries(countries).sort((a, b) => b[1] - a[1]).forEach(([country, count]) => {
const row = table.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) {
document.getElementById('total').innerHTML = '<span class="error">Error</span>';
}
}
fetchStats();
fetchLogs();
fetchNatType();
setInterval(fetchStats, 300000);
setInterval(fetchLogs, 300000);

View file

@ -1,38 +1,54 @@
:root { :root {
--bg-primary: #0d1117; --bg-primary: oklch(11% 0.013 305);
--bg-secondary: #161b22; --bg-secondary: oklch(20% 0.015 305);
--bg-hover: #1c2128; --bg-hover: oklch(24% 0.015 305);
--text-primary: #c9d1d9; --text-primary: oklch(87% 0.009 305);
--text-secondary: #a8b3c0; --text-secondary: oklch(65% 0.011 305);
--border-color: #30363d; --border-color: oklch(24% 0.017 305);
--accent-color: #58a6ff; --accent-color: oklch(72% 0.18 305);
--success-color: #3fb950; --success-color: oklch(66% 0.19 145);
--error-color: #f85149; --warn-color: oklch(71% 0.16 73);
--error-color: oklch(61% 0.22 22);
--bottom-grid-height: clamp(80vh, 95vh, 100vh); --bottom-grid-height: clamp(80vh, 95vh, 100vh);
--scrollbar-track: rgba(255, 255, 255, 0.04); --scrollbar-track: color-mix(in oklch, var(--text-primary) 8%, transparent);
--scrollbar-thumb: rgba(201, 209, 217, 0.22); --scrollbar-thumb: color-mix(in oklch, var(--text-primary) 22%, transparent);
--scrollbar-thumb-hover: rgba(201, 209, 217, 0.35); --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) { @media (prefers-color-scheme: light) {
:root { :root {
--bg-primary: #ffffff; --bg-primary: oklch(97.5% 0.007 305);
--bg-secondary: #f6f8fa; --bg-secondary: oklch(94% 0.009 305);
--bg-hover: #f0f3f6; --bg-hover: oklch(91% 0.011 305);
--text-primary: #24292f; --text-primary: oklch(17% 0.013 305);
--text-secondary: #57606a; --text-secondary: oklch(40% 0.013 305);
--border-color: #d0d7de; --border-color: oklch(84% 0.014 305);
--accent-color: #0969da; --accent-color: oklch(38% 0.18 305);
--success-color: #1a7f37; --success-color: oklch(40% 0.18 145);
--error-color: #cf222e; --warn-color: oklch(46% 0.16 73);
--scrollbar-track: rgba(0, 0, 0, 0.04); --error-color: oklch(44% 0.22 22);
--scrollbar-thumb: rgba(36, 41, 47, 0.18);
--scrollbar-thumb-hover: rgba(36, 41, 47, 0.3);
} }
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; font-family: var(--font-body);
max-width: 75rem; max-width: 75rem;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
@ -40,6 +56,34 @@ body {
color: var(--text-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 { .grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@ -60,35 +104,44 @@ body {
} }
.label { .label {
font-family: var(--font-body);
color: var(--text-secondary); color: var(--text-secondary);
font-size: 1rem; font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.0625em; letter-spacing: 0.08em;
margin-bottom: 0.9375rem; margin: 0 0 var(--space-3) 0;
} }
.metric-row { .metric-row {
display: grid; display: grid;
grid-template-columns: minmax(auto, 11.25rem) auto; grid-template-columns: minmax(auto, 11.25rem) auto;
gap: 7.5rem; gap: clamp(var(--space-4), 6vw, 7.5rem);
padding: 0.5rem 0; min-height: 1.875rem;
align-items: center;
padding: 0.125rem 0;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.metric-row > span:first-child { .metric-label {
font: inherit;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.8125rem; font-size: var(--text-sm);
text-transform: uppercase; align-self: stretch;
letter-spacing: 0.03125em; display: flex;
align-items: center;
} }
[title] { .metric-label:focus-visible {
cursor: help; outline: 2px solid var(--accent-color);
outline-offset: 2px;
border-radius: 2px;
} }
.metric-row > span:last-child { .metric-row > [role="definition"] {
font-weight: 600; font-weight: 600;
font-size: 0.9375rem; font-size: var(--text-base);
font-variant-numeric: tabular-nums;
} }
.metric-row:last-child { .metric-row:last-child {
@ -149,18 +202,21 @@ table {
th, th,
td { td {
padding: 0.75rem; padding: var(--space-3);
text-align: left; text-align: left;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-size: 0.9375rem; font-size: var(--text-base);
}
td {
font-variant-numeric: tabular-nums;
} }
th { th {
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 600; font-weight: 600;
text-transform: uppercase; font-size: var(--text-xs);
font-size: 0.75rem;
} }
tr:hover { tr:hover {
@ -175,13 +231,9 @@ tr:last-child td {
color: var(--error-color); color: var(--error-color);
} }
.success-rate {
color: var(--success-color);
}
.flag { .flag {
font-size: 1.25rem; font-size: 1.25rem;
margin-right: 0.5rem; margin-right: var(--space-2);
} }
.left-column { .left-column {
@ -191,7 +243,7 @@ tr:last-child td {
} }
#activity-log { #activity-log {
font-size: 0.8125rem; font-size: var(--text-sm);
line-height: 1.8; line-height: 1.8;
} }
@ -205,13 +257,13 @@ tr:last-child td {
.activity-table th, .activity-table th,
.activity-table td { .activity-table td {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; font-family: var(--font-body);
} }
.activity-table td { .activity-table td {
color: var(--text-primary); color: var(--text-primary);
font-weight: 600; font-weight: 600;
font-size: 0.9375rem; font-size: var(--text-base);
} }
.activity-table td:first-child { .activity-table td:first-child {
@ -221,12 +273,22 @@ tr:last-child td {
.activity-table .rate { .activity-table .rate {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.75rem; font-size: var(--text-xs);
display: block; display: block;
} }
.activity-table tr:nth-child(even) { @media (prefers-reduced-motion: no-preference) {
background: var(--bg-hover); @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) { @media (min-width: 769px) {
@ -288,7 +350,7 @@ tr:last-child td {
} }
.left-column { .left-column {
gap: 0.625rem; gap: var(--space-2);
} }
.grid-bottom { .grid-bottom {
@ -314,29 +376,29 @@ tr:last-child td {
} }
.stat { .stat {
padding: 0.5rem 0.75rem; padding: var(--space-2) var(--space-3);
} }
.label { .label {
font-size: 0.9375rem; font-size: var(--text-base);
margin-bottom: 0.75rem; margin-bottom: var(--space-3);
} }
th, th,
td { td {
padding: 0.5rem 0.25rem; padding: var(--space-2) var(--space-1);
} }
#countries td:nth-child(3) { #countries td:nth-child(3) {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.8125rem; font-size: var(--text-sm);
} }
#countries td, #countries td,
.activity-table td { .activity-table td {
font-size: 0.9375rem; font-size: var(--text-base);
font-weight: 600; font-weight: 600;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; font-family: var(--font-body);
} }
.activity-table td:first-child { .activity-table td:first-child {
@ -344,11 +406,10 @@ tr:last-child td {
} }
.metric-row { .metric-row {
flex-wrap: nowrap; gap: var(--space-4);
gap: 1rem;
} }
.metric-row > span:last-child { .metric-row > [role="definition"] {
text-align: right; text-align: right;
} }