Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c02f6ba62 |
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
title: API Policies
|
|
||||||
last_modified: 2026-10-27
|
|
||||||
---
|
|
||||||
|
|
||||||
A value in an API response is never a string with a label or unit in it. A value
|
|
||||||
of a native type is sent as that type, and its unit goes in the field name, for
|
|
||||||
example `_ms`.
|
|
||||||
|
|
||||||
Wrong:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "execution_time": "0 ms" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Right:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "execution_time_ms": 0 }
|
|
||||||
```
|
|
||||||
+22
-92
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-10-27
|
last_modified: 2026-10-18
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -12,7 +12,6 @@ style conventions are in separate documents:
|
|||||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||||
- [API Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/API_POLICIES.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,32 +174,27 @@ style conventions are in separate documents:
|
|||||||
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
||||||
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
||||||
two gate phases and `script/fmt-check`, and builds no image of its own. In
|
two gate phases and `script/fmt-check`, and builds no image of its own. In
|
||||||
every stage of the `Dockerfile`, every step that lints, tests or compiles
|
every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
|
||||||
comes after `ARG CACHEBUST`. `ARG CACHEBUST` comes right after these steps:
|
that install system packages and download Go's modules, or right after `FROM`
|
||||||
- the steps that install system packages
|
in a stage with neither, and every step that lints, tests or compiles comes
|
||||||
- the steps that download Go's modules
|
after it. A step that installs anything else as well, such as a
|
||||||
- the step that runs `script/bootstrap` to install a Python `test` phase's
|
`script/bootstrap` that also installs node packages, comes after it too. A new
|
||||||
packages from `Pipfile.lock`
|
value reruns every `RUN` step after that line; `COPY` steps are compared by
|
||||||
|
content and need nothing. Docker keeps the layers above the line until the
|
||||||
A stage with none of these steps declares `ARG CACHEBUST` right after
|
base image, their own lines or the files they copy change. A stage without the
|
||||||
`FROM`. A step that installs anything else as well, such as a
|
`ARG` line is served from cache on an unchanged tree, so a repository
|
||||||
`script/bootstrap` that also installs node packages, comes after it too. A
|
re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do
|
||||||
new value reruns every `RUN` step after that line; `COPY` steps are compared
|
not pass `--no-cache`: it reruns every step too, but it also builds the layers
|
||||||
by content and need nothing. Docker keeps the layers above the line until
|
above the line again and empties every cache mount, so Go's build cache and
|
||||||
the base image, their own lines or the files they copy change. A stage
|
golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they
|
||||||
without the `ARG` line is served from cache on an unchanged tree, so a
|
survive between check builds, would start empty every time. A bare
|
||||||
repository re-vendors the scripts and adds the `ARG` lines in one commit.
|
`docker build .` is not evidence that anything ran: a sub-second build
|
||||||
The scripts do not pass `--no-cache`: it reruns every step too, but it also
|
reporting success is a cache hit, not a result. Never invalidate by pruning —
|
||||||
builds the layers above the line again and empties every cache mount, so
|
`docker builder prune` and friends destroy a build cache shared with every
|
||||||
Go's build cache and golangci-lint's, which the canonical Go `Dockerfile`
|
other build on the host. When a check is added or changed, prove it works by
|
||||||
below mounts so that they survive between check builds, would start empty
|
planting a defect it must catch and watching the run fail on it, then revert
|
||||||
every time. A bare `docker build .` is not evidence that anything ran: a
|
the defect. A green run alone shows neither that the check ran nor that it
|
||||||
sub-second build reporting success is a cache hit, not a result. Never
|
covers what it should.
|
||||||
invalidate by pruning — `docker builder prune` and friends destroy a build
|
|
||||||
cache shared with every other build on the host. When a check is added or
|
|
||||||
changed, prove it works by planting a defect it must catch and watching the
|
|
||||||
run fail on it, then revert the defect. A green run alone shows neither that
|
|
||||||
the check ran nor that it covers what it should.
|
|
||||||
|
|
||||||
- **The gate phases are separate stages, and the build stage depends on both.**
|
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||||
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||||
@@ -315,9 +309,6 @@ style conventions are in separate documents:
|
|||||||
a package's findings only when the package's files and those of every
|
a package's findings only when the package's files and those of every
|
||||||
package it imports, `go.mod`, the linters and their settings in
|
package it imports, `go.mod`, the linters and their settings in
|
||||||
`.golangci.yml`, and the golangci-lint version are all unchanged.
|
`.golangci.yml`, and the golangci-lint version are all unchanged.
|
||||||
- In a Python `test` phase, the layer of the step that runs
|
|
||||||
`script/bootstrap` to install the packages from `Pipfile.lock`, as
|
|
||||||
described for that phase below.
|
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
create placeholder files so the embed directives resolve. Example:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
@@ -372,67 +363,6 @@ style conventions are in separate documents:
|
|||||||
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
|
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
|
||||||
do.
|
do.
|
||||||
|
|
||||||
- **A Python `test` phase that installs its packages from `Pipfile.lock` does it
|
|
||||||
above `ARG CACHEBUST`, so check builds keep them.** The steps are
|
|
||||||
`COPY script/ script/`, `COPY Pipfile Pipfile.lock ./` and
|
|
||||||
`RUN script/bootstrap`, with `ARG CACHEBUST` right after them. Docker keeps
|
|
||||||
what that `RUN` installed until the base image, `script/`, `Pipfile`,
|
|
||||||
`Pipfile.lock` or a line up to and including the `RUN` changes. The tests run
|
|
||||||
after `ARG CACHEBUST`, on every check build. In every other stage, a step that
|
|
||||||
installs Python packages comes after `ARG CACHEBUST`.
|
|
||||||
- When `script/bootstrap` runs in that step, it installs nothing beyond
|
|
||||||
system packages, pipenv and its dependencies, and the packages from
|
|
||||||
`Pipfile.lock`. It installs no Python there: the tests use the base
|
|
||||||
image's Python, which the image's hash pins. If it installs anything else
|
|
||||||
there, the step goes after `ARG CACHEBUST`.
|
|
||||||
- Every external reference must be pinned by hash, so `script/bootstrap`
|
|
||||||
installs pipenv and its dependencies by hash. It does so with
|
|
||||||
`pip install --require-hashes -r` and a requirements file in `script/`
|
|
||||||
that lists each of them with `==` and its `--hash`. pip refuses to install
|
|
||||||
a dependency the file does not list.
|
|
||||||
- It then runs `pipenv install --deploy`, with `--dev` when the tests need
|
|
||||||
the development packages. `--deploy` installs only what `Pipfile.lock`
|
|
||||||
names, checks every package against the lockfile's hashes, and fails when
|
|
||||||
the lockfile is out of date with `Pipfile`.
|
|
||||||
- A kept layer holds nothing a new install would refuse: pip checked pipenv
|
|
||||||
and its dependencies, and pipenv checked the lockfile's packages, against
|
|
||||||
their hashes. pipenv creates its virtual environment with the `virtualenv`
|
|
||||||
package. The packages that `virtualenv` adds come from wheels inside that
|
|
||||||
package, so the hash pip checked for it covers them. Two things in the
|
|
||||||
layer are not fixed by a hash. A package with no ready-built wheel for the
|
|
||||||
image is built from source. pip downloads the build tools without a hash
|
|
||||||
check, so a kept layer's build of such a package may differ from a new
|
|
||||||
one. And system packages keep the versions the package manager gave when
|
|
||||||
the layer was built; a new install may get newer ones.
|
|
||||||
- As with Go's modules, the packages are kept in a layer, not a cache mount.
|
|
||||||
A cache mount is shared by every build on the host, and nothing checks its
|
|
||||||
files again.
|
|
||||||
|
|
||||||
In this example, `script/bootstrap` finds `make` and `git` already
|
|
||||||
installed, installs pipenv and its dependencies by hash, and runs
|
|
||||||
`pipenv install --dev --deploy`:
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Test phase. The 90-second timeout covers both pytest runs.
|
|
||||||
# python:3.x-slim, YYYY-MM-DD
|
|
||||||
FROM python@sha256:... AS test
|
|
||||||
# make and git are base tools that script/bootstrap installs when they
|
|
||||||
# are missing. Installed here, they stay in their own layer when
|
|
||||||
# script/ changes.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends make git \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
WORKDIR /app
|
|
||||||
COPY script/ script/
|
|
||||||
COPY Pipfile Pipfile.lock ./
|
|
||||||
RUN script/bootstrap
|
|
||||||
ARG CACHEBUST
|
|
||||||
COPY . .
|
|
||||||
RUN timeout 90 sh -c 'pipenv run pytest || { \
|
|
||||||
echo "--- Rerunning with -v for details ---"; \
|
|
||||||
pipenv run pytest -v; exit 1; }'
|
|
||||||
```
|
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `script/cibuild` on push, and checks out the repo as its only other step,
|
runs `script/cibuild` on push, and checks out the repo as its only other step,
|
||||||
with `persist-credentials: false`: `script/cibuild` needs no token, and
|
with `persist-credentials: false`: `script/cibuild` needs no token, and
|
||||||
|
|||||||
@@ -35,26 +35,6 @@ tested.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-27: README "Rationale" says why RouteWatch exists: to answer our other
|
|
||||||
services' questions about IP addresses, from its own routing table and the
|
|
||||||
WHOIS details it keeps. It still says what RIS Live does not give (closes
|
|
||||||
#204)
|
|
||||||
- 2026-10-27: every JSON answer carries `@meta`. A panic on a JSON route answers
|
|
||||||
500 with the JSON error body. `/status.json` and the health check answer 405
|
|
||||||
with the JSON error body to any method they do not take. The 500 and the 405
|
|
||||||
used to have an empty body. The home page's IP lookup shows the whole JSON
|
|
||||||
answer, `@meta` included. It used to show only the answer's `data`, or only
|
|
||||||
the message of an error answer. A test checks `@meta` on every JSON route, in
|
|
||||||
its successes and its errors (closes #201)
|
|
||||||
- 2026-10-27: every value in a JSON answer that has a unit is a number, with the
|
|
||||||
unit at the end of its field name. `@meta.execution_time` is now
|
|
||||||
`execution_time_ms`. The stats' `uptime`, `connection_duration` and
|
|
||||||
`memory_usage` are now `uptime_seconds`, `connection_duration_seconds` and
|
|
||||||
`memory_usage_bytes`. `whois_stats.current_interval` is now
|
|
||||||
`current_interval_seconds`. They used to be strings such as "12 ms",
|
|
||||||
"1h18m34s" or "1.1 GB". `connection_duration_seconds` is null while the feed
|
|
||||||
is not connected. `API_POLICIES.md` is vendored from `sneak/prompts`, and
|
|
||||||
`REPO_POLICIES.md` re-vendored (closes #199)
|
|
||||||
- 2026-10-27: every result of the IP lookup has the same fields. A field without
|
- 2026-10-27: every result of the IP lookup has the same fields. A field without
|
||||||
a value is null. It used to be left out. An address with no live route has
|
a value is null. It used to be left out. An address with no live route has
|
||||||
every route and AS field set to null, and `ptr` is an empty list when there
|
every route and AS field set to null, and `ptr` is an empty list when there
|
||||||
|
|||||||
+33
-41
@@ -23,6 +23,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/templates"
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
||||||
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -225,23 +226,18 @@ type WHOISStatsInfo struct {
|
|||||||
NeverFetched int `json:"never_fetched"`
|
NeverFetched int `json:"never_fetched"`
|
||||||
SuccessesLastHour int `json:"successes_last_hour"`
|
SuccessesLastHour int `json:"successes_last_hour"`
|
||||||
ErrorsLastHour int `json:"errors_last_hour"`
|
ErrorsLastHour int `json:"errors_last_hour"`
|
||||||
|
CurrentInterval string `json:"current_interval"`
|
||||||
ConsecutiveFails int `json:"consecutive_fails"`
|
ConsecutiveFails int `json:"consecutive_fails"`
|
||||||
FreshPercent float64 `json:"fresh_percent"`
|
FreshPercent float64 `json:"fresh_percent"`
|
||||||
|
|
||||||
// CurrentIntervalSeconds is the wait between two fetches. It can have a
|
|
||||||
// fractional part, such as 22.5.
|
|
||||||
CurrentIntervalSeconds float64 `json:"current_interval_seconds"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats is the /status.json answer: uptime, message counts, database stats,
|
// Stats is the /status.json answer: uptime, message counts, database stats,
|
||||||
// and the routing table's figures. UptimeSeconds is the time since the stream
|
// and the routing table's figures. OldestRoute and NewestRoute are the times
|
||||||
// last connected, or 0 if it has never connected. MemoryUsageBytes is the Go
|
// of the oldest and the newest kept routes.
|
||||||
// heap in use. OldestRoute and NewestRoute are the times of the oldest and the
|
|
||||||
// newest kept routes.
|
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // field names are the existing JSON API
|
//nolint:tagliatelle // field names are the existing JSON API
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
Uptime string `json:"uptime"`
|
||||||
TotalMessages uint64 `json:"total_messages"`
|
TotalMessages uint64 `json:"total_messages"`
|
||||||
TotalBytes uint64 `json:"total_bytes"`
|
TotalBytes uint64 `json:"total_bytes"`
|
||||||
TotalWireBytes uint64 `json:"total_wire_bytes"`
|
TotalWireBytes uint64 `json:"total_wire_bytes"`
|
||||||
@@ -251,7 +247,7 @@ type Stats struct {
|
|||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
GoVersion string `json:"go_version"`
|
GoVersion string `json:"go_version"`
|
||||||
Goroutines int `json:"goroutines"`
|
Goroutines int `json:"goroutines"`
|
||||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
MemoryUsage string `json:"memory_usage"`
|
||||||
ASNs int `json:"asns"`
|
ASNs int `json:"asns"`
|
||||||
Prefixes int `json:"prefixes"`
|
Prefixes int `json:"prefixes"`
|
||||||
IPv4Prefixes int `json:"ipv4_prefixes"`
|
IPv4Prefixes int `json:"ipv4_prefixes"`
|
||||||
@@ -366,7 +362,7 @@ func (s *Server) statusStats(
|
|||||||
runtime.ReadMemStats(&memStats)
|
runtime.ReadMemStats(&memStats)
|
||||||
|
|
||||||
return Stats{
|
return Stats{
|
||||||
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
|
Uptime: streamUptime(streamMetrics.ConnectedSince),
|
||||||
TotalMessages: streamMetrics.TotalMessages,
|
TotalMessages: streamMetrics.TotalMessages,
|
||||||
TotalBytes: streamMetrics.TotalBytes,
|
TotalBytes: streamMetrics.TotalBytes,
|
||||||
TotalWireBytes: streamMetrics.TotalWireBytes,
|
TotalWireBytes: streamMetrics.TotalWireBytes,
|
||||||
@@ -376,7 +372,7 @@ func (s *Server) statusStats(
|
|||||||
Connected: streamMetrics.Connected,
|
Connected: streamMetrics.Connected,
|
||||||
GoVersion: runtime.Version(),
|
GoVersion: runtime.Version(),
|
||||||
Goroutines: runtime.NumGoroutine(),
|
Goroutines: runtime.NumGoroutine(),
|
||||||
MemoryUsageBytes: memStats.Alloc,
|
MemoryUsage: humanize.Bytes(memStats.Alloc),
|
||||||
ASNs: dbStats.ASNs,
|
ASNs: dbStats.ASNs,
|
||||||
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
|
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
|
||||||
IPv4Prefixes: counts.IPv4Prefixes,
|
IPv4Prefixes: counts.IPv4Prefixes,
|
||||||
@@ -443,31 +439,30 @@ func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo {
|
|||||||
NeverFetched: dbStats.NeverFetched,
|
NeverFetched: dbStats.NeverFetched,
|
||||||
SuccessesLastHour: fetcherStats.SuccessesLastHour,
|
SuccessesLastHour: fetcherStats.SuccessesLastHour,
|
||||||
ErrorsLastHour: fetcherStats.ErrorsLastHour,
|
ErrorsLastHour: fetcherStats.ErrorsLastHour,
|
||||||
|
CurrentInterval: fetcherStats.CurrentInterval.String(),
|
||||||
ConsecutiveFails: fetcherStats.ConsecutiveFails,
|
ConsecutiveFails: fetcherStats.ConsecutiveFails,
|
||||||
FreshPercent: freshPercent,
|
FreshPercent: freshPercent,
|
||||||
|
|
||||||
CurrentIntervalSeconds: fetcherStats.CurrentInterval.Seconds(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// uptimeSeconds is the number of whole seconds since the stream last
|
// streamUptime is how long the stream has been connected, to the second, or
|
||||||
// connected, or 0 if it has never connected.
|
// "0s" if it has never connected.
|
||||||
func uptimeSeconds(connectedSince time.Time) int64 {
|
func streamUptime(connectedSince time.Time) string {
|
||||||
if connectedSince.IsZero() {
|
if connectedSince.IsZero() {
|
||||||
return 0
|
return "0s"
|
||||||
}
|
}
|
||||||
|
|
||||||
return int64(time.Since(connectedSince) / time.Second)
|
return time.Since(connectedSince).Truncate(time.Second).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectionDurationSeconds is the number of whole seconds the stream has
|
// connectionDuration is how long the stream has been connected, to the
|
||||||
// been connected, or nil while it is not connected.
|
// second, or "disconnected".
|
||||||
func connectionDurationSeconds(connected bool, connectedSince time.Time) *int64 {
|
func connectionDuration(connected bool, connectedSince time.Time) string {
|
||||||
if !connected || connectedSince.IsZero() {
|
if !connected || connectedSince.IsZero() {
|
||||||
return nil
|
return "disconnected"
|
||||||
}
|
}
|
||||||
|
|
||||||
return new(uptimeSeconds(connectedSince))
|
return time.Since(connectedSince).Truncate(time.Second).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// whoisStaleThreshold matches the fetcher's threshold for consistency.
|
// whoisStaleThreshold matches the fetcher's threshold for consistency.
|
||||||
@@ -517,7 +512,7 @@ type StreamStats struct {
|
|||||||
//
|
//
|
||||||
//nolint:tagliatelle // field names are the existing JSON API
|
//nolint:tagliatelle // field names are the existing JSON API
|
||||||
type StatsResponse struct {
|
type StatsResponse struct {
|
||||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
Uptime string `json:"uptime"`
|
||||||
TotalMessages uint64 `json:"total_messages"`
|
TotalMessages uint64 `json:"total_messages"`
|
||||||
TotalBytes uint64 `json:"total_bytes"`
|
TotalBytes uint64 `json:"total_bytes"`
|
||||||
TotalWireBytes uint64 `json:"total_wire_bytes"`
|
TotalWireBytes uint64 `json:"total_wire_bytes"`
|
||||||
@@ -525,10 +520,11 @@ type StatsResponse struct {
|
|||||||
MbitsPerSec float64 `json:"mbits_per_sec"`
|
MbitsPerSec float64 `json:"mbits_per_sec"`
|
||||||
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
|
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
|
ConnectionDuration string `json:"connection_duration"`
|
||||||
ReconnectCount uint64 `json:"reconnect_count"`
|
ReconnectCount uint64 `json:"reconnect_count"`
|
||||||
GoVersion string `json:"go_version"`
|
GoVersion string `json:"go_version"`
|
||||||
Goroutines int `json:"goroutines"`
|
Goroutines int `json:"goroutines"`
|
||||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
MemoryUsage string `json:"memory_usage"`
|
||||||
GC GCStats `json:"gc"`
|
GC GCStats `json:"gc"`
|
||||||
Stream StreamStats `json:"stream"`
|
Stream StreamStats `json:"stream"`
|
||||||
ASNs int `json:"asns"`
|
ASNs int `json:"asns"`
|
||||||
@@ -550,9 +546,6 @@ type StatsResponse struct {
|
|||||||
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
|
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
|
||||||
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
|
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
|
||||||
|
|
||||||
// ConnectionDurationSeconds is null while the stream is not connected.
|
|
||||||
ConnectionDurationSeconds *int64 `json:"connection_duration_seconds"`
|
|
||||||
|
|
||||||
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
|
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,18 +684,20 @@ func (s *Server) statsResponse(
|
|||||||
runtime.ReadMemStats(&memStats)
|
runtime.ReadMemStats(&memStats)
|
||||||
|
|
||||||
return StatsResponse{
|
return StatsResponse{
|
||||||
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
|
Uptime: streamUptime(streamMetrics.ConnectedSince),
|
||||||
TotalMessages: streamMetrics.TotalMessages,
|
TotalMessages: streamMetrics.TotalMessages,
|
||||||
TotalBytes: streamMetrics.TotalBytes,
|
TotalBytes: streamMetrics.TotalBytes,
|
||||||
TotalWireBytes: streamMetrics.TotalWireBytes,
|
TotalWireBytes: streamMetrics.TotalWireBytes,
|
||||||
MessagesPerSec: streamMetrics.MessagesPerSec,
|
MessagesPerSec: streamMetrics.MessagesPerSec,
|
||||||
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
|
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
|
||||||
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
|
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
|
||||||
Connected: streamMetrics.Connected,
|
Connected: streamMetrics.Connected,
|
||||||
|
ConnectionDuration: connectionDuration(
|
||||||
|
streamMetrics.Connected, streamMetrics.ConnectedSince),
|
||||||
ReconnectCount: streamMetrics.ReconnectCount,
|
ReconnectCount: streamMetrics.ReconnectCount,
|
||||||
GoVersion: runtime.Version(),
|
GoVersion: runtime.Version(),
|
||||||
Goroutines: runtime.NumGoroutine(),
|
Goroutines: runtime.NumGoroutine(),
|
||||||
MemoryUsageBytes: memStats.Alloc,
|
MemoryUsage: humanize.Bytes(memStats.Alloc),
|
||||||
GC: gcStats(&memStats),
|
GC: gcStats(&memStats),
|
||||||
Stream: s.streamStats(),
|
Stream: s.streamStats(),
|
||||||
ASNs: dbStats.ASNs,
|
ASNs: dbStats.ASNs,
|
||||||
@@ -724,9 +719,6 @@ func (s *Server) statsResponse(
|
|||||||
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
|
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
|
||||||
WHOISStats: whoisStats,
|
WHOISStats: whoisStats,
|
||||||
|
|
||||||
ConnectionDurationSeconds: connectionDurationSeconds(
|
|
||||||
streamMetrics.Connected, streamMetrics.ConnectedSince),
|
|
||||||
|
|
||||||
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
|
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ func emptyTable() *routetable.Table {
|
|||||||
// Paths several tests request.
|
// Paths several tests request.
|
||||||
const (
|
const (
|
||||||
asPagePath = "/as/64500"
|
asPagePath = "/as/64500"
|
||||||
asJSONPath = "/api/v1/as/64500"
|
|
||||||
prefixLengthPagePath = "/prefixlength/24"
|
prefixLengthPagePath = "/prefixlength/24"
|
||||||
|
statusJSONPath = "/status.json"
|
||||||
statsPath = "/api/v1/stats"
|
statsPath = "/api/v1/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -295,167 +295,6 @@ func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fixedIntervalASNFetcher is a WHOIS fetcher that does nothing and reports
|
|
||||||
// interval as its wait between fetches.
|
|
||||||
type fixedIntervalASNFetcher struct {
|
|
||||||
idleASNFetcher
|
|
||||||
|
|
||||||
interval time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fixedIntervalASNFetcher) GetStats() ASNFetcherStats {
|
|
||||||
return ASNFetcherStats{CurrentInterval: f.interval}
|
|
||||||
}
|
|
||||||
|
|
||||||
// unitStats holds the figures of a stats answer that carry a unit. Each is a
|
|
||||||
// pointer, so that a figure missing from the answer is nil.
|
|
||||||
//
|
|
||||||
//nolint:tagliatelle // field names are the existing JSON API
|
|
||||||
type unitStats struct {
|
|
||||||
UptimeSeconds *int64 `json:"uptime_seconds"`
|
|
||||||
MemoryUsageBytes *uint64 `json:"memory_usage_bytes"`
|
|
||||||
WHOISStats struct {
|
|
||||||
CurrentIntervalSeconds *float64 `json:"current_interval_seconds"`
|
|
||||||
} `json:"whois_stats"`
|
|
||||||
GC struct {
|
|
||||||
HeapAllocBytes uint64 `json:"heap_alloc_bytes"`
|
|
||||||
} `json:"gc"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStatsAnswersGiveUnitsInFieldNames checks that the figures of both stats
|
|
||||||
// answers that carry a unit are numbers, with the unit at the end of their
|
|
||||||
// names. They used to be strings with the unit in them, such as "1h18m34s" or
|
|
||||||
// "1.1 GB" (https://git.eeqj.de/sneak/routewatch/issues/199). The stream has
|
|
||||||
// never connected, so the uptime is 0. The memory use in /api/v1/stats is the
|
|
||||||
// same number of bytes as its gc.heap_alloc_bytes.
|
|
||||||
func TestStatsAnswersGiveUnitsInFieldNames(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const fetchInterval = 18200 * time.Millisecond
|
|
||||||
|
|
||||||
s := newServerWithTestRoutes(t)
|
|
||||||
s.SetASNFetcher(fixedIntervalASNFetcher{interval: fetchInterval})
|
|
||||||
|
|
||||||
for _, path := range []string{statusJSONPath, statsPath} {
|
|
||||||
answer := get(t, s, path).Body.Bytes()
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
Data unitStats `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.Unmarshal(answer, &body)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s: figures are not numbers: %v; answer %s", path, err, answer)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
got := body.Data
|
|
||||||
if got.UptimeSeconds == nil || *got.UptimeSeconds != 0 {
|
|
||||||
t.Errorf("%s: uptime_seconds is not 0: %s", path, answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.MemoryUsageBytes == nil || *got.MemoryUsageBytes == 0 {
|
|
||||||
t.Errorf("%s: memory_usage_bytes is not a number of bytes: %s",
|
|
||||||
path, answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := got.WHOISStats.CurrentIntervalSeconds
|
|
||||||
if interval == nil || *interval != fetchInterval.Seconds() {
|
|
||||||
t.Errorf("%s: whois_stats.current_interval_seconds is not %v: %s",
|
|
||||||
path, fetchInterval.Seconds(), answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
if path == statsPath && got.MemoryUsageBytes != nil &&
|
|
||||||
*got.MemoryUsageBytes != got.GC.HeapAllocBytes {
|
|
||||||
t.Errorf("%s: memory_usage_bytes %d, want gc.heap_alloc_bytes %d",
|
|
||||||
path, *got.MemoryUsageBytes, got.GC.HeapAllocBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStatsAnswerGivesConnectionDurationInSeconds checks that the
|
|
||||||
// connection_duration_seconds of /api/v1/stats is null while the stream is not
|
|
||||||
// connected, and a whole number of seconds once it is. It used to be the
|
|
||||||
// string "disconnected", or a Go duration such as "1h18m34s"
|
|
||||||
// (https://git.eeqj.de/sneak/routewatch/issues/199).
|
|
||||||
func TestStatsAnswerGivesConnectionDurationInSeconds(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newServerWithTestRoutes(t)
|
|
||||||
|
|
||||||
connectionDuration := func() json.RawMessage {
|
|
||||||
answer := get(t, s, statsPath).Body.Bytes()
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
Data map[string]json.RawMessage `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.Unmarshal(answer, &body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decoding the answer: %v; answer %s", err, answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
duration, ok := body.Data["connection_duration_seconds"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("no connection_duration_seconds: %s", answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
return duration
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnected := connectionDuration()
|
|
||||||
if string(disconnected) != "null" {
|
|
||||||
t.Errorf("disconnected: connection_duration_seconds = %s, want null",
|
|
||||||
disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.streamer.GetMetricsTracker().SetConnected(true)
|
|
||||||
|
|
||||||
var seconds int64
|
|
||||||
|
|
||||||
connected := connectionDuration()
|
|
||||||
|
|
||||||
err := json.Unmarshal(connected, &seconds)
|
|
||||||
if err != nil || seconds != 0 {
|
|
||||||
t.Errorf("just connected: connection_duration_seconds = %s, want 0",
|
|
||||||
connected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStreamDurationsCountSeconds checks that the uptime and the connection
|
|
||||||
// duration of a stream that connected 90 seconds ago are both 90. A stream
|
|
||||||
// that has never connected has an uptime of 0, and one that is not connected
|
|
||||||
// has no connection duration.
|
|
||||||
func TestStreamDurationsCountSeconds(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const connectedFor = 90 * time.Second
|
|
||||||
|
|
||||||
connectedSince := time.Now().Add(-connectedFor)
|
|
||||||
want := int64(connectedFor / time.Second)
|
|
||||||
|
|
||||||
if got := uptimeSeconds(connectedSince); got != want {
|
|
||||||
t.Errorf("uptimeSeconds = %d, want %d", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := uptimeSeconds(time.Time{}); got != 0 {
|
|
||||||
t.Errorf("uptimeSeconds before any connection = %d, want 0", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch got := connectionDurationSeconds(true, connectedSince); {
|
|
||||||
case got == nil:
|
|
||||||
t.Errorf("connectionDurationSeconds = nil, want %d", want)
|
|
||||||
case *got != want:
|
|
||||||
t.Errorf("connectionDurationSeconds = %d, want %d", *got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := connectionDurationSeconds(false, connectedSince); got != nil {
|
|
||||||
t.Errorf("connectionDurationSeconds while not connected = %d, want nil",
|
|
||||||
*got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestTable returns a routing table that holds a route for testPrefixV4,
|
// newTestTable returns a routing table that holds a route for testPrefixV4,
|
||||||
// with origin AS 64500, and one for testPrefixV6, with origin AS 64501.
|
// with origin AS 64500, and one for testPrefixV6, with origin AS 64501.
|
||||||
func newTestTable() *routetable.Table {
|
func newTestTable() *routetable.Table {
|
||||||
@@ -927,7 +766,7 @@ func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
|
|||||||
{path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true},
|
{path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true},
|
||||||
{path: statsPath, wantJSONBody: true},
|
{path: statsPath, wantJSONBody: true},
|
||||||
{path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true},
|
{path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true},
|
||||||
{path: asJSONPath, wantJSONBody: true},
|
{path: "/api/v1/as/64500", wantJSONBody: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -1108,10 +947,10 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
wantCode int
|
wantCode int
|
||||||
}{
|
}{
|
||||||
{healthCheckPath, http.StatusServiceUnavailable},
|
{"/.well-known/healthcheck.json", http.StatusServiceUnavailable},
|
||||||
{statusJSONPath, http.StatusInternalServerError},
|
{statusJSONPath, http.StatusInternalServerError},
|
||||||
{statsPath, http.StatusInternalServerError},
|
{statsPath, http.StatusInternalServerError},
|
||||||
{asJSONPath, http.StatusInternalServerError},
|
{"/api/v1/as/64500", http.StatusInternalServerError},
|
||||||
{"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError},
|
{"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError},
|
||||||
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
|
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
|
||||||
}
|
}
|
||||||
@@ -1477,9 +1316,6 @@ func TestIPLookupReportsBlocklists(t *testing.T) {
|
|||||||
// jsonNull is how JSON writes a field without a value.
|
// jsonNull is how JSON writes a field without a value.
|
||||||
const jsonNull = "null"
|
const jsonNull = "null"
|
||||||
|
|
||||||
// jsonEmptyList is how JSON writes an empty list.
|
|
||||||
const jsonEmptyList = "[]"
|
|
||||||
|
|
||||||
// resultFields decodes an IP lookup answer and returns each of its results as
|
// resultFields decodes an IP lookup answer and returns each of its results as
|
||||||
// its JSON fields.
|
// its JSON fields.
|
||||||
func resultFields(t *testing.T, answer []byte) []map[string]json.RawMessage {
|
func resultFields(t *testing.T, answer []byte) []map[string]json.RawMessage {
|
||||||
@@ -1615,37 +1451,6 @@ func TestIPLookupResultsHaveTheSameFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock looks up a routed
|
|
||||||
// address that has no PTR names, and whose netblock the database has not
|
|
||||||
// stored. Its ptr is an empty list and its first_seen is null. Before the fix
|
|
||||||
// ptr was left out, and first_seen was 0001-01-01T00:00:00Z
|
|
||||||
// (https://git.eeqj.de/sneak/routewatch/issues/200).
|
|
||||||
func TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newServerWithTestRoutes(t)
|
|
||||||
s.resolver = &fakeResolver{}
|
|
||||||
|
|
||||||
path := "/api/v1/ip/" + addrInTestPrefixV4
|
|
||||||
|
|
||||||
results := resultFields(t, get(t, s, path).Body.Bytes())
|
|
||||||
if len(results) != 1 {
|
|
||||||
t.Fatalf("%s: %d results, want 1", path, len(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
want := map[string]string{
|
|
||||||
"ptr": jsonEmptyList,
|
|
||||||
"first_seen": jsonNull,
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, value := range want {
|
|
||||||
got := string(results[0][name])
|
|
||||||
if got != value {
|
|
||||||
t.Errorf("%s: %s is %s, want %s", path, name, got, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIPLookupAnswersMayBeCached checks the Cache-Control header of the IP
|
// TestIPLookupAnswersMayBeCached checks the Cache-Control header of the IP
|
||||||
// lookup answers. Caches may keep them for the blocklists' shortest interval,
|
// lookup answers. Caches may keep them for the blocklists' shortest interval,
|
||||||
// and only the caller's own browser may keep the answer about the caller's
|
// and only the caller's own browser may keep the answer about the caller's
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// responseWriter wraps http.ResponseWriter to capture the response
|
// responseWriter wraps http.ResponseWriter to capture the response
|
||||||
@@ -54,15 +53,6 @@ func (rw *responseWriter) Header() http.Header {
|
|||||||
return rw.ResponseWriter.Header()
|
return rw.ResponseWriter.Header()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The @meta object JSONResponseMiddleware adds to a JSON answer: its key, and
|
|
||||||
// the keys of its fields.
|
|
||||||
const (
|
|
||||||
jsonKeyMeta = "@meta"
|
|
||||||
metaKeyTimeZone = "time_zone"
|
|
||||||
metaKeyAPIVersion = "api_version"
|
|
||||||
metaKeyExecutionTimeMs = "execution_time_ms"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JSONResponseMiddleware is an HTTP middleware that wraps all JSON responses
|
// JSONResponseMiddleware is an HTTP middleware that wraps all JSON responses
|
||||||
// with a @meta field containing execution metadata. The metadata includes the
|
// with a @meta field containing execution metadata. The metadata includes the
|
||||||
// time zone (always UTC), API version, and request execution time in milliseconds.
|
// time zone (always UTC), API version, and request execution time in milliseconds.
|
||||||
@@ -116,10 +106,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add @meta field
|
// Add @meta field
|
||||||
originalResponse[jsonKeyMeta] = map[string]any{
|
originalResponse["@meta"] = map[string]any{
|
||||||
metaKeyTimeZone: "UTC",
|
"time_zone": "UTC",
|
||||||
metaKeyAPIVersion: 1,
|
"api_version": 1,
|
||||||
metaKeyExecutionTimeMs: execTime.Milliseconds(),
|
"execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the enhanced response
|
// Write the enhanced response
|
||||||
@@ -155,8 +145,7 @@ func deadlinePassed(ctx context.Context) bool {
|
|||||||
// has passed, it answers 504 with the error message "Request timeout". A 4xx
|
// has passed, it answers 504 with the error message "Request timeout". A 4xx
|
||||||
// keeps its status, with the status's standard text as the message. For other
|
// keeps its status, with the status's standard text as the message. For other
|
||||||
// errors, it returns "Internal server error", with status 500 if the original
|
// errors, it returns "Internal server error", with status 500 if the original
|
||||||
// status was 200. A panic in the handler answers 500 with "Internal server
|
// status was 200.
|
||||||
// error" too.
|
|
||||||
//
|
//
|
||||||
// Non-JSON responses are passed through unchanged.
|
// Non-JSON responses are passed through unchanged.
|
||||||
func JSONValidationMiddleware(next http.Handler) http.Handler {
|
func JSONValidationMiddleware(next http.Handler) http.Handler {
|
||||||
@@ -168,9 +157,8 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
|
|||||||
statusCode: http.StatusOK,
|
statusCode: http.StatusOK,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve the request. Recoverer logs a panic in the handler and turns it
|
// Serve the request
|
||||||
// into a 500 without a body, which gets the JSON error body below.
|
next.ServeHTTP(rw, r)
|
||||||
middleware.Recoverer(next).ServeHTTP(rw, r)
|
|
||||||
|
|
||||||
// Check if it's meant to be a JSON response
|
// Check if it's meant to be a JSON response
|
||||||
contentType := rw.Header().Get("Content-Type")
|
contentType := rw.Header().Get("Content-Type")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -11,7 +10,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -102,219 +100,6 @@ func TestJSONValidationKeeps4xxWithoutBody(t *testing.T) {
|
|||||||
checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest))
|
checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestJSONAnswersPutNoUnitInAString sends a request to each JSON route and
|
|
||||||
// checks each answer. Its @meta.execution_time_ms is a number, and none of its
|
|
||||||
// strings is a number with a unit, such as "12 ms", "1.1 GB" or "1h18m34s"
|
|
||||||
// (https://git.eeqj.de/sneak/routewatch/issues/199). The health check answers
|
|
||||||
// 503, since the stream is not connected, and the last path matches no route.
|
|
||||||
func TestJSONAnswersPutNoUnitInAString(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
numberWithUnit := regexp.MustCompile(
|
|
||||||
`^([0-9]+(\.[0-9]+)? ?(ns|µs|ms|s|m|h|B|kB|MB|GB|TB))+$`)
|
|
||||||
|
|
||||||
s := newServerWithTestRoutes(t)
|
|
||||||
s.resolver = &fakeResolver{}
|
|
||||||
s.SetASNFetcher(fixedIntervalASNFetcher{interval: time.Second})
|
|
||||||
|
|
||||||
paths := []string{
|
|
||||||
statusJSONPath,
|
|
||||||
statsPath,
|
|
||||||
healthCheckPath,
|
|
||||||
"/api/v1/ip/" + untrustedPeerAddr,
|
|
||||||
"/ip/" + untrustedPeerAddr,
|
|
||||||
asJSONPath,
|
|
||||||
"/api/v1/prefix/" + testPrefixV4,
|
|
||||||
"/api/v1/search/as?q=google",
|
|
||||||
"/api/v1/no-such-route",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path := range paths {
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, path, nil))
|
|
||||||
|
|
||||||
var answer map[string]any
|
|
||||||
|
|
||||||
err := json.Unmarshal(rec.Body.Bytes(), &answer)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s: decoding the answer: %v; answer %s", path, err, rec.Body)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
meta, _ := answer["@meta"].(map[string]any)
|
|
||||||
if _, ok := meta["execution_time_ms"].(float64); !ok {
|
|
||||||
t.Errorf("%s: @meta.execution_time_ms is not a number: %s",
|
|
||||||
path, rec.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, value := range stringsIn(answer) {
|
|
||||||
if numberWithUnit.MatchString(value) {
|
|
||||||
t.Errorf("%s: %q is a number with a unit in a string", path, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestJSONAnswersCarryMeta sends requests to every JSON route through the
|
|
||||||
// server's router, and checks that each answer carries @meta with its three
|
|
||||||
// fields. The answers include successes, and errors that the handlers, the
|
|
||||||
// router and the middleware write. Before the fix a panic in a handler, and a
|
|
||||||
// method /status.json or the health check does not take, answered with an
|
|
||||||
// empty body (https://git.eeqj.de/sneak/routewatch/issues/201).
|
|
||||||
func TestJSONAnswersCarryMeta(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// JSON routes whose handlers write nothing or panic, so that the JSON
|
|
||||||
// check on the route writes the error answer itself.
|
|
||||||
const (
|
|
||||||
writesNothingPath = "/writes-nothing.json"
|
|
||||||
panicsPath = "/panics.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
s := newServerWithTestRoutes(t)
|
|
||||||
s.resolver = &fakeResolver{}
|
|
||||||
s.streamer.GetMetricsTracker().SetConnected(true)
|
|
||||||
s.router.Get(writesNothingPath, JSONValidationMiddleware(http.HandlerFunc(
|
|
||||||
func(http.ResponseWriter, *http.Request) {})).ServeHTTP)
|
|
||||||
s.router.Get(panicsPath, JSONValidationMiddleware(http.HandlerFunc(
|
|
||||||
func(http.ResponseWriter, *http.Request) { panic(errDiskIO) })).ServeHTTP)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
wantCode int
|
|
||||||
}{
|
|
||||||
// Answers that succeed
|
|
||||||
{http.MethodGet, statusJSONPath, http.StatusOK},
|
|
||||||
{http.MethodGet, healthCheckPath, http.StatusOK},
|
|
||||||
{http.MethodGet, "/ip", http.StatusOK},
|
|
||||||
{http.MethodGet, ownAddressPath, http.StatusOK},
|
|
||||||
{http.MethodGet, "/ip/" + addrInTestPrefixV4, http.StatusOK},
|
|
||||||
{http.MethodGet, statsPath, http.StatusOK},
|
|
||||||
{http.MethodGet, "/api/v1/ip/" + addrInTestPrefixV4, http.StatusOK},
|
|
||||||
{http.MethodGet, asJSONPath, http.StatusOK},
|
|
||||||
{http.MethodGet, "/api/v1/prefix/" + testPrefixV4, http.StatusOK},
|
|
||||||
{http.MethodGet, "/api/v1/search/as?q=google", http.StatusOK},
|
|
||||||
// Errors the handlers write
|
|
||||||
{http.MethodGet, "/ip/no-such-host.example", http.StatusBadRequest},
|
|
||||||
{http.MethodGet, "/api/v1/ip/no-such-host.example", http.StatusBadRequest},
|
|
||||||
{http.MethodGet, "/api/v1/as/foo", http.StatusBadRequest},
|
|
||||||
{http.MethodGet, "/api/v1/as/0", http.StatusNotFound},
|
|
||||||
{http.MethodGet, "/api/v1/prefix/192.0.2.0/33", http.StatusBadRequest},
|
|
||||||
{http.MethodGet, "/api/v1/prefix/198.51.100.0/24", http.StatusNotFound},
|
|
||||||
{http.MethodGet, "/api/v1/search/as", http.StatusBadRequest},
|
|
||||||
// Errors the router and the middleware write
|
|
||||||
{http.MethodGet, "/api/v1/no-such-route", http.StatusNotFound},
|
|
||||||
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound},
|
|
||||||
{http.MethodPost, statsPath, http.StatusMethodNotAllowed},
|
|
||||||
{http.MethodPost, ownAddressPath, http.StatusMethodNotAllowed},
|
|
||||||
{http.MethodPost, statusJSONPath, http.StatusMethodNotAllowed},
|
|
||||||
{http.MethodPut, healthCheckPath, http.StatusMethodNotAllowed},
|
|
||||||
{http.MethodGet, writesNothingPath, http.StatusInternalServerError},
|
|
||||||
{http.MethodGet, panicsPath, http.StatusInternalServerError},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), tt.method, tt.path, nil), tt.wantCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A request whose time limit has already passed answers 504. The handlers
|
|
||||||
// write the answers of the first five routes, and the JSON check on the
|
|
||||||
// route writes the last.
|
|
||||||
pastTimeLimit, cancel := context.WithDeadline(t.Context(), time.Now())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
for _, path := range []string{
|
|
||||||
statusJSONPath, statsPath, "/ip/" + addrInTestPrefixV4,
|
|
||||||
"/api/v1/ip/" + addrInTestPrefixV4, asJSONPath, writesNothingPath,
|
|
||||||
} {
|
|
||||||
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
|
|
||||||
pastTimeLimit, http.MethodGet, path, nil), http.StatusGatewayTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// With a store whose reads fail, the health check answers 503, and the
|
|
||||||
// handlers of the other routes 500.
|
|
||||||
failing := New(failingDB{}, newTestTable(),
|
|
||||||
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
|
|
||||||
failing.resolver = &fakeResolver{}
|
|
||||||
|
|
||||||
checkAnswerCarriesMeta(t, failing, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, healthCheckPath, nil),
|
|
||||||
http.StatusServiceUnavailable)
|
|
||||||
|
|
||||||
for _, path := range []string{
|
|
||||||
statusJSONPath, statsPath, asJSONPath,
|
|
||||||
"/api/v1/ip/" + addrInTestPrefixV4, "/ip/" + addrInTestPrefixV4,
|
|
||||||
} {
|
|
||||||
checkAnswerCarriesMeta(t, failing, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, path, nil), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkAnswerCarriesMeta serves req through the server's router, and checks
|
|
||||||
// that it answers wantCode with @meta and its three fields.
|
|
||||||
func checkAnswerCarriesMeta(
|
|
||||||
t *testing.T, s *Server, req *http.Request, wantCode int,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// The values README "API v1" gives the two fields that never change.
|
|
||||||
const (
|
|
||||||
wantTimeZone = "UTC"
|
|
||||||
wantAPIVersion = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
name := req.Method + " " + req.URL.String()
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != wantCode {
|
|
||||||
t.Errorf("%s: status %d, want %d; body %s", name, rec.Code, wantCode, rec.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
var answer map[string]any
|
|
||||||
|
|
||||||
err := json.Unmarshal(rec.Body.Bytes(), &answer)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
meta, _ := answer[jsonKeyMeta].(map[string]any)
|
|
||||||
_, timeIsNumber := meta[metaKeyExecutionTimeMs].(float64)
|
|
||||||
|
|
||||||
if meta[metaKeyTimeZone] != wantTimeZone ||
|
|
||||||
meta[metaKeyAPIVersion] != float64(wantAPIVersion) || !timeIsNumber {
|
|
||||||
t.Errorf("%s: @meta %v, want time_zone %q, api_version %d and a "+
|
|
||||||
"number in execution_time_ms", name, meta, wantTimeZone, wantAPIVersion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stringsIn returns every string in a decoded JSON value, at any depth.
|
|
||||||
func stringsIn(value any) []string {
|
|
||||||
var found []string
|
|
||||||
|
|
||||||
switch v := value.(type) {
|
|
||||||
case string:
|
|
||||||
found = append(found, v)
|
|
||||||
case []any:
|
|
||||||
for _, item := range v {
|
|
||||||
found = append(found, stringsIn(item)...)
|
|
||||||
}
|
|
||||||
case map[string]any:
|
|
||||||
for _, item := range v {
|
|
||||||
found = append(found, stringsIn(item)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPagesCarryTheCSPNonce checks that every script and style block of each
|
// TestPagesCarryTheCSPNonce checks that every script and style block of each
|
||||||
// page carries the nonce its Content-Security-Policy header names, so that the
|
// page carries the nonce its Content-Security-Policy header names, so that the
|
||||||
// browser runs and applies them, that each answer has a nonce of its own, and
|
// browser runs and applies them, that each answer has a nonce of its own, and
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The error messages the JSON routes answer with when no route matches the
|
// The error messages the JSON routes under /api/v1 and /ip answer with when no
|
||||||
// path, and when the route does not take the method.
|
// route matches the path, and when none takes the method.
|
||||||
const (
|
const (
|
||||||
notFoundMessage = "Not found"
|
notFoundMessage = "Not found"
|
||||||
methodNotAllowedMessage = "Method not allowed"
|
methodNotAllowedMessage = "Method not allowed"
|
||||||
@@ -19,13 +19,6 @@ const (
|
|||||||
jsonRoutesAllow = "GET, HEAD"
|
jsonRoutesAllow = "GET, HEAD"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The paths of the status figures and the health check, the two JSON routes
|
|
||||||
// outside /api/v1 and /ip.
|
|
||||||
const (
|
|
||||||
statusJSONPath = "/status.json"
|
|
||||||
healthCheckPath = "/.well-known/healthcheck.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupRoutes configures the HTTP routes
|
// setupRoutes configures the HTTP routes
|
||||||
func (s *Server) setupRoutes() {
|
func (s *Server) setupRoutes() {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -39,8 +32,6 @@ func (s *Server) setupRoutes() {
|
|||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(RealIPMiddleware(s.trustedProxies))
|
r.Use(RealIPMiddleware(s.trustedProxies))
|
||||||
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
|
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
|
||||||
// Recoverer answers a panic on a page. JSONValidationMiddleware recovers a
|
|
||||||
// panic on a JSON route itself, so that the answer is JSON with @meta.
|
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(JSONResponseMiddleware)
|
r.Use(JSONResponseMiddleware)
|
||||||
// Timeout must come after JSONResponseMiddleware, which sends its answer
|
// Timeout must come after JSONResponseMiddleware, which sends its answer
|
||||||
@@ -52,6 +43,11 @@ func (s *Server) setupRoutes() {
|
|||||||
// Routes
|
// Routes
|
||||||
r.Get("/", s.handleIndex())
|
r.Get("/", s.handleIndex())
|
||||||
r.Get("/status", s.handleStatusHTML())
|
r.Get("/status", s.handleStatusHTML())
|
||||||
|
r.Get("/status.json", JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
|
||||||
|
r.Get(
|
||||||
|
"/.well-known/healthcheck.json",
|
||||||
|
JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP,
|
||||||
|
)
|
||||||
|
|
||||||
// AS and prefix detail pages
|
// AS and prefix detail pages
|
||||||
r.Get("/as/{asn}", s.handleASDetail())
|
r.Get("/as/{asn}", s.handleASDetail())
|
||||||
@@ -74,21 +70,6 @@ func (s *Server) setupRoutes() {
|
|||||||
s.writeJSONError(w, http.StatusMethodNotAllowed, methodNotAllowedMessage)
|
s.writeJSONError(w, http.StatusMethodNotAllowed, methodNotAllowedMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The status figures and the health check. chi sets one 405 answer for a
|
|
||||||
// whole router. These two routes are on the pages' router, whose 405 stays
|
|
||||||
// chi's own. So each method they do not take is routed to the JSON 405
|
|
||||||
// here.
|
|
||||||
r.Get(statusJSONPath, JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
|
|
||||||
r.Get(healthCheckPath, JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP)
|
|
||||||
|
|
||||||
for _, method := range []string{
|
|
||||||
http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete,
|
|
||||||
http.MethodOptions, http.MethodTrace, http.MethodConnect,
|
|
||||||
} {
|
|
||||||
r.MethodFunc(method, statusJSONPath, jsonMethodNotAllowed)
|
|
||||||
r.MethodFunc(method, healthCheckPath, jsonMethodNotAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IP info JSON endpoints (replaces old /ip redirect)
|
// IP info JSON endpoints (replaces old /ip redirect)
|
||||||
r.Route("/ip", func(r chi.Router) {
|
r.Route("/ip", func(r chi.Router) {
|
||||||
r.Use(JSONValidationMiddleware)
|
r.Use(JSONValidationMiddleware)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestRoutesAnswerHEAD(t *testing.T) {
|
|||||||
|
|
||||||
s := newServerWithTestRoutes(t)
|
s := newServerWithTestRoutes(t)
|
||||||
|
|
||||||
for _, path := range []string{"/", statusJSONPath, statsPath, "/ip", "/ip/"} {
|
for _, path := range []string{"/", statsPath, "/ip", "/ip/"} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
t.Context(), http.MethodHead, path, nil))
|
t.Context(), http.MethodHead, path, nil))
|
||||||
@@ -31,13 +31,10 @@ func TestRoutesAnswerHEAD(t *testing.T) {
|
|||||||
|
|
||||||
// TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors sends a method the JSON
|
// TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors sends a method the JSON
|
||||||
// routes do not take, and a path none of them matches, to each group of JSON
|
// routes do not take, and a path none of them matches, to each group of JSON
|
||||||
// routes through the server's router. It also sends a method they do not take
|
// routes through the server's router. Each must answer with the JSON error
|
||||||
// to /status.json and the health check. Each must answer with the JSON error
|
|
||||||
// body, and a 405 must name the methods the routes take in its Allow header.
|
// body, and a 405 must name the methods the routes take in its Allow header.
|
||||||
// Before the fix a 405 said "Internal server error" and a 404 was chi's plain
|
// Before the fix a 405 said "Internal server error" and a 404 was chi's plain
|
||||||
// text answer (https://git.eeqj.de/sneak/routewatch/issues/111).
|
// text answer (https://git.eeqj.de/sneak/routewatch/issues/111).
|
||||||
// /status.json and the health check gave chi's empty 405
|
|
||||||
// (https://git.eeqj.de/sneak/routewatch/issues/201).
|
|
||||||
func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
|
func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -51,14 +48,6 @@ func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{http.MethodPost, statsPath, http.StatusMethodNotAllowed, methodNotAllowedMessage},
|
{http.MethodPost, statsPath, http.StatusMethodNotAllowed, methodNotAllowedMessage},
|
||||||
{http.MethodPost, "/ip/", http.StatusMethodNotAllowed, methodNotAllowedMessage},
|
{http.MethodPost, "/ip/", http.StatusMethodNotAllowed, methodNotAllowedMessage},
|
||||||
{
|
|
||||||
http.MethodDelete, statusJSONPath,
|
|
||||||
http.StatusMethodNotAllowed, methodNotAllowedMessage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
http.MethodOptions, healthCheckPath,
|
|
||||||
http.StatusMethodNotAllowed, methodNotAllowedMessage,
|
|
||||||
},
|
|
||||||
{http.MethodGet, "/api/v1/nothing", http.StatusNotFound, notFoundMessage},
|
{http.MethodGet, "/api/v1/nothing", http.StatusNotFound, notFoundMessage},
|
||||||
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound, notFoundMessage},
|
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound, notFoundMessage},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -714,8 +714,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// IP lookup: show the whole JSON answer, its @meta and an error answer
|
// IP lookup
|
||||||
// included, as the IP information API card does.
|
|
||||||
document.getElementById('ip-form').addEventListener('submit', function(e) {
|
document.getElementById('ip-form').addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const ip = document.getElementById('ip-input').value.trim();
|
const ip = document.getElementById('ip-input').value.trim();
|
||||||
@@ -731,11 +730,18 @@
|
|||||||
|
|
||||||
fetch('/ip/' + encodeURIComponent(ip))
|
fetch('/ip/' + encodeURIComponent(ip))
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(answer => {
|
.then(response => {
|
||||||
contentEl.className = '';
|
contentEl.classList.remove('loading');
|
||||||
contentEl.textContent = JSON.stringify(answer, null, 2);
|
if (response.status === 'error') {
|
||||||
|
contentEl.className = 'error';
|
||||||
|
contentEl.textContent = 'Error: ' + response.error.msg;
|
||||||
|
} else {
|
||||||
|
contentEl.className = '';
|
||||||
|
contentEl.textContent = JSON.stringify(response.data, null, 2);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
contentEl.classList.remove('loading');
|
||||||
contentEl.className = 'error';
|
contentEl.className = 'error';
|
||||||
contentEl.textContent = 'Error: ' + error.message;
|
contentEl.textContent = 'Error: ' + error.message;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -401,8 +401,6 @@
|
|||||||
const REFRESH_PERIOD_MS = 2000;
|
const REFRESH_PERIOD_MS = 2000;
|
||||||
const STALE_AFTER_REFRESHES = 3;
|
const STALE_AFTER_REFRESHES = 3;
|
||||||
const MS_PER_SECOND = 1000;
|
const MS_PER_SECOND = 1000;
|
||||||
const SECONDS_PER_MINUTE = 60;
|
|
||||||
const SECONDS_PER_HOUR = 3600;
|
|
||||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
|
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
|
||||||
|
|
||||||
@@ -445,18 +443,7 @@
|
|||||||
function formatNumber(num) {
|
function formatNumber(num) {
|
||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatDuration writes a number of seconds in hours, minutes and
|
|
||||||
// seconds, such as "1h 18m 34s" or "18.2s", with at most one decimal.
|
|
||||||
function formatDuration(totalSeconds) {
|
|
||||||
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
|
|
||||||
const minutes = Math.floor(totalSeconds % SECONDS_PER_HOUR / SECONDS_PER_MINUTE);
|
|
||||||
const seconds = parseFloat((totalSeconds % SECONDS_PER_MINUTE).toFixed(1));
|
|
||||||
if (hours > 0) return hours + 'h ' + minutes + 'm ' + seconds + 's';
|
|
||||||
if (minutes > 0) return minutes + 'm ' + seconds + 's';
|
|
||||||
return seconds + 's';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatProcessingTime(ms) {
|
function formatProcessingTime(ms) {
|
||||||
if (ms < 0.001) {
|
if (ms < 0.001) {
|
||||||
return (ms * 1000).toFixed(0) + ' µs';
|
return (ms * 1000).toFixed(0) + ' µs';
|
||||||
@@ -632,13 +619,11 @@
|
|||||||
noteMessageCount(data.total_messages);
|
noteMessageCount(data.total_messages);
|
||||||
|
|
||||||
// Update all metrics
|
// Update all metrics
|
||||||
document.getElementById('uptime').textContent = formatDuration(data.uptime_seconds);
|
document.getElementById('uptime').textContent = data.uptime;
|
||||||
document.getElementById('go_version').textContent = data.go_version;
|
document.getElementById('go_version').textContent = data.go_version;
|
||||||
document.getElementById('goroutines').textContent = formatNumber(data.goroutines);
|
document.getElementById('goroutines').textContent = formatNumber(data.goroutines);
|
||||||
document.getElementById('memory_usage').textContent = formatBytes(data.memory_usage_bytes);
|
document.getElementById('memory_usage').textContent = data.memory_usage;
|
||||||
// connection_duration_seconds is null while the stream is not connected.
|
document.getElementById('connection_duration').textContent = data.connection_duration;
|
||||||
document.getElementById('connection_duration').textContent =
|
|
||||||
data.connection_duration_seconds === null ? 'disconnected' : formatDuration(data.connection_duration_seconds);
|
|
||||||
document.getElementById('reconnect_count').textContent = formatNumber(data.reconnect_count);
|
document.getElementById('reconnect_count').textContent = formatNumber(data.reconnect_count);
|
||||||
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
|
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
|
||||||
document.getElementById('messages_per_sec').textContent = formatNumber(Math.round(data.messages_per_sec));
|
document.getElementById('messages_per_sec').textContent = formatNumber(Math.round(data.messages_per_sec));
|
||||||
@@ -687,7 +672,7 @@
|
|||||||
const errorsEl = document.getElementById('whois_errors');
|
const errorsEl = document.getElementById('whois_errors');
|
||||||
errorsEl.textContent = formatNumber(data.whois_stats.errors_last_hour);
|
errorsEl.textContent = formatNumber(data.whois_stats.errors_last_hour);
|
||||||
errorsEl.className = 'metric-value' + (data.whois_stats.errors_last_hour > 0 ? ' disconnected' : '');
|
errorsEl.className = 'metric-value' + (data.whois_stats.errors_last_hour > 0 ? ' disconnected' : '');
|
||||||
document.getElementById('whois_interval').textContent = formatDuration(data.whois_stats.current_interval_seconds);
|
document.getElementById('whois_interval').textContent = data.whois_stats.current_interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update handler stats
|
// Update handler stats
|
||||||
|
|||||||
Reference in New Issue
Block a user