Compare commits
No commits in common. "master" and "v1.3.1" have entirely different histories.
26 changed files with 641 additions and 1066 deletions
15
.air.toml
15
.air.toml
|
|
@ -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
|
||||
|
|
@ -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 .
|
||||
|
|
@ -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 }}
|
||||
14
.github/workflows/lint.yml
vendored
14
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
|
@ -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
7
.gitignore
vendored
|
|
@ -1,8 +1,3 @@
|
|||
.env
|
||||
node_modules
|
||||
.DS_Store
|
||||
server
|
||||
*.test
|
||||
*.out
|
||||
tmp/main
|
||||
.agents/
|
||||
.impeccable.md
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: false
|
||||
|
||||
linters:
|
||||
disable:
|
||||
- errcheck
|
||||
34
Dockerfile
34
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
28
Makefile
28
Makefile
|
|
@ -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
|
||||
13
README.md
13
README.md
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
1
VERSION
1
VERSION
|
|
@ -1 +0,0 @@
|
|||
1.10.3
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json"
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
274
dashboard.js
274
dashboard.js
|
|
@ -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);
|
||||
179
dev-server.go
179
dev-server.go
|
|
@ -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
143
dev-server.js
Normal 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}`);
|
||||
|
|
@ -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
3
go.mod
|
|
@ -1,3 +0,0 @@
|
|||
module github.com/lone-cloud/snowflake-dashboard
|
||||
|
||||
go 1.26
|
||||
331
index.html
331
index.html
|
|
@ -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
59
logs-server.js
Normal 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
45
nginx.conf
Normal 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
15
package.json
Normal 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 |
BIN
screenshot.webp
BIN
screenshot.webp
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 58 KiB |
276
styles.css
276
styles.css
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue