2 Commits
Author SHA1 Message Date
sneak 6c6081b455 feat(frontend): post collected samples to /api/v1/reports (closes #53)
check / check (push) Failing after 1s
A Reporter beside AppState POSTs a JSON delta report to the same-origin
/api/v1/reports every reportInterval (default 60s). buildReport is an
exported pure function of host state, emitting each host's unreported,
non-paused samples plus a per-browser clientId, geo null, and a UTC
timestamp; a per-host high-water mark advances only on a delivered POST,
so a failed send re-sends next interval within the history window.
Failure is quiet and never blocks probing.

The client id feature-detects crypto.randomUUID and otherwise builds a v4
id from crypto.getRandomValues, so insecure-context loads (plain HTTP to a
non-localhost host) work; reporter setup is isolated so it can never stop
probing. init() runs only when the #app page is present, so a test can
import buildReport.

vite.config.js proxies /api to 127.0.0.1:8080 for yarn dev.

Model: opus-4-8
2026-09-21 13:10:55 +00:00
clawbot f3895789d2 feat(backend): server hardening: timeouts, security headers, trusted-proxy client IP (closes #19)
check / check (push) Failing after 1s
Add ReadHeaderTimeout and IdleTimeout to the http.Server as named constants beside the existing timeouts. Add a SecurityHeaders middleware (HSTS, a JSON-API CSP of default-src 'none'; frame-ancestors 'none', X-Frame-Options DENY, nosniff, Referrer-Policy, Permissions-Policy), registered before CORS so preflight responses carry it. Resolve the client IP from X-Forwarded-For / X-Real-IP only when the direct peer is in the trusted-proxy allowlist (loopback plus RFC1918 by default, configurable via TRUSTED_PROXIES); an untrusted peer's forwarded headers are ignored. Uses net/netip; no new dependency.

Model: opus-4-8 (implementation and review); claude-fable-5 (merge)
2026-09-21 15:05:25 +02:00
11 changed files with 539 additions and 20 deletions
+15
View File
@@ -23,6 +23,9 @@ docker build -t netwatch .
docker run -p 8080:8080 netwatch docker run -p 8080:8080 netwatch
``` ```
`yarn dev` proxies `/api` to `http://127.0.0.1:8080`, so a locally running
`netwatch-server` (see `backend/`) receives the reports the page posts.
## Entrypoints ## Entrypoints
This repository adheres to the This repository adheres to the
@@ -71,6 +74,18 @@ code lives in `src/main.js` with a class-based architecture:
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`, - **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
false outage) false outage)
- **`Reporter`**: Posts collected samples to the backend
### Reporting
Every `reportInterval` (default 60s) the page POSTs a JSON report to the
same-origin path `/api/v1/reports`: a random per-browser `clientId` kept in
`localStorage`, `geo` sent as null, and each host's unreported, non-paused
samples (timestamp, latency, error). A per-host high-water mark makes every
report a delta, so only new samples are sent; the mark advances only on a
delivered report, and while paused nothing is sent. Delivery failure is quiet —
one debug-log line per outage, retried at the next interval, never blocking
probing. The report-building step is a pure function of host state.
### Monitoring targets ### Monitoring targets
+10
View File
@@ -22,6 +22,16 @@ files, so merging it also closes most compliance gaps.
# Completed Steps # Completed Steps
- 2026-09-21: frontend reporting client — a `Reporter` class posts collected
samples to `/api/v1/reports` every `reportInterval` (default 60s) as a
per-host delta, with the report-building step a pure exported function of host
state; the per-browser client id works in insecure (plain-HTTP) contexts;
`vite.config.js` proxies `/api` to the local backend for `yarn dev`
- 2026-09-21: backend HTTP hardening (issue #19): added `ReadHeaderTimeout` and
`IdleTimeout` to the server, a `SecurityHeaders` middleware (HSTS, tight CSP,
frame/sniff/referrer/permissions headers) registered before CORS, and
trusted-proxy client IP resolution honouring `X-Forwarded-For` / `X-Real-IP`
only from a `TRUSTED_PROXIES` allowlist (loopback plus RFC1918 by default)
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap - 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
viewports, `#pause-btn`). The pin button's hit area grows via matching viewports, `#pause-btn`). The pin button's hit area grows via matching
+11 -5
View File
@@ -42,11 +42,17 @@ Internal packages in `internal/` follow standard Go project layout:
### Configuration ### Configuration
| Variable | Default | Description | | Variable | Default | Description |
| ---------- | ------------------ | --------------------------------- | | ----------------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
| `PORT` | `8080` | HTTP listen port | | `PORT` | `8080` | HTTP listen port |
| `DATA_DIR` | `./data/reports` | Directory for compressed reports | | `DATA_DIR` | `./data/reports` | Directory for compressed reports |
| `DEBUG` | `false` | Enable debug logging | | `DEBUG` | `false` | Enable debug logging |
| `TRUSTED_PROXIES` | loopback + RFC1918 | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution |
`TRUSTED_PROXIES` defaults to `127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`.
The loopback entries cover the reverse proxy that shares the container; the
RFC1918 ranges match `nginx.conf`. A request whose direct peer is outside this
set has its forwarded headers ignored, and the direct peer is logged instead.
### Report storage ### Report storage
+28
View File
@@ -5,6 +5,7 @@ package config
import ( import (
"errors" "errors"
"log/slog" "log/slog"
"strings"
"sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger" "sneak.berlin/go/netwatch/internal/logger"
@@ -14,6 +15,14 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
) )
// defaultTrustedProxies lists the networks whose forwarded
// headers are honoured by default. It covers the RFC1918
// ranges (to match nginx.conf) plus IPv4 and IPv6 loopback,
// because the reverse proxy shares the container and reaches
// the backend over loopback.
const defaultTrustedProxies = "127.0.0.1/32,::1/128," +
"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
// Params defines the dependencies for Config. // Params defines the dependencies for Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -30,6 +39,7 @@ type Config struct {
MetricsUsername string MetricsUsername string
Port int Port int
SentryDSN string SentryDSN string
TrustedProxies []string
log *slog.Logger log *slog.Logger
params *Params params *Params
} }
@@ -56,6 +66,7 @@ func New(
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("TRUSTED_PROXIES", defaultTrustedProxies)
err := viper.ReadInConfig() err := viper.ReadInConfig()
if err != nil { if err != nil {
@@ -73,6 +84,7 @@ func New(
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
Port: viper.GetInt("PORT"), Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
TrustedProxies: splitList(viper.GetString("TRUSTED_PROXIES")),
log: log, log: log,
params: &params, params: &params,
} }
@@ -84,3 +96,19 @@ func New(
return s, nil return s, nil
} }
// splitList turns a comma-separated setting into a trimmed
// slice, dropping empty entries.
func splitList(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
@@ -0,0 +1,21 @@
package middleware
import (
"net/http"
"net/netip"
)
// Test-only wrappers exposing unexported helpers to the
// external middleware_test package.
func ClientIP(
remoteAddr string,
header http.Header,
trusted []netip.Prefix,
) string {
return clientIP(remoteAddr, header, trusted)
}
func ParseTrustedProxies(cidrs []string) ([]netip.Prefix, error) {
return parseTrustedProxies(cidrs)
}
+130 -3
View File
@@ -3,9 +3,12 @@
package middleware package middleware
import ( import (
"fmt"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/netip"
"strings"
"time" "time"
"sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/config"
@@ -19,6 +22,15 @@ import (
const corsMaxAgeSec = 300 const corsMaxAgeSec = 300
// Security header values. The backend is a JSON API with no
// HTML surface, so the CSP forbids every resource type and
// framing outright.
const (
hstsValue = "max-age=31536000; includeSubDomains"
cspValue = "default-src 'none'; frame-ancestors 'none'"
permissionsPolicyValue = "camera=(), microphone=(), geolocation=()"
)
// Params defines the dependencies for Middleware. // Params defines the dependencies for Middleware.
type Params struct { type Params struct {
fx.In fx.In
@@ -30,8 +42,9 @@ type Params struct {
// Middleware holds shared state for middleware factories. // Middleware holds shared state for middleware factories.
type Middleware struct { type Middleware struct {
log *slog.Logger log *slog.Logger
params *Params params *Params
trustedProxies []netip.Prefix
} }
// New creates a Middleware instance. // New creates a Middleware instance.
@@ -39,13 +52,38 @@ func New(
_ fx.Lifecycle, _ fx.Lifecycle,
params Params, params Params,
) (*Middleware, error) { ) (*Middleware, error) {
trusted, err := parseTrustedProxies(params.Config.TrustedProxies)
if err != nil {
return nil, err
}
s := new(Middleware) s := new(Middleware)
s.params = &params s.params = &params
s.log = params.Logger.Get() s.log = params.Logger.Get()
s.trustedProxies = trusted
return s, nil return s, nil
} }
// parseTrustedProxies converts CIDR strings into prefixes,
// failing fast on any malformed entry.
func parseTrustedProxies(cidrs []string) ([]netip.Prefix, error) {
prefixes := make([]netip.Prefix, 0, len(cidrs))
for _, cidr := range cidrs {
prefix, err := netip.ParsePrefix(cidr)
if err != nil {
return nil, fmt.Errorf(
"trusted proxy %q: %w", cidr, err,
)
}
prefixes = append(prefixes, prefix.Masked())
}
return prefixes, nil
}
type loggingResponseWriter struct { type loggingResponseWriter struct {
http.ResponseWriter http.ResponseWriter
@@ -72,6 +110,70 @@ func ipFromHostPort(hostPort string) string {
return host return host
} }
// clientIP resolves the caller's address. X-Forwarded-For and
// X-Real-IP are honoured only when the direct peer is a
// trusted proxy; otherwise the direct peer is returned so a
// spoofed header cannot forge the logged address.
func clientIP(
remoteAddr string,
header http.Header,
trusted []netip.Prefix,
) string {
peer := ipFromHostPort(remoteAddr)
if !addrInAny(peer, trusted) {
return peer
}
if xff := firstForwardedFor(header.Get("X-Forwarded-For")); xff != "" {
return xff
}
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
return xr
}
return peer
}
// firstForwardedFor returns the left-most valid address in an
// X-Forwarded-For list (the original client), or "" if none.
func firstForwardedFor(value string) string {
for part := range strings.SplitSeq(value, ",") {
candidate := strings.TrimSpace(part)
if validIP(candidate) {
return candidate
}
}
return ""
}
func validIP(s string) bool {
_, err := netip.ParseAddr(s)
return err == nil
}
// addrInAny reports whether s parses as an address contained
// in any of the trusted prefixes.
func addrInAny(s string, trusted []netip.Prefix) bool {
addr, err := netip.ParseAddr(s)
if err != nil {
return false
}
addr = addr.Unmap()
for _, prefix := range trusted {
if prefix.Contains(addr) {
return true
}
}
return false
}
// Logging returns middleware that logs each request with // Logging returns middleware that logs each request with
// timing, status code, and client information. // timing, status code, and client information.
func (s *Middleware) Logging() func(http.Handler) http.Handler { func (s *Middleware) Logging() func(http.Handler) http.Handler {
@@ -96,7 +198,11 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
"referer", r.Referer(), "referer", r.Referer(),
"proto", r.Proto, "proto", r.Proto,
"remote_ip", "remote_ip",
ipFromHostPort(r.RemoteAddr), clientIP(
r.RemoteAddr,
r.Header,
s.trustedProxies,
),
"status", lrw.statusCode, "status", lrw.statusCode,
"latency_ms", "latency_ms",
latency.Milliseconds(), latency.Milliseconds(),
@@ -109,6 +215,27 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
} }
} }
// SecurityHeaders returns middleware that sets response
// security headers. It runs before CORS so the headers are
// present on preflight responses the CORS handler writes.
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Strict-Transport-Security", hstsValue)
h.Set("Content-Security-Policy", cspValue)
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Permissions-Policy", permissionsPolicyValue)
next.ServeHTTP(w, r)
},
)
}
}
// CORS returns middleware that adds permissive CORS headers. // CORS returns middleware that adds permissive CORS headers.
func (s *Middleware) CORS() func(http.Handler) http.Handler { func (s *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ return cors.Handler(cors.Options{
@@ -0,0 +1,139 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"sneak.berlin/go/netwatch/internal/middleware"
)
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
t.Helper()
prefixes, err := middleware.ParseTrustedProxies(cidrs)
if err != nil {
t.Fatalf("ParseTrustedProxies(%v): %v", cidrs, err)
}
return prefixes
}
func TestParseTrustedProxiesRejectsMalformed(t *testing.T) {
t.Parallel()
_, err := middleware.ParseTrustedProxies([]string{"not-a-cidr"})
if err == nil {
t.Fatal("expected error for malformed CIDR, got nil")
}
}
type clientIPCase struct {
name string
remoteAddr string
xff string
xRealIP string
want string
}
func clientIPCases() []clientIPCase {
return []clientIPCase{
{
name: "trusted proxy uses forwarded-for",
remoteAddr: "127.0.0.1:5000",
xff: "203.0.113.7",
want: "203.0.113.7",
},
{
name: "trusted proxy uses left-most of chain",
remoteAddr: "10.1.2.3:5000",
xff: "203.0.113.7, 10.1.2.3",
want: "203.0.113.7",
},
{
name: "trusted proxy falls back to x-real-ip",
remoteAddr: "127.0.0.1:5000",
xRealIP: "203.0.113.9",
want: "203.0.113.9",
},
{
name: "untrusted peer ignores forwarded-for",
remoteAddr: "198.51.100.4:5000",
xff: "203.0.113.7",
want: "198.51.100.4",
},
{
name: "untrusted peer ignores x-real-ip",
remoteAddr: "198.51.100.4:5000",
xRealIP: "203.0.113.9",
want: "198.51.100.4",
},
{
name: "trusted proxy with no headers uses peer",
remoteAddr: "10.1.2.3:5000",
want: "10.1.2.3",
},
{
name: "trusted proxy with garbage header uses peer",
remoteAddr: "127.0.0.1:5000",
xff: "not-an-ip",
want: "127.0.0.1",
},
}
}
func TestClientIP(t *testing.T) {
t.Parallel()
trusted := mustPrefixes(t, "127.0.0.1/32", "::1/128", "10.0.0.0/8")
for _, tc := range clientIPCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
header := http.Header{}
if tc.xff != "" {
header.Set("X-Forwarded-For", tc.xff)
}
if tc.xRealIP != "" {
header.Set("X-Real-IP", tc.xRealIP)
}
got := middleware.ClientIP(tc.remoteAddr, header, trusted)
if got != tc.want {
t.Errorf("ClientIP() = %q, want %q", got, tc.want)
}
})
}
}
func TestSecurityHeaders(t *testing.T) {
t.Parallel()
handler := (&middleware.Middleware{}).SecurityHeaders()(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}),
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
handler.ServeHTTP(rec, req)
want := map[string]string{
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}
for name, value := range want {
if got := rec.Header().Get(name); got != value {
t.Errorf("header %s = %q, want %q", name, got, value)
}
}
}
+12 -8
View File
@@ -8,20 +8,24 @@ import (
) )
const ( const (
readTimeout = 10 * time.Second readTimeout = 10 * time.Second
writeTimeout = 10 * time.Second readHeaderTimeout = 5 * time.Second
maxHeaderBytes = 1 << 20 // 1 MiB writeTimeout = 10 * time.Second
idleTimeout = 60 * time.Second
maxHeaderBytes = 1 << 20 // 1 MiB
) )
func (s *Server) serveUntilShutdown() { func (s *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port) listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
s.httpServer = &http.Server{ s.httpServer = &http.Server{
Addr: listenAddr, Addr: listenAddr,
Handler: s, Handler: s,
MaxHeaderBytes: maxHeaderBytes, MaxHeaderBytes: maxHeaderBytes,
ReadTimeout: readTimeout, ReadTimeout: readTimeout,
WriteTimeout: writeTimeout, ReadHeaderTimeout: readHeaderTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
} }
s.SetupRoutes() s.SetupRoutes()
+1
View File
@@ -17,6 +17,7 @@ func (s *Server) SetupRoutes() {
s.router.Use(middleware.Recoverer) s.router.Use(middleware.Recoverer)
s.router.Use(middleware.RequestID) s.router.Use(middleware.RequestID)
s.router.Use(s.mw.Logging()) s.router.Use(s.mw.Logging())
s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.CORS()) s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(requestTimeout)) s.router.Use(middleware.Timeout(requestTimeout))
+165 -4
View File
@@ -7,9 +7,11 @@ import "./styles.css";
// graphMaxLatency — values above it pin to the top of the chart but still // graphMaxLatency — values above it pin to the top of the chart but still
// display their real value in the latency figure. The history buffer holds // display their real value in the latency figure. The history buffer holds
// maxHistoryPoints samples (historyDuration / updateInterval). // maxHistoryPoints samples (historyDuration / updateInterval).
// reportInterval is how often collected samples are POSTed to the backend.
const CONFIG = { const CONFIG = {
updateInterval: 3000, updateInterval: 3000,
maxHistoryPoints: 100, maxHistoryPoints: 100,
reportInterval: 60000,
get historyDuration() { get historyDuration() {
return (this.maxHistoryPoints * this.updateInterval) / 1000; return (this.maxHistoryPoints * this.updateInterval) / 1000;
}, },
@@ -346,6 +348,147 @@ class AppState {
} }
} }
// --- Reporting ---------------------------------------------------------------
// A random UUIDv4. `crypto.randomUUID` exists only in secure contexts
// (HTTPS or localhost); over plain HTTP to any other host — the normal LAN
// deployment — it is undefined, so feature-detect it and otherwise build the
// id from `crypto.getRandomValues`, which is available in insecure contexts.
function randomId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0"));
return (
hex.slice(0, 4).join("") +
"-" +
hex.slice(4, 6).join("") +
"-" +
hex.slice(6, 8).join("") +
"-" +
hex.slice(8, 10).join("") +
"-" +
hex.slice(10, 16).join("")
);
}
// A random id identifying this browser across reports. Generated once and
// kept in localStorage; if storage is unavailable (e.g. private mode) a
// fresh id is used for this session only.
function getClientId() {
const key = "netwatch-client-id";
try {
let id = localStorage.getItem(key);
if (!id) {
id = randomId();
localStorage.setItem(key, id);
}
return id;
} catch {
return randomId();
}
}
// Build the delta report body the backend decodes, plus the new per-host
// high-water marks. Pure function of the passed state: `hosts` is an array
// of { name, url, status, history }, `since` maps a host url to the Unix-ms
// timestamp of the last sample already reported for it, and `now` is a Date.
// Only non-paused samples newer than the mark are included. Returns null
// when no host has an unreported sample.
export function buildReport(hosts, clientId, now, since) {
const reportHosts = [];
const marks = new Map();
for (const host of hosts) {
const mark = since.get(host.url) ?? 0;
const samples = [];
let high = mark;
for (const p of host.history) {
if (p.paused) continue;
if (p.timestamp <= mark) continue;
samples.push({
t: p.timestamp,
latency: p.latency,
error: p.error ?? null,
});
if (p.timestamp > high) high = p.timestamp;
}
if (samples.length === 0) continue;
reportHosts.push({
name: host.name,
url: host.url,
status: host.status,
history: samples,
});
marks.set(host.url, high);
}
if (reportHosts.length === 0) return null;
return {
body: {
clientId,
geo: null,
hosts: reportHosts,
timestamp: now.toISOString(),
},
marks,
};
}
// Periodically POSTs unreported samples to the same-origin backend. Holds
// the per-host high-water marks so each report is a delta; marks only
// advance on a delivered report, so a failed POST simply re-sends those
// samples next interval (bounded by the history window — whatever has since
// fallen out is dropped). Failure is quiet: one debug line per outage, one
// on recovery, never an alert, never a tight retry loop.
class Reporter {
constructor(state, clientId, intervalMs) {
this.state = state;
this.clientId = clientId;
this.intervalMs = intervalMs;
this.marks = new Map();
this.failing = false;
this.timerId = null;
}
start() {
if (this.timerId) return;
this.timerId = setInterval(() => this.flush(), this.intervalMs);
}
async flush() {
if (this.state.paused) return;
const report = buildReport(
this.state.allHosts,
this.clientId,
new Date(),
this.marks,
);
if (!report) return;
try {
const resp = await fetch("/api/v1/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
body: JSON.stringify(report.body),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
for (const [url, t] of report.marks) this.marks.set(url, t);
if (this.failing) {
log.debug("Report delivery recovered");
this.failing = false;
}
} catch (err) {
if (!this.failing) {
log.debug(`Report delivery failed: ${err.message}`);
this.failing = true;
}
}
}
}
// --- Latency Measurement ----------------------------------------------------- // --- Latency Measurement -----------------------------------------------------
async function measureLatency(url) { async function measureLatency(url) {
@@ -1162,6 +1305,19 @@ async function init() {
buildUI(state); buildUI(state);
log.info("UI built, starting tick loop"); log.info("UI built, starting tick loop");
// Reporting is best-effort: any failure setting it up (e.g. no usable
// crypto for the client id) must never stop the monitor from probing.
try {
const reporter = new Reporter(
state,
getClientId(),
CONFIG.reportInterval,
);
reporter.start();
} catch (err) {
log.error(`Reporting disabled: ${err.message}`);
}
document document
.getElementById("pause-btn") .getElementById("pause-btn")
.addEventListener("click", () => togglePause(state)); .addEventListener("click", () => togglePause(state));
@@ -1274,8 +1430,13 @@ async function init() {
setTimeout(() => handleResize(state), 100); setTimeout(() => handleResize(state), 100);
} }
if (document.readyState === "loading") { // Bootstrap only when loaded as the page: a real DOM containing the #app
document.addEventListener("DOMContentLoaded", init); // mount point this module renders into. Importing the module in a unit test
} else { // (which has no #app) runs nothing, so buildReport can be tested in isolation.
init(); if (typeof document !== "undefined" && document.getElementById("app")) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
} }
+7
View File
@@ -7,6 +7,13 @@ const commitFull = execSync("git rev-parse HEAD").toString().trim();
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
server: {
// Proxy /api to a locally running netwatch-server so `yarn dev`
// exercises the real report-posting path.
proxy: {
"/api": "http://127.0.0.1:8080",
},
},
define: { define: {
__COMMIT_HASH__: JSON.stringify(commitHash), __COMMIT_HASH__: JSON.stringify(commitHash),
__COMMIT_FULL__: JSON.stringify(commitFull), __COMMIT_FULL__: JSON.stringify(commitFull),