Compare commits

...

10 commits

Author SHA1 Message Date
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
12 changed files with 358 additions and 149 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 }}

2
.gitignore vendored
View file

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

View file

@ -14,6 +14,7 @@ COPY styles.css /app/static/styles.css
COPY dashboard.js /app/static/dashboard.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

View file

@ -20,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 fix check dev build docker test release .PHONY: lint fix check dev build docker test release

View file

@ -1 +1 @@
1.9.1 1.10.2

View file

@ -1,7 +1,7 @@
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes === 0) return "0 B"; if (bytes === 0) return "0 B";
const k = 1024; const k = 1024;
const sizes = ["B", "KB", "MB", "GB"]; const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
} }
@ -20,23 +20,25 @@ const regionNames =
? new Intl.DisplayNames([navigator.language || "en"], { type: "region" }) ? new Intl.DisplayNames([navigator.language || "en"], { type: "region" })
: null; : null;
function getCountryName(countryCode) { function normalizeCode(countryCode) {
if (!countryCode || countryCode === "Unknown") return "Unknown"; if (!countryCode || countryCode === "Unknown") return null;
const normalized = String(countryCode).trim().toUpperCase(); const normalized = String(countryCode).trim().toUpperCase();
if (!normalized || normalized.length !== 2) return "Unknown"; return normalized.length === 2 ? normalized : null;
}
function getCountryName(countryCode) {
const normalized = normalizeCode(countryCode);
if (!normalized) return "Unknown";
try { try {
const name = regionNames?.of(normalized); return regionNames?.of(normalized) || normalized;
return name || normalized;
} catch { } catch {
return normalized; return normalized;
} }
} }
function getFlag(countryCode) { function getFlag(countryCode) {
if (!countryCode || countryCode === "Unknown") return "🌐"; const normalized = normalizeCode(countryCode);
const normalized = String(countryCode).trim().toUpperCase(); if (!normalized) return "🌐";
if (normalized.length !== 2) return "🌐";
const codePoints = normalized const codePoints = normalized
.split("") .split("")
.map((char) => 127397 + char.charCodeAt(0)); .map((char) => 127397 + char.charCodeAt(0));
@ -68,11 +70,11 @@ async function fetchLogs() {
} }
const rows = lines const rows = lines
.map((line) => { .flatMap((line) => {
const match = line.match( 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\)/, /(\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 ""; if (!match) return [];
const [, timestamp, connections, dlTotal, dlRate, ulTotal, ulRate] = const [, timestamp, connections, dlTotal, dlRate, ulTotal, ulRate] =
match; match;
@ -90,24 +92,32 @@ async function fetchLogs() {
second: "2-digit", second: "2-digit",
}); });
return `<tr> const isoDate = utcDate.toISOString();
<td title="${fullDate}">${time}</td> return [
`<tr>
<td><time datetime="${isoDate}" aria-label="${fullDate}">${time}</time></td>
<td>${connections}</td> <td>${connections}</td>
<td>${formatKB(dlTotal)} <span class="rate">(${formatKB(dlRate)}/s)</span></td> <td>${formatKB(dlTotal)} <span class="rate">(${formatKB(dlRate)}/s)</span></td>
<td>${formatKB(ulTotal)} <span class="rate">(${formatKB(ulRate)}/s)</span></td> <td>${formatKB(ulTotal)} <span class="rate">(${formatKB(ulRate)}/s)</span></td>
</tr>`; </tr>`,
];
}) })
.join(""); .join("");
logDiv.innerHTML = `<table class="activity-table"> logDiv.innerHTML = `<table class="activity-table" aria-labelledby="activity-heading">
<tr> <thead>
<th>Time</th> <tr>
<th>Conns</th> <th scope="col">Time</th>
<th>Download</th> <th scope="col">Conns</th>
<th>Upload</th> <th scope="col">Download</th>
</tr> <th scope="col">Upload</th>
${rows} </tr>
</thead>
<tbody>
${rows}
</tbody>
</table>`; </table>`;
pulse(logDiv);
} catch (_e) { } catch (_e) {
document.getElementById("activity-log").innerHTML = document.getElementById("activity-log").innerHTML =
'<div class="error">Error loading logs</div>'; '<div class="error">Error loading logs</div>';
@ -130,15 +140,26 @@ async function fetchNatType() {
el.textContent = capitalized; el.textContent = capitalized;
const natLower = natType.toLowerCase(); const natLower = natType.toLowerCase();
let natDesc = "";
if (natLower === "unrestricted") { if (natLower === "unrestricted") {
el.title = natDesc =
"Address-independent NAT mapping. Compatible with most other NATs and can connect to restricted proxies."; "Address-independent NAT mapping. Compatible with most other NATs and can connect to restricted proxies.";
} else if (natLower === "restricted") { } else if (natLower === "restricted") {
el.title = natDesc =
"Address-dependent NAT mapping. May have limited compatibility with other restricted NATs."; "Address-dependent NAT mapping. May have limited compatibility with other restricted NATs.";
} else if (natLower === "unknown") { }
el.title = if (natDesc) {
"NAT type could not be determined. Probe test may have failed or not completed yet."; 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) { } catch (_e) {
@ -196,31 +217,34 @@ async function fetchStats() {
}); });
const attempts = total + timeouts; const attempts = total + timeouts;
const successRate = const successRate = attempts > 0 ? (total / attempts) * 100 : 0;
attempts > 0 ? ((total / attempts) * 100).toFixed(1) : 0;
const uptime = startTime > 0 ? Date.now() / 1000 - startTime : 0; const uptime = startTime > 0 ? Date.now() / 1000 - startTime : 0;
document.getElementById("total").textContent = total; document.getElementById("total").textContent = total;
document.getElementById("timeouts").textContent = timeouts; document.getElementById("timeouts").textContent = timeouts;
const rateEl = document.getElementById("success-rate-small"); const rateEl = document.getElementById("success-rate-small");
rateEl.textContent = `${successRate}%`; const statusLabel =
successRate >= 70 ? "Good" : successRate >= 40 ? "Warning" : "Critical";
rateEl.innerHTML = `${successRate.toFixed(1)}% <span class="sr-only">(${statusLabel})</span>`;
rateEl.className = rateEl.className =
successRate >= 85 successRate >= 70
? "success-rate" ? "success-rate"
: successRate >= 60 : successRate >= 40
? "warn-rate" ? "warn-rate"
: "error"; : "error";
document.getElementById("tor-traffic").textContent = document.getElementById("tor-traffic").textContent =
`${formatBytes(torInbound)} / ${formatBytes(torOutbound)}`; `${formatBytes(torInbound)} / ${formatBytes(torOutbound)}`;
document.getElementById("memory").textContent = formatBytes(memory); document.getElementById("memory").textContent = formatBytes(memory);
document.getElementById("uptime").textContent = formatUptime(uptime); document.getElementById("uptime").textContent = formatUptime(uptime);
pulse(document.getElementById("metrics-stat"));
const table = document.getElementById("countries"); const table = document.getElementById("countries");
table.innerHTML = "<tr><th>Country</th><th>Count</th><th>%</th></tr>"; const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
Object.entries(countries) Object.entries(countries)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.forEach(([country, count]) => { .forEach(([country, count]) => {
const row = table.insertRow(); const row = tbody.insertRow();
const displayName = getCountryName(country); const displayName = getCountryName(country);
const percentage = total > 0 ? ((count / total) * 100).toFixed(1) : 0; const percentage = total > 0 ? ((count / total) * 100).toFixed(1) : 0;
@ -235,11 +259,21 @@ async function fetchStats() {
row.insertCell(2).textContent = `${percentage}%`; row.insertCell(2).textContent = `${percentage}%`;
}); });
} catch (_e) { } catch (_e) {
document.getElementById("total").innerHTML = for (const id of ["total", "timeouts", "tor-traffic", "memory", "uptime"]) {
'<span class="error">Error</span>'; 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(); fetchStats();
fetchLogs(); fetchLogs();
fetchNatType(); fetchNatType();

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:

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,59 +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>
<div class="grid grid-bottom"> <main>
<div class="left-column"> <div class="grid grid-bottom">
<div class="stat"> <div class="left-column">
<div class="label">Metrics</div> <div class="stat" id="metrics-stat">
<div class="metric-row"> <h2 class="label">Metrics</h2>
<span title="Percentage of successful connections vs total attempts">Success Rate</span> <div class="metric-row">
<span class="success-rate" id="success-rate-small">-</span> <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" class="success-rate" 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>
<div class="metric-row">
<span title="How long the Snowflake proxy has been running">Uptime</span> <div class="stat">
<span id="uptime">-</span> <h2 class="label" id="countries-heading">Connections by Country</h2>
</div> <div class="table-scroll">
<div class="metric-row"> <table id="countries" aria-labelledby="countries-heading">
<span title="Data relayed from Tor to clients / from clients to Tor">Relay ↓ / ↑</span> <thead>
<span id="tor-traffic">-</span> <tr>
</div> <th scope="col">Country</th>
<div class="metric-row" id="nat-row" hidden> <th scope="col">Count</th>
<span title="Your network's NAT configuration - determines proxy compatibility">NAT Type</span> <th scope="col">%</th>
<span id="nat-type">-</span> </tr>
</div> </thead>
<div class="metric-row"> <tbody></tbody>
<span title="Successful client connections completed">Connections</span> </table>
<span id="total">-</span> </div>
</div>
<div class="metric-row">
<span title="Connection attempts that failed or timed out">Timeouts</span>
<span id="timeouts">-</span>
</div>
<div class="metric-row">
<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="activity-heading">Recent Activity (Hourly)</h2>
<div class="table-scroll"> <div id="activity-log" aria-live="polite" aria-atomic="false">
<table id="countries"> <div class="loading">Loading...</div>
<tr><th>Country</th><th>Count</th><th>%</th></tr>
</table>
</div> </div>
</div> </div>
</div> </div>
</main>
<div class="stat">
<div class="label">Recent Activity (Hourly)</div>
<div id="activity-log">
<div class="loading">Loading...</div>
</div>
</div>
</div>
<script src="dashboard.js?v=VERSION"></script> <script src="dashboard.js?v=VERSION"></script>
</body> </body>
</html> </html>

View file

@ -1,40 +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);
--warn-color: #d29922; --warn-color: oklch(71% 0.16 73);
--error-color: #f85149; --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);
--warn-color: #9a6700; --warn-color: oklch(46% 0.16 73);
--error-color: #cf222e; --error-color: oklch(44% 0.22 22);
--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 { 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;
@ -42,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;
@ -62,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 {
@ -151,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 {
@ -187,7 +241,7 @@ tr:last-child td {
.flag { .flag {
font-size: 1.25rem; font-size: 1.25rem;
margin-right: 0.5rem; margin-right: var(--space-2);
} }
.left-column { .left-column {
@ -197,7 +251,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;
} }
@ -211,13 +265,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 {
@ -227,12 +281,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) {
@ -294,7 +358,7 @@ tr:last-child td {
} }
.left-column { .left-column {
gap: 0.625rem; gap: var(--space-2);
} }
.grid-bottom { .grid-bottom {
@ -320,30 +384,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: font-family: var(--font-body);
-apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
} }
.activity-table td:first-child { .activity-table td:first-child {
@ -351,11 +414,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;
} }