Compare commits

..
8 Commits
Author SHA1 Message Date
Laan Tungir ca7a4b6722 v2.1.36 - Unified relay table, caching/backfill separation, inbox defaults, profile gating, and UI fixes
- Added caching_relays unified table with live_enabled/backfill_enabled columns
- Separated --reset-backfill from --start-caching as independent flags
- Removed redundant caching_enabled master setting; daemon derives from live/backfill
- Set caching_inbox_enabled=true by default; removed Inbox toggle from Backfill page
- Set caching_live_strategy=cache_all by default
- Fixed outbox relay discovery to store ALL discovered relays, not just covering set
- Added store_kind_0_information config (default: true) to gate profile sync trigger
- Regenerated pg_schema.h from pg_schema.sql to include caching_relays table
- Fixed process toggle button styling to match monochrome aesthetic
- Fixed radio button styling to match black/white/red theme
- Simplified Backfill page: removed Service Status and Inbox Status sections
- Backfill status now respects config setting, not just daemon state
- Admin config API now bumps caching_config_generation for caching-related changes
- make_and_restart_relay.sh now resets PostgreSQL schema on fresh restart
2026-08-03 19:37:09 -04:00
Laan Tungir aa07fe0edd v2.1.35 - Style native date/time pickers to match admin monospace theme 2026-08-02 17:48:59 -04:00
Laan Tungir 67874636d2 v2.1.34 - Replace date pickers with plain text datetime inputs (YYYY-MM-DD HH:MM) for cleanup page 2026-08-02 17:45:46 -04:00
Laan Tungir c316c39860 v2.1.33 - Replace max_age_days with from_date/to_date date range pickers in cleanup page 2026-08-02 17:39:42 -04:00
Laan Tungir a4197dab4b v2.1.32 - Clear stats cache after cleanup delete so dashboard reflects changes immediately 2026-08-02 17:37:14 -04:00
Laan Tungir 8453d82ee4 v2.1.31 - Fix make_and_restart_relay.sh: load test keys by default, add --no-test-keys flag 2026-08-02 17:12:52 -04:00
Laan Tungir 8c6770c7e2 v2.1.30 - Add Event Cleanup admin page with saved queries, preview, and delete execution 2026-08-02 17:08:03 -04:00
Laan Tungir 7cd33aa6ea v2.1.29 - Remove SQLite support, hardcode PostgreSQL backend, fix admin API to use first_seen, add pgweb systemd service 2026-08-02 08:20:11 -04:00
74 changed files with 42241 additions and 11333 deletions
+53
View File
@@ -2,6 +2,59 @@
**Project-Specific Information for AI Agents Working with C-Relay-PG**
## ⚠️ CRITICAL: Never Run `make` Directly
**NEVER run `make` to build the relay binary.** The Makefile will refuse and
print an error. Running `make` directly produces a dynamically-linked binary
that will silently fall back to SQLite storage while the admin UI connects to
PostgreSQL — causing the admin page to show stale data.
Always use one of these instead:
- **`./build_static.sh`** — Build the static MUSL binary with PostgreSQL backend
- **`./make_and_restart_relay.sh`** — Build, kill old relay, and start new one
The Makefile exists only for submodule compilation (nostr_core_lib, c_utils_lib)
and utility targets (clean, install-deps). It will refuse to build the relay
binary directly.
## Critical Build Commands
### Primary Build Command
```bash
./make_and_restart_relay.sh
```
**Never use `make` directly.** The project requires the custom restart script which:
- Handles database preservation/cleanup based on flags
- Manages architecture-specific binary detection (x86/ARM64)
- Performs automatic process cleanup and port management
- Starts relay in background with proper logging
**Note:** `--test-keys` / `-t` is now the **default**. Use `--no-test-keys` or `--production` for random key generation.
### Architecture-Specific Binary Outputs
- **x86_64**: `./build/c_relay_pg_x86`
- **ARM64**: `./build/c_relay_pg_arm64`
- **Other**: `./build/c_relay_pg_$(ARCH)`
**Project-Specific Information for AI Agents Working with C-Relay-PG**
## ⚠️ CRITICAL: Never Run `make` Directly
**NEVER run `make` to build the relay binary.** The Makefile will refuse and
print an error. Running `make` directly produces a dynamically-linked binary
that will silently fall back to SQLite storage while the admin UI connects to
PostgreSQL — causing the admin page to show stale data.
Always use one of these instead:
- **`./build_static.sh`** — Build the static MUSL binary with PostgreSQL backend
- **`./make_and_restart_relay.sh`** — Build, kill old relay, and start new one
The Makefile exists only for submodule compilation (nostr_core_lib, c_utils_lib)
and utility targets (clean, install-deps). It will refuse to build the relay
binary directly.
## Critical Build Commands
### Primary Build Command
+9 -15
View File
@@ -1,14 +1,13 @@
# Alpine-based MUSL static binary builder for C-Relay-PG
# Produces truly portable binaries with zero runtime dependencies
# PostgreSQL backend only.
ARG DEBUG_BUILD=false
ARG DB_BACKEND=sqlite
FROM alpine:3.19 AS builder
# Re-declare build arguments in this stage
ARG DEBUG_BUILD=false
ARG DB_BACKEND=sqlite
# Install build dependencies
RUN apk add --no-cache \
@@ -105,7 +104,8 @@ COPY Makefile /build/Makefile
# Build c-relay-pg with full static linking (only rebuilds when src/ changes)
# Disable fortification to avoid __*_chk symbols that don't exist in MUSL
# Use conditional compilation flags based on DEBUG_BUILD and DB_BACKEND build args
# Use conditional compilation flags based on DEBUG_BUILD build arg.
# PostgreSQL backend is always used.
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
CFLAGS="-g -O2 -DDEBUG"; \
STRIP_CMD="echo 'Keeping debug symbols'"; \
@@ -115,15 +115,9 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
STRIP_CMD="strip /build/c_relay_pg_static"; \
echo "Building optimized production binary (symbols stripped)"; \
fi && \
if [ "$DB_BACKEND" = "postgres" ]; then \
DB_FLAGS="-DDB_BACKEND_POSTGRES -DHAVE_LIBPQ"; \
DB_LIBS="-lpq -lpgcommon -lpgport"; \
echo "Compiling with PostgreSQL backend"; \
else \
DB_FLAGS=""; \
DB_LIBS=""; \
echo "Compiling with SQLite backend"; \
fi && \
DB_FLAGS="-DDB_BACKEND_POSTGRES -DHAVE_LIBPQ" && \
DB_LIBS="-lpq -lpgcommon -lpgport" && \
echo "Compiling with PostgreSQL backend" && \
gcc -static $CFLAGS $DB_FLAGS -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core \
@@ -132,11 +126,11 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c \
src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c \
src/caching_inbox_poller.c src/caching_service_launcher.c \
src/db_ops.c src/db_ops_sqlite.c src/db_ops_postgres.c src/thread_pool.c \
src/db_ops.c src/db_ops_postgres.c src/thread_pool.c \
-o /build/c_relay_pg_static \
c_utils_lib/libc_utils.a \
nostr_core_lib/libnostr_core_x64.a \
-lwebsockets -lssl -lcrypto -lsqlite3 -lsecp256k1 \
-lwebsockets -lssl -lcrypto -lsecp256k1 \
-lcurl -lz -lpthread -lm -ldl $DB_LIBS && \
eval "$STRIP_CMD"
@@ -150,4 +144,4 @@ RUN echo "=== Binary Information ===" && \
# Output stage - just the binary
FROM scratch AS output
COPY --from=builder /build/c_relay_pg_static /c_relay_pg_static
COPY --from=builder /build/c_relay_pg_static /c_relay_pg_static
+75 -135
View File
@@ -1,26 +1,16 @@
# C-Relay-PG Makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c99 -g -O2
CFLAGS = -Wall -Wextra -std=c99 -g -O2 -DDB_BACKEND_POSTGRES -DHAVE_LIBPQ
INCLUDES = -I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket -I/usr/include/postgresql
LIBS = -lsqlite3 -lwebsockets -lz -ldl -lpthread -lm -L/usr/local/lib -lsecp256k1 -lssl -lcrypto -L/usr/local/lib -lcurl -Lc_utils_lib -lc_utils
DB_BACKEND ?= sqlite
LIBS = -lwebsockets -lz -ldl -lpthread -lm -L/usr/local/lib -lsecp256k1 -lssl -lcrypto -L/usr/local/lib -lcurl -Lc_utils_lib -lc_utils -lpq
# Build directory
BUILD_DIR = build
# Source files
MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c src/thread_pool.c src/caching_inbox_poller.c src/caching_service_launcher.c
DB_OPS_SRC = src/db_ops.c
ifeq ($(DB_BACKEND),postgres)
CFLAGS += -DDB_BACKEND_POSTGRES -DHAVE_LIBPQ
DB_OPS_SRC += src/db_ops_postgres.c
LIBS += -lpq
else
DB_OPS_SRC += src/db_ops_sqlite.c
endif
DB_OPS_SRC = src/db_ops.c src/db_ops_postgres.c
NOSTR_CORE_LIB = nostr_core_lib/libnostr_core_x64.a
C_UTILS_LIB = c_utils_lib/libc_utils.a
@@ -37,8 +27,30 @@ else
TARGET = $(BUILD_DIR)/c_relay_pg_$(ARCH)
endif
# Default target
all: $(TARGET)
# Default target — refuse direct build, instruct to use build_static.sh
# The Makefile is only for submodule builds (nostr_core_lib, c_utils_lib)
# and utility targets (clean, install-deps, etc.).
# Running 'make' directly produces a dynamically-linked binary that will
# silently fall back to SQLite storage while the admin UI connects to
# PostgreSQL — causing the admin page to show stale data.
all:
@echo "============================================"
@echo " ERROR: Do not run 'make' directly!"
@echo ""
@echo " This project requires a static MUSL build"
@echo " with PostgreSQL backend. Run:"
@echo ""
@echo " ./build_static.sh"
@echo ""
@echo " Or use the full build+restart script:"
@echo ""
@echo " ./make_and_restart_relay.sh"
@echo ""
@echo " The Makefile is only for submodule builds"
@echo " (nostr_core_lib, c_utils_lib) and utility"
@echo " targets (clean, install-deps, etc.)."
@echo "============================================"
@exit 1
# Create build directory
$(BUILD_DIR):
@@ -92,147 +104,75 @@ force-version:
@echo "Force updating main.h version information..."
@$(MAKE) src/main.h
# Build the relay
$(TARGET): $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(DB_OPS_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
@echo "Compiling C-Relay-PG for architecture: $(ARCH) (backend: $(DB_BACKEND))"
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) $(DB_OPS_SRC) -o $(TARGET) $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)
@echo "Build complete: $(TARGET)"
# Build the relay — guarded to prevent accidental use.
# Use ./build_static.sh instead.
$(TARGET):
@echo "ERROR: Do not run 'make $(TARGET)' directly."
@echo "Use ./build_static.sh to build the static MUSL binary."
@exit 1
# Build for specific architectures
x86: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(DB_OPS_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
@echo "Building C-Relay-PG for x86_64 (backend: $(DB_BACKEND))..."
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) $(DB_OPS_SRC) -o $(BUILD_DIR)/c_relay_pg_x86 $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)
@echo "Build complete: $(BUILD_DIR)/c_relay_pg_x86"
x86:
@echo "ERROR: Do not run 'make x86' directly."
@echo "Use ./build_static.sh to build the static MUSL binary."
@exit 1
arm64: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(DB_OPS_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
@echo "Cross-compiling C-Relay-PG for ARM64 (backend: $(DB_BACKEND))..."
arm64:
@echo "ERROR: Do not run 'make arm64' directly."
@echo "Use ./build_static.sh to build the static MUSL binary."
@exit 1
@if ! command -v aarch64-linux-gnu-gcc >/dev/null 2>&1; then \
echo "ERROR: ARM64 cross-compiler not found."; \
echo "Install with: make install-cross-tools"; \
echo "Or install manually: sudo apt install gcc-aarch64-linux-gnu"; \
echo "ERROR: aarch64-linux-gnu-gcc not found."; \
echo "Install the ARM64 cross-compiler:"; \
echo " sudo apt install gcc-aarch64-linux-gnu"; \
exit 1; \
fi
@echo "Checking for ARM64 development libraries..."
@if ! dpkg -l | grep -q "libssl-dev:arm64\|libsqlite3-dev:arm64"; then \
@if ! dpkg -l | grep -q "libssl-dev:arm64\|libpq-dev:arm64"; then \
echo "ERROR: ARM64 libraries not found. Cross-compilation requires ARM64 versions of:"; \
echo " - libssl-dev:arm64"; \
echo " - libsqlite3-dev:arm64"; \
echo " - libpq-dev:arm64"; \
echo " - libwebsockets-dev:arm64"; \
echo " - libsecp256k1-dev:arm64"; \
echo " - zlib1g-dev:arm64"; \
echo " - libcurl4-openssl-dev:arm64"; \
echo " - libcurl-dev:arm64"; \
echo ""; \
echo "Install ARM64 libraries with: make install-arm64-deps"; \
echo "Or use Docker for cross-platform builds."; \
echo "Install them with:"; \
echo " sudo dpkg --add-architecture arm64"; \
echo " sudo apt update"; \
echo " sudo apt install gcc-aarch64-linux-gnu \\"; \
echo " libssl-dev:arm64 libpq-dev:arm64 \\"; \
echo " libwebsockets-dev:arm64 zlib1g-dev:arm64 \\"; \
echo " libcurl4-openssl-dev:arm64"; \
exit 1; \
fi
@echo "Using aarch64-linux-gnu-gcc with ARM64 libraries..."
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \
aarch64-linux-gnu-gcc $(CFLAGS) $(INCLUDES) $(MAIN_SRC) $(DB_OPS_SRC) -o $(BUILD_DIR)/c_relay_pg_arm64 $(NOSTR_CORE_LIB) $(C_UTILS_LIB) \
-L/usr/lib/aarch64-linux-gnu $(LIBS)
@echo "Build complete: $(BUILD_DIR)/c_relay_pg_arm64"
fi; \
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig \
PKG_CONFIG_SYSROOT_DIR=/ \
aarch64-linux-gnu-gcc -Wall -Wextra -std=c99 -g -O2 \
-DDB_BACKEND_POSTGRES -DHAVE_LIBPQ \
-I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
$(MAIN_SRC) $(DB_OPS_SRC) \
-o $(BUILD_DIR)/c_relay_pg_arm64 \
$(NOSTR_CORE_LIB) $(C_UTILS_LIB) \
-lwebsockets -lssl -lcrypto -lz -ldl -lpthread -lm \
-lsecp256k1 -lcurl -lpq
@echo "Cross-compilation complete: $(BUILD_DIR)/c_relay_pg_arm64"
# Install ARM64 cross-compilation dependencies
# Install dependencies for ARM64 cross-compilation
install-arm64-deps:
@echo "Installing ARM64 cross-compilation dependencies..."
@echo "This requires adding ARM64 architecture and installing cross-libraries..."
sudo dpkg --add-architecture arm64
sudo apt update
sudo apt install -y \
gcc-aarch64-linux-gnu \
libc6-dev-arm64-cross \
libssl-dev:arm64 \
libsqlite3-dev:arm64 \
zlib1g-dev:arm64 \
sudo apt install -y gcc-aarch64-linux-gnu \
libssl-dev:arm64 libpq-dev:arm64 \
libwebsockets-dev:arm64 zlib1g-dev:arm64 \
libcurl4-openssl-dev:arm64
@echo "Note: libwebsockets-dev:arm64 and libsecp256k1-dev:arm64 may need manual building"
# Install cross-compilation tools
install-cross-tools:
@echo "Installing cross-compilation tools..."
# Install dependencies for native build
install-deps:
sudo apt update
sudo apt install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross
# Check what architectures we can actually build
check-toolchain:
@echo "Checking available toolchains:"
@echo "Native compiler: $(shell $(CC) --version | head -1)"
@if command -v aarch64-linux-gnu-gcc >/dev/null 2>&1; then \
echo "ARM64 cross-compiler: $(shell aarch64-linux-gnu-gcc --version | head -1)"; \
else \
echo "ARM64 cross-compiler: NOT INSTALLED (install with 'make install-cross-tools')"; \
fi
# Run tests
test: $(TARGET)
@echo "Running tests..."
./tests/1_nip_test.sh
# Initialize database (now handled automatically when server starts)
init-db:
@echo "Database initialization is now handled automatically when the server starts."
@echo "The schema is embedded in the binary - no external files needed."
@echo "To manually recreate database: rm -f db/c_nostr_relay.db && ./build/c_relay_pg_x86"
sudo apt install -y build-essential libpq-dev libssl-dev libcurl4-openssl-dev libsecp256k1-dev zlib1g-dev jq curl
# Clean build artifacts
clean:
rm -rf $(BUILD_DIR)
@echo "Clean complete"
# Clean everything including nostr_core_lib and c_utils_lib
clean-all: clean
cd nostr_core_lib && make clean 2>/dev/null || true
cd c_utils_lib && make clean 2>/dev/null || true
# Install dependencies (Ubuntu/Debian)
install-deps:
@echo "Installing dependencies..."
sudo apt update
sudo apt install -y build-essential libsqlite3-dev libssl-dev libcurl4-openssl-dev libsecp256k1-dev zlib1g-dev jq curl
# Help
help:
@echo "C-Relay-PG Build System"
@echo ""
@echo "Targets:"
@echo " all Build the relay for current architecture (default)"
@echo " x86 Build specifically for x86_64"
@echo " arm64 Build for ARM64 (requires cross-compilation setup)"
@echo " test Build and run tests"
@echo " init-db Initialize the database"
@echo " clean Clean build artifacts"
@echo " clean-all Clean everything including dependencies"
@echo " install-deps Install system dependencies"
@echo " install-cross-tools Install basic ARM64 cross-compiler"
@echo " install-arm64-deps Install ARM64 cross-compilation libraries"
@echo " check-toolchain Check available compilers"
@echo " help Show this help"
@echo ""
@echo "Usage:"
@echo " make # Build the relay for current arch"
@echo " make x86 # Build for x86_64"
@echo " make arm64 # Build for ARM64 (fails if cross-compilation not set up)"
@echo " make install-arm64-deps # Install full ARM64 cross-compilation setup"
@echo " make check-toolchain # Check what compilers are available"
@echo " make test # Run tests"
@echo " make init-db # Set up database"
@echo " make force-version # Force regenerate main.h from git"
# Build fully static MUSL binaries using Docker
static-musl-x86_64:
@echo "Building fully static MUSL binary for x86_64..."
docker buildx build --platform linux/amd64 -f examples/deployment/static-builder.Dockerfile -t c-relay-pg-static-builder-x86_64 --load .
docker run --rm -v $(PWD)/build:/output c-relay-pg-static-builder-x86_64 sh -c "cp /c_relay_pg_static_musl_x86_64 /output/"
@echo "Static binary created: build/c_relay_pg_static_musl_x86_64"
static-musl-arm64:
@echo "Building fully static MUSL binary for ARM64..."
docker buildx build --platform linux/arm64 -f examples/deployment/static-builder.Dockerfile -t c-relay-pg-static-builder-arm64 --load .
docker run --rm -v $(PWD)/build:/output c-relay-pg-static-builder-arm64 sh -c "cp /c_relay_pg_static_musl_x86_64 /output/c_relay_pg_static_musl_arm64"
@echo "Static binary created: build/c_relay_pg_static_musl_arm64"
static-musl: static-musl-x86_64 static-musl-arm64
@echo "Built static MUSL binaries for both architectures"
.PHONY: static-musl-x86_64 static-musl-arm64 static-musl
.PHONY: all x86 arm64 test init-db clean clean-all install-deps install-cross-tools install-arm64-deps check-toolchain help force-version
.PHONY: all x86 arm64 install-arm64-deps install-deps clean force-version
+45 -4
View File
@@ -66,10 +66,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
// Caching config values (for toggle controls)
// Caching config values (for toggle controls). The external daemon is
// controlled by live/backfill; the inbox poller remains a main-relay setting.
$config = [];
try {
$cfg_rows = $pdo->query("SELECT key, value FROM config WHERE key IN ('caching_enabled','caching_inbox_enabled','caching_live_enabled','caching_backfill_enabled')")->fetchAll();
$cfg_rows = $pdo->query("SELECT key, value FROM config WHERE key IN ('caching_inbox_enabled','caching_live_enabled','caching_backfill_enabled')")->fetchAll();
foreach ($cfg_rows as $r) { $config[$r['key']] = $r['value']; }
} catch (PDOException $e) {}
@@ -87,8 +88,37 @@ try {
$inbox = $pdo->query("SELECT COUNT(*) AS pending, COUNT(*) FILTER (WHERE source_class='live') AS live, COUNT(*) FILTER (WHERE source_class='backfill') AS backfill FROM caching_event_inbox")->fetch() ?: $inbox;
} catch (PDOException $e) {}
// Upstream relay status (per-relay connection state). The unified
// caching_relays table is authoritative; the old table is only a migration
// fallback so the Backfill page cannot display stale legacy statuses.
$upstreamRelays = [];
try {
$upstreamRelays = $pdo->query("
SELECT relay_url, status_code, status_text, updated_at
FROM caching_relays
ORDER BY relay_url
")->fetchAll();
} catch (PDOException $e) {
try {
$upstreamRelays = $pdo->query("
SELECT relay_url, status_code, status_text, updated_at
FROM caching_upstream_relays
ORDER BY relay_url
")->fetchAll();
} catch (PDOException $ignored) {}
}
// Follows (paginated, with names from profiles cache + per-relay progress)
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 50;
$offset = ($page - 1) * $perPage;
$follows = [];
$totalFollows = 0;
try {
$totalFollows = (int)$pdo->query("SELECT COUNT(*) FROM caching_followed_pubkeys")->fetchColumn();
} catch (PDOException $e) {}
try {
$follows = $pdo->query("
SELECT fp.pubkey, fp.is_root, fp.backfill_complete, fp.events_fetched,
@@ -99,7 +129,8 @@ try {
(SELECT count(*) FROM caching_backfill_relay_progress WHERE author_pubkey = fp.pubkey AND complete = false) AS relay_incomplete
FROM caching_followed_pubkeys fp
LEFT JOIN profiles p ON p.pubkey = fp.pubkey
ORDER BY fp.is_root DESC, fp.events_fetched DESC LIMIT 1000
ORDER BY fp.is_root DESC, fp.events_fetched DESC
LIMIT $perPage OFFSET $offset
")->fetchAll();
// Fetch per-relay progress for all followed pubkeys in one query
$relayProgress = [];
@@ -120,4 +151,14 @@ try {
}
} catch (PDOException $e) {}
json_response(['state' => $state, 'active' => $active, 'inbox' => $inbox, 'follows' => $follows, 'config' => $config]);
json_response([
'state' => $state,
'active' => $active,
'inbox' => $inbox,
'follows' => $follows,
'config' => $config,
'upstreamRelays' => $upstreamRelays,
'totalFollows' => $totalFollows,
'page' => $page,
'perPage' => $perPage,
]);
+20 -3
View File
@@ -1,6 +1,6 @@
<?php
/**
* admin2/api/chart.php — Standalone ASCII chart endpoint.
* admin/api/chart.php — Standalone ASCII chart endpoint.
*
* Returns a plain-text ASCII X-bar chart of event counts over time.
* Works in both the browser (injected into a <div>) and the terminal:
@@ -10,8 +10,22 @@
* curl http://localhost:8088/api/chart.php?range=month
* curl http://localhost:8088/api/chart.php?range=year
*
* This endpoint is read-only (a fixed aggregate COUNT query with the only
* user input being the `range` selector, which is validated against a
* whitelist). It is safe to expose publicly and is served without auth at:
*
* https://<domain>/relay/api/chart.php?range=day
*
* It is also reachable (behind Basic Auth) from the admin UI at:
*
* https://<domain>/relay/admin/api/chart.php?range=day
*
* Caching: the hour chart is never cached (live). Day/month/year are
* cached to file with TTLs to avoid expensive queries on every request.
*
* NOTE: Uses first_seen (not created_at) for binning, because some events
* have corrupted created_at timestamps (far-future values) that would
* place them outside the visible chart range.
*/
require_once __DIR__ . '/../lib/helpers.php';
@@ -59,9 +73,12 @@ try {
// bin index 0 = oldest, bin index (num_bins-1) = newest
$base_bin = (int)floor($epoch / $bin_size);
$sql = "SELECT FLOOR(created_at / {$bin_size})::BIGINT - {$base_bin} AS bin, COUNT(*) AS cnt
// Use first_seen (not created_at) for binning, because some events
// have corrupted created_at timestamps (far-future values) that
// would place them outside the visible chart range.
$sql = "SELECT FLOOR(first_seen / {$bin_size})::BIGINT - {$base_bin} AS bin, COUNT(*) AS cnt
FROM events
WHERE created_at >= {$epoch}
WHERE first_seen >= {$epoch}
GROUP BY bin
ORDER BY bin";
$rows = $pdo->query($sql)->fetchAll();
+310
View File
@@ -0,0 +1,310 @@
<?php
/**
* admin2/api/cleanup.php — Event Cleanup: Preview & Execute.
*
* GET — Preview a cleanup query (count + size estimate, no delete).
* POST — Execute a cleanup query (dry_run=true for preview, false for delete).
*/
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
$method = $_SERVER['REQUEST_METHOD'];
// ── Shared filter builder ──────────────────────────────────────────────
// Returns [sql_conditions[], params[]] for the WHERE clauses.
function build_filters(array $opts): array {
$conds = [];
$params = [];
// Kinds filter
if (!empty($opts['kinds'])) {
$kinds = is_array($opts['kinds']) ? $opts['kinds'] : explode(',', $opts['kinds']);
$kinds = array_map('intval', $kinds);
$kinds = array_filter($kinds, fn($v) => $v > 0);
if (!empty($kinds)) {
$placeholders = [];
foreach ($kinds as $i => $k) {
$key = ':kind_' . $i;
$placeholders[] = $key;
$params[$key] = $k;
}
$conds[] = 'e.kind IN (' . implode(',', $placeholders) . ')';
}
}
// Date/time range filter (supports YYYY-MM-DD or YYYY-MM-DD HH:MM)
$from_date = $opts['from_date'] ?? '';
$to_date = $opts['to_date'] ?? '';
if ($from_date !== '') {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from_date)) {
$conds[] = 'e.created_at >= EXTRACT(EPOCH FROM :from_date::date)::BIGINT';
$params[':from_date'] = $from_date;
} elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $from_date)) {
$conds[] = 'e.created_at >= EXTRACT(EPOCH FROM :from_date::timestamp)::BIGINT';
$params[':from_date'] = $from_date . ':00';
}
}
if ($to_date !== '') {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $to_date)) {
$conds[] = 'e.created_at < (EXTRACT(EPOCH FROM :to_date::date)::BIGINT + 86400)';
$params[':to_date'] = $to_date;
} elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $to_date)) {
$conds[] = 'e.created_at < EXTRACT(EPOCH FROM :to_date::timestamp)::BIGINT';
$params[':to_date'] = $to_date . ':00';
}
}
// Follows filter
$follows_filter = $opts['follows_filter'] ?? 'all';
if ($follows_filter === 'follows') {
$conds[] = 'e.pubkey IN (SELECT pubkey FROM caching_followed_pubkeys)';
} elseif ($follows_filter === 'non_follows') {
$conds[] = 'e.pubkey NOT IN (SELECT pubkey FROM caching_followed_pubkeys)';
}
return [$conds, $params];
}
// ── Build a WHERE clause string from conditions ────────────────────────
function where_clause(array $conds): string {
if (empty($conds)) return '';
return 'AND ' . implode("\n AND ", $conds);
}
// ── Format bytes as human-readable ─────────────────────────────────────
function format_bytes(int $bytes): string {
if ($bytes < 1024) return $bytes . ' B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB';
return round($bytes / 1073741824, 2) . ' GB';
}
// ── GET: Preview ───────────────────────────────────────────────────────
if ($method === 'GET') {
$opts = [
'follows_filter' => $_GET['follows_filter'] ?? 'all',
'kinds' => $_GET['kinds'] ?? '',
'from_date' => $_GET['from_date'] ?? '',
'to_date' => $_GET['to_date'] ?? '',
'max_events' => intval($_GET['max_events'] ?? 0),
];
list($conds, $params) = build_filters($opts);
$where = where_clause($conds);
// Preview SQL (for display)
$sql_preview = "SELECT COUNT(*) AS match_count,\n"
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1\n"
. ($where ? " $where\n" : '');
// Count + size query
$count_sql = "SELECT COUNT(*) AS match_count,\n"
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where";
try {
$stmt = $pdo->prepare($count_sql);
$stmt->execute($params);
$row = $stmt->fetch();
$match_count = intval($row['match_count'] ?? 0);
$total_size_bytes = intval($row['total_size_bytes'] ?? 0);
} catch (PDOException $e) {
json_response(['error' => 'Preview query failed: ' . $e->getMessage()]);
exit;
}
// Kind breakdown
$breakdown = [];
if ($match_count > 0) {
$breakdown_sql = "SELECT e.kind,\n"
. " COUNT(*) AS count,\n"
. " COALESCE(SUM(pg_column_size(e.event_json)), 0) AS size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where\n"
. "GROUP BY e.kind\n"
. "ORDER BY count DESC\n"
. "LIMIT 50";
try {
$stmt = $pdo->prepare($breakdown_sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
foreach ($rows as $r) {
$breakdown[] = [
'kind' => intval($r['kind']),
'count' => intval($r['count']),
'size_bytes' => intval($r['size_bytes']),
];
}
} catch (PDOException $e) {
// Breakdown is non-critical
}
}
// If a query_id was provided, update last_preview_count and last_preview_size_bytes
$query_id = intval($_GET['query_id'] ?? 0);
if ($query_id > 0) {
try {
$pdo->prepare(
"UPDATE cleanup_saved_queries\n"
. " SET last_preview_count = :count,\n"
. " last_preview_size_bytes = :size\n"
. " WHERE id = :id"
)->execute([
':id' => $query_id,
':count' => $match_count,
':size' => $total_size_bytes,
]);
} catch (PDOException $e) {}
}
json_response([
'match_count' => $match_count,
'total_size_bytes' => $total_size_bytes,
'total_size_human' => format_bytes($total_size_bytes),
'avg_size_per_event' => $match_count > 0 ? intval($total_size_bytes / $match_count) : 0,
'kinds_breakdown' => $breakdown,
'sql_preview' => $sql_preview,
]);
exit;
}
// ── POST: Execute (or dry-run) ─────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
if (!$body) {
json_response(['error' => 'Invalid JSON body']);
exit;
}
$dry_run = !empty($body['dry_run']);
$opts = [
'follows_filter' => $body['follows_filter'] ?? 'all',
'kinds' => $body['kinds'] ?? [],
'from_date' => $body['from_date'] ?? '',
'to_date' => $body['to_date'] ?? '',
'max_events' => intval($body['max_events'] ?? 0),
];
list($conds, $params) = build_filters($opts);
$where = where_clause($conds);
// If dry_run, return preview (same as GET)
if ($dry_run) {
$count_sql = "SELECT COUNT(*) AS match_count,\n"
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where";
try {
$stmt = $pdo->prepare($count_sql);
$stmt->execute($params);
$row = $stmt->fetch();
$match_count = intval($row['match_count'] ?? 0);
$total_size_bytes = intval($row['total_size_bytes'] ?? 0);
} catch (PDOException $e) {
json_response(['error' => 'Preview query failed: ' . $e->getMessage()]);
exit;
}
// Kind breakdown
$breakdown = [];
if ($match_count > 0) {
$breakdown_sql = "SELECT e.kind, COUNT(*) AS count,\n"
. " COALESCE(SUM(pg_column_size(e.event_json)), 0) AS size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where\n"
. "GROUP BY e.kind\n"
. "ORDER BY count DESC\n"
. "LIMIT 50";
try {
$stmt = $pdo->prepare($breakdown_sql);
$stmt->execute($params);
foreach ($stmt->fetchAll() as $r) {
$breakdown[] = [
'kind' => intval($r['kind']),
'count' => intval($r['count']),
'size_bytes' => intval($r['size_bytes']),
];
}
} catch (PDOException $e) {}
}
json_response([
'match_count' => $match_count,
'total_size_bytes' => $total_size_bytes,
'total_size_human' => format_bytes($total_size_bytes),
'avg_size_per_event' => $match_count > 0 ? intval($total_size_bytes / $match_count) : 0,
'kinds_breakdown' => $breakdown,
'dry_run' => true,
]);
exit;
}
// ── Actual DELETE ──────────────────────────────────────────────────
$max_events = intval($body['max_events'] ?? 0);
$limit_clause = $max_events > 0 ? 'LIMIT :max_events' : '';
// First, get the count and size of what will be deleted
$preview_sql = "SELECT COUNT(*) AS match_count,\n"
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where";
try {
$stmt = $pdo->prepare($preview_sql);
$stmt->execute($params);
$row = $stmt->fetch();
$expected_count = intval($row['match_count'] ?? 0);
$expected_bytes = intval($row['total_size_bytes'] ?? 0);
} catch (PDOException $e) {
json_response(['error' => 'Pre-delete count failed: ' . $e->getMessage()]);
exit;
}
// Build the DELETE using id IN (subquery) for safe LIMIT + ORDER BY
$delete_sql = "DELETE FROM events e\n"
. "WHERE e.id IN (\n"
. " SELECT e2.id FROM events e2\n"
. " WHERE 1=1 $where\n"
. " ORDER BY e2.created_at ASC\n"
. " $limit_clause\n"
. ")";
$start_time = microtime(true);
try {
$stmt = $pdo->prepare($delete_sql);
if ($max_events > 0) {
$params[':max_events'] = $max_events;
}
$stmt->execute($params);
$deleted_count = $stmt->rowCount();
} catch (PDOException $e) {
json_response(['error' => 'Delete query failed: ' . $e->getMessage()]);
exit;
}
$duration_ms = round((microtime(true) - $start_time) * 1000);
// If we deleted fewer than expected, the actual freed bytes are proportional
$freed_bytes = $expected_count > 0
? intval($expected_bytes * ($deleted_count / $expected_count))
: 0;
// Clear stats cache so the dashboard reflects changes immediately
$cache_dir = __DIR__ . '/../cache';
foreach (['stats_kinds.json', 'stats_pubkeys.json'] as $cache_file) {
$path = $cache_dir . '/' . $cache_file;
if (is_file($path)) @unlink($path);
}
json_response([
'deleted_count' => $deleted_count,
'freed_bytes' => $freed_bytes,
'freed_human' => format_bytes($freed_bytes),
'duration_ms' => $duration_ms,
]);
exit;
}
// Unsupported method
json_response(['error' => 'Method not allowed']);
+345
View File
@@ -0,0 +1,345 @@
<?php
/**
* admin2/api/cleanup_queries.php — Saved Cleanup Query CRUD.
*
* GET — List all saved queries.
* POST — Create/update/delete/execute saved queries.
*/
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
$method = $_SERVER['REQUEST_METHOD'];
/** Format bytes as human-readable. */
function fmt_bytes(int $bytes): string {
if ($bytes < 1024) return $bytes . ' B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB';
return round($bytes / 1073741824, 2) . ' GB';
}
// ── GET: List all saved queries ────────────────────────────────────────
if ($method === 'GET') {
try {
$rows = $pdo->query(
"SELECT id, name, follows_filter, kinds, from_date, to_date, max_events,\n"
. " last_preview_count, last_preview_size_bytes,\n"
. " last_executed_at, created_at, updated_at\n"
. " FROM cleanup_saved_queries\n"
. " ORDER BY updated_at DESC"
)->fetchAll();
} catch (PDOException $e) {
// Table may not exist yet
json_response(['queries' => []]);
exit;
}
$queries = [];
foreach ($rows as $r) {
$kinds = $r['kinds'];
if (is_string($kinds)) {
// PostgreSQL returns {1,7} format — parse it
$kinds = trim($kinds, '{}');
$kinds = $kinds !== '' ? array_map('intval', explode(',', $kinds)) : [];
} elseif (is_resource($kinds)) {
$kinds = [];
}
$queries[] = [
'id' => intval($r['id']),
'name' => $r['name'],
'follows_filter' => $r['follows_filter'],
'kinds' => $kinds,
'from_date' => $r['from_date'] ?? '',
'to_date' => $r['to_date'] ?? '',
'max_events' => intval($r['max_events']),
'last_preview_count' => intval($r['last_preview_count']),
'last_preview_size_human' => fmt_bytes(intval($r['last_preview_size_bytes'])),
'last_executed_at' => intval($r['last_executed_at']) > 0
? date('Y-m-d H:i:s', intval($r['last_executed_at']))
: null,
'created_at' => date('Y-m-d H:i:s', intval($r['created_at'])),
'updated_at' => date('Y-m-d H:i:s', intval($r['updated_at'])),
];
}
json_response(['queries' => $queries]);
exit;
}
// ── POST: Actions ──────────────────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['action'])) {
json_response(['error' => 'Missing action']);
exit;
}
$action = $body['action'];
// ── Save (create or update) ────────────────────────────────────────
if ($action === 'save') {
$id = $body['id'] ?? null;
$name = trim($body['name'] ?? '');
if ($name === '') {
json_response(['error' => 'Name is required']);
exit;
}
$follows_filter = $body['follows_filter'] ?? 'all';
if (!in_array($follows_filter, ['all', 'follows', 'non_follows'])) {
$follows_filter = 'all';
}
$kinds = $body['kinds'] ?? [];
if (is_array($kinds)) {
$kinds = array_map('intval', $kinds);
$kinds = array_filter($kinds, fn($v) => $v > 0);
$kinds = array_values($kinds);
} else {
$kinds = [];
}
// PostgreSQL array literal
$kinds_pg = '{' . implode(',', $kinds) . '}';
$from_date = $body['from_date'] ?? '';
$to_date = $body['to_date'] ?? '';
$max_events = intval($body['max_events'] ?? 0);
try {
if ($id) {
// Update existing
$stmt = $pdo->prepare(
"UPDATE cleanup_saved_queries\n"
. " SET name = :name,\n"
. " follows_filter = :follows_filter,\n"
. " kinds = :kinds::integer[],\n"
. " from_date = :from_date,\n"
. " to_date = :to_date,\n"
. " max_events = :max_events,\n"
. " updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT\n"
. " WHERE id = :id"
);
$stmt->execute([
':id' => $id,
':name' => $name,
':follows_filter' => $follows_filter,
':kinds' => $kinds_pg,
':from_date' => $from_date,
':to_date' => $to_date,
':max_events' => $max_events,
]);
} else {
// Insert new
$stmt = $pdo->prepare(
"INSERT INTO cleanup_saved_queries\n"
. " (name, follows_filter, kinds, from_date, to_date, max_events)\n"
. "VALUES (:name, :follows_filter, :kinds::integer[],\n"
. " :from_date, :to_date, :max_events)\n"
. "ON CONFLICT (name) DO UPDATE SET\n"
. " follows_filter = EXCLUDED.follows_filter,\n"
. " kinds = EXCLUDED.kinds,\n"
. " from_date = EXCLUDED.from_date,\n"
. " to_date = EXCLUDED.to_date,\n"
. " max_events = EXCLUDED.max_events,\n"
. " updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT"
);
$stmt->execute([
':name' => $name,
':follows_filter' => $follows_filter,
':kinds' => $kinds_pg,
':from_date' => $from_date,
':to_date' => $to_date,
':max_events' => $max_events,
]);
$id = $pdo->lastInsertId();
}
} catch (PDOException $e) {
json_response(['error' => 'Save failed: ' . $e->getMessage()]);
exit;
}
json_response(['ok' => true, 'id' => intval($id)]);
exit;
}
// ── Delete ─────────────────────────────────────────────────────────
if ($action === 'delete') {
$id = intval($body['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => 'Invalid id']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM cleanup_saved_queries WHERE id = :id");
$stmt->execute([':id' => $id]);
} catch (PDOException $e) {
json_response(['error' => 'Delete failed: ' . $e->getMessage()]);
exit;
}
json_response(['ok' => true]);
exit;
}
// ── Execute (run a saved query as non-dry-run DELETE) ──────────────
if ($action === 'execute') {
$id = intval($body['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => 'Invalid id']);
exit;
}
// Load the saved query
try {
$stmt = $pdo->prepare(
"SELECT id, name, follows_filter, kinds, from_date, to_date, max_events\n"
. " FROM cleanup_saved_queries\n"
. " WHERE id = :id"
);
$stmt->execute([':id' => $id]);
$query = $stmt->fetch();
} catch (PDOException $e) {
json_response(['error' => 'Query load failed: ' . $e->getMessage()]);
exit;
}
if (!$query) {
json_response(['error' => 'Saved query not found']);
exit;
}
// Parse kinds from PG array
$kinds_raw = $query['kinds'];
if (is_string($kinds_raw)) {
$kinds_raw = trim($kinds_raw, '{}');
$kinds = $kinds_raw !== '' ? array_map('intval', explode(',', $kinds_raw)) : [];
} else {
$kinds = [];
}
// Build filters (same logic as cleanup.php)
$conds = [];
$params = [];
if (!empty($kinds)) {
$kind_placeholders = [];
foreach ($kinds as $i => $k) {
$key = ':kind_' . $i;
$kind_placeholders[] = $key;
$params[$key] = $k;
}
$conds[] = 'e.kind IN (' . implode(',', $kind_placeholders) . ')';
}
$from_date = $query['from_date'] ?? '';
$to_date = $query['to_date'] ?? '';
if ($from_date !== '') {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from_date)) {
$conds[] = 'e.created_at >= EXTRACT(EPOCH FROM :from_date::date)::BIGINT';
$params[':from_date'] = $from_date;
} elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $from_date)) {
$conds[] = 'e.created_at >= EXTRACT(EPOCH FROM :from_date::timestamp)::BIGINT';
$params[':from_date'] = $from_date . ':00';
}
}
if ($to_date !== '') {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $to_date)) {
$conds[] = 'e.created_at < (EXTRACT(EPOCH FROM :to_date::date)::BIGINT + 86400)';
$params[':to_date'] = $to_date;
} elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $to_date)) {
$conds[] = 'e.created_at < EXTRACT(EPOCH FROM :to_date::timestamp)::BIGINT';
$params[':to_date'] = $to_date . ':00';
}
}
$follows_filter = $query['follows_filter'];
if ($follows_filter === 'follows') {
$conds[] = 'e.pubkey IN (SELECT pubkey FROM caching_followed_pubkeys)';
} elseif ($follows_filter === 'non_follows') {
$conds[] = 'e.pubkey NOT IN (SELECT pubkey FROM caching_followed_pubkeys)';
}
$where = !empty($conds) ? 'AND ' . implode("\n AND ", $conds) : '';
$max_events = intval($query['max_events']);
$limit_clause = $max_events > 0 ? 'LIMIT :max_events' : '';
// Pre-delete count
$preview_sql = "SELECT COUNT(*) AS match_count,\n"
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
. "FROM events e\n"
. "WHERE 1=1 $where";
try {
$stmt = $pdo->prepare($preview_sql);
$stmt->execute($params);
$row = $stmt->fetch();
$expected_count = intval($row['match_count'] ?? 0);
$expected_bytes = intval($row['total_size_bytes'] ?? 0);
} catch (PDOException $e) {
json_response(['error' => 'Pre-delete count failed: ' . $e->getMessage()]);
exit;
}
// Execute DELETE
$delete_sql = "DELETE FROM events e\n"
. "WHERE e.id IN (\n"
. " SELECT e2.id FROM events e2\n"
. " WHERE 1=1 $where\n"
. " ORDER BY e2.created_at ASC\n"
. " $limit_clause\n"
. ")";
$start_time = microtime(true);
try {
$stmt = $pdo->prepare($delete_sql);
if ($max_events > 0) {
$params[':max_events'] = $max_events;
}
$stmt->execute($params);
$deleted_count = $stmt->rowCount();
} catch (PDOException $e) {
json_response(['error' => 'Delete failed: ' . $e->getMessage()]);
exit;
}
$duration_ms = round((microtime(true) - $start_time) * 1000);
$freed_bytes = $expected_count > 0
? intval($expected_bytes * ($deleted_count / $expected_count))
: 0;
// Update last_preview_count, last_preview_size_bytes, last_executed_at
try {
$pdo->prepare(
"UPDATE cleanup_saved_queries\n"
. " SET last_preview_count = :count,\n"
. " last_preview_size_bytes = :size,\n"
. " last_executed_at = EXTRACT(EPOCH FROM NOW())::BIGINT\n"
. " WHERE id = :id"
)->execute([
':id' => $id,
':count' => $expected_count,
':size' => $expected_bytes,
]);
} catch (PDOException $e) {}
// Clear stats cache so the dashboard reflects changes immediately
$cache_dir = __DIR__ . '/../cache';
foreach (['stats_kinds.json', 'stats_pubkeys.json'] as $cache_file) {
$path = $cache_dir . '/' . $cache_file;
if (is_file($path)) @unlink($path);
}
json_response([
'deleted_count' => $deleted_count,
'freed_bytes' => $freed_bytes,
'freed_human' => fmt_bytes($freed_bytes),
'duration_ms' => $duration_ms,
]);
exit;
}
json_response(['error' => 'Unknown action: ' . $action]);
exit;
}
json_response(['error' => 'Method not allowed']);
+26 -3
View File
@@ -9,10 +9,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$value = $input['value'] ?? '';
if (!$key) json_response(['error' => 'Missing key']);
try {
$pdo->prepare("UPDATE config SET value = ? WHERE key = ?")->execute([$value, $key]);
$pdo->beginTransaction();
$updated = $pdo->prepare("UPDATE config SET value = ?, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT WHERE key = ?")
->execute([$value, $key]);
if (!$updated) {
throw new RuntimeException("Failed to update $key");
}
// The caching daemon polls this generation value and reloads config
// independently of whether the relay's main process is restarted.
if (in_array($key, [
'caching_backfill_enabled',
'caching_inbox_enabled',
'caching_live_enabled',
'caching_live_strategy',
'caching_live_kinds',
'caching_live_since_seconds',
'caching_live_limit',
'caching_backfill_page_size',
'caching_backfill_tick_interval_ms'
], true)) {
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT WHERE key = 'caching_config_generation'");
}
$pdo->commit();
json_response(['message' => "Updated $key"]);
} catch (PDOException $e) {
json_response(['error' => $e->getMessage()]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
json_response(['error' => $e->getMessage()], 500);
}
}
+6 -2
View File
@@ -12,17 +12,21 @@ $params = [];
if ($kind !== '') { $where[] = 'kind = ?'; $params[] = intval($kind); }
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$rows = $pdo->prepare("SELECT e.id, e.pubkey, e.kind, e.created_at, e.content,
// Order by first_seen DESC to show most recently received events first,
// avoiding corrupted created_at timestamps (e.g. far-future values) that
// would otherwise dominate the ordering.
$rows = $pdo->prepare("SELECT e.id, e.pubkey, e.kind, e.created_at, e.first_seen, e.content,
p.name, p.display_name
FROM events e
LEFT JOIN profiles p ON p.pubkey = e.pubkey
$where_sql
ORDER BY e.created_at DESC LIMIT $limit OFFSET $offset");
ORDER BY e.first_seen DESC LIMIT $limit OFFSET $offset");
$rows->execute($params);
$events = $rows->fetchAll();
foreach ($events as &$e) {
$e['created_at'] = date('Y-m-d H:i:s', intval($e['created_at']));
$e['first_seen'] = date('Y-m-d H:i:s', intval($e['first_seen']));
$e['content'] = substr($e['content'] ?? '', 0, 200);
// Resolve display name from profiles cache; fall back to truncated pubkey.
$best = profile_display_name($e);
+238
View File
@@ -0,0 +1,238 @@
<?php
/**
* admin/api/kind_1_report.php — Markdown relay status report.
*
* Returns a markdown-formatted relay status report suitable for use as
* a kind 1 event content. Includes:
* - Relay name header
* - 1H ASCII chart (event rate)
* - Database overview
* - Top 10 event kinds
* - Time-based statistics
* - Top pubkeys
*
* Accessible at: /relay/admin/api/kind_1_report.php
*/
require_once __DIR__ . '/../lib/helpers.php';
require_once __DIR__ . '/../lib/ascii_chart.php';
header('Content-Type: text/plain; charset=utf-8');
$pdo = db();
// --- Relay info ---
$relay_name = 'C-Relay-PG';
$relay_version = '';
try {
$relay_name = $pdo->query("SELECT value FROM config WHERE key = 'relay_name'")->fetchColumn() ?: 'C-Relay-PG';
$relay_version = $pdo->query("SELECT value FROM config WHERE key = 'relay_version'")->fetchColumn() ?: '';
} catch (PDOException $e) {}
$display_name = $relay_version ? "$relay_name v$relay_version" : $relay_name;
// --- Total events ---
$total_events = 0;
try {
$total_events = intval($pdo->query("SELECT COUNT(*) FROM events")->fetchColumn());
} catch (PDOException $e) {}
// --- Database size ---
$db_size = '-';
try {
$bytes = intval($pdo->query("SELECT pg_database_size(current_database())")->fetchColumn());
if ($bytes < 1073741824) {
$db_size = round($bytes / 1048576, 1) . ' MB';
} else {
$db_size = round($bytes / 1073741824, 2) . ' GB';
}
} catch (PDOException $e) {}
// --- Process ID ---
$process_id = '-';
try {
$pid = $pdo->query("SELECT pg_backend_pid()")->fetchColumn();
$process_id = strval($pid);
} catch (PDOException $e) {}
// --- WebSocket connections (estimate from pg_class) ---
$ws_connections = 0;
try {
$est = intval($pdo->query("SELECT reltuples FROM pg_class WHERE relname = 'subscriptions'")->fetchColumn());
$ws_connections = max(0, intval($est * 0.2));
} catch (PDOException $e) {}
// --- Active subscriptions (same estimate) ---
$active_subscriptions = $ws_connections;
// --- Memory usage ---
$memory_usage = '-';
$mem_total = 0;
$mem_avail = 0;
$meminfo = @file_get_contents('/proc/meminfo');
if ($meminfo !== false) {
// /proc/meminfo uses "Key: Value kB" format (colon, not equals sign)
foreach (explode("\n", $meminfo) as $line) {
if (preg_match('/^MemTotal:\s+(\d+)\s+kB/i', $line, $m)) {
$mem_total = intval($m[1]) * 1024;
} elseif (preg_match('/^MemAvailable:\s+(\d+)\s+kB/i', $line, $m)) {
$mem_avail = intval($m[1]) * 1024;
}
}
}
if ($mem_total > 0) {
$used = $mem_total - $mem_avail;
if ($used < 1073741824) {
$memory_usage = round($used / 1048576, 1) . ' MB / ' . round($mem_total / 1073741824, 2) . ' GB';
} else {
$memory_usage = round($used / 1073741824, 2) . ' GB / ' . round($mem_total / 1073741824, 2) . ' GB';
}
}
// --- CPU ---
$cpu_usage = '-';
$cpu_core = '-';
if (is_readable('/proc/cpuinfo')) {
$cores = intval(@shell_exec('nproc 2>/dev/null') ?: 1);
$cpu_core = strval($cores) . ' cores';
}
$load = @file_get_contents('/proc/loadavg');
if ($load !== false) {
$cpu_usage = trim(explode(' ', $load)[0] ?? '-');
}
// --- Oldest / newest event ---
$oldest_event = '-';
$newest_event = '-';
try {
$row = $pdo->query("SELECT MIN(created_at) AS oldest, MAX(created_at) AS newest FROM events")->fetch();
if ($row && $row['oldest']) $oldest_event = date('Y-m-d H:i:s', intval($row['oldest']));
if ($row && $row['newest']) $newest_event = date('Y-m-d H:i:s', intval($row['newest']));
} catch (PDOException $e) {}
// --- Time-based stats ---
$now = time();
$events_24h = 0; $events_7d = 0; $events_30d = 0;
try {
$events_24h = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 86400")->fetchColumn());
$events_7d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 604800")->fetchColumn());
$events_30d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 2592000")->fetchColumn());
} catch (PDOException $e) {}
// --- Kind distribution (top 10) ---
$kinds = [];
try {
$rows = $pdo->query("SELECT kind, COUNT(*) AS cnt FROM events GROUP BY kind ORDER BY cnt DESC LIMIT 10")->fetchAll();
foreach ($rows as $r) {
$kinds[] = ['kind' => intval($r['kind']), 'count' => intval($r['cnt']), 'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0];
}
} catch (PDOException $e) {}
// --- Top pubkeys (top 20) ---
$top_pubkeys = [];
try {
$rows = $pdo->query("
SELECT pubkey, COUNT(*) AS cnt
FROM events
GROUP BY pubkey
ORDER BY cnt DESC LIMIT 20
")->fetchAll();
$pubkeys = array_column($rows, 'pubkey');
$pmap = profile_map($pubkeys);
foreach ($rows as $r) {
$pk = $r['pubkey'];
$prof = $pmap[$pk] ?? null;
$top_pubkeys[] = [
'pubkey' => $pk,
'name' => $prof ? $prof['best_name'] : '',
'count' => intval($r['cnt']),
'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0,
];
}
} catch (PDOException $e) {}
// --- 1H ASCII chart (30 bins of 2 minutes each) ---
$chart = '';
try {
$span = 3600;
$bin_size = 120; // 2 minutes
$num_bins = 30;
$epoch = $now - $span;
$base_bin = (int)floor($epoch / $bin_size);
$sql = "SELECT FLOOR(created_at / {$bin_size})::BIGINT - {$base_bin} AS bin, COUNT(*) AS cnt
FROM events
WHERE created_at >= {$epoch}
GROUP BY bin
ORDER BY bin";
$rows = $pdo->query($sql)->fetchAll();
$bins = build_bin_array($rows, $num_bins);
$bins = array_reverse($bins);
$chart = render_ascii_chart($bins, [
'title' => 'Event Rate (Last Hour)',
'max_height' => 11,
'bin_duration' => $bin_size,
'label_interval' => 5, // label every 5 bins (10 min)
]);
} catch (PDOException $e) {
$chart = "Chart unavailable\n";
}
// ================================
// BUILD MARKDOWN OUTPUT
// ================================
$output = '';
// Chart
$output .= "## laantungir.net/relay\n\n";
$output .= "```\n$chart```\n";
// Database Overview
$output .= "## Database Overview\n\n";
$output .= "| Metric | Value |\n";
$output .= "|--------|-------|\n";
$output .= "| Database Size | $db_size |\n";
$output .= "| Total Events | " . number_format($total_events) . " |\n";
$output .= "| Process ID | $process_id |\n";
$output .= "| WebSocket Connections | " . number_format($ws_connections) . " |\n";
$output .= "| Active Subscriptions | " . number_format($active_subscriptions) . " |\n";
$output .= "| Memory Usage | $memory_usage |\n";
$output .= "| CPU Usage | $cpu_usage |\n";
$output .= "| CPU Core | $cpu_core |\n";
$output .= "| Oldest Event | $oldest_event |\n";
$output .= "| Newest Event | $newest_event |\n\n";
// Event Kinds (Top 10)
$output .= "## Event Kinds (Top 10)\n\n";
$output .= "| Kind | Count | % |\n";
$output .= "|------|-------|---|\n";
foreach ($kinds as $k) {
$output .= "| {$k['kind']} | " . number_format($k['count']) . " | {$k['pct']}% |\n";
}
$output .= "\n";
// Time-Based Statistics
$output .= "## Time-Based Statistics\n\n";
$output .= "| Period | Events |\n";
$output .= "|--------|-------|\n";
$output .= "| 24 Hours | " . number_format($events_24h) . " |\n";
$output .= "| 7 Days | " . number_format($events_7d) . " |\n";
$output .= "| 30 Days | " . number_format($events_30d) . " |\n\n";
// Top Pubkeys
$output .= "## Top Pubkeys\n\n";
$output .= "| Name | Events | % |\n";
$output .= "|------|-------|---|\n";
foreach ($top_pubkeys as $p) {
if ($p['name']) {
$name = $p['name'];
} else {
$name = substr($p['pubkey'], 0, 4) . '..' . substr($p['pubkey'], -4);
}
$output .= "| $name | " . number_format($p['count']) . " | {$p['pct']}% |\n";
}
$output .= "\n";
// Footer
$output .= "---\n";
$output .= "_Report generated: " . date('Y-m-d H:i:s T') . "_\n";
echo $output;
+150
View File
@@ -0,0 +1,150 @@
<?php
/** admin2/api/live_subscription.php — Live subscription config API. */
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
// --- POST actions: update live subscription config ---
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$action = $input['action'] ?? '';
if ($action === 'toggle_enabled') {
// Toggle caching_live_enabled. The relay's config-change listener
// starts/stops caching_relay based on live OR backfill settings.
try {
$current = $pdo->query("SELECT value FROM config WHERE key = 'caching_live_enabled'")->fetchColumn();
$newVal = ($current === 'true') ? 'false' : 'true';
$dataType = 'boolean';
$stmt = $pdo->prepare("INSERT INTO config (key, value, data_type) VALUES ('caching_live_enabled', ?, ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
$stmt->execute([$newVal, $dataType]);
// Bump config generation to trigger hot-reload
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
WHERE key = 'caching_config_generation'");
json_response(['ok' => true, 'enabled' => $newVal === 'true']);
} catch (PDOException $e) {
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
}
} elseif ($action === 'save_config') {
$strategy = $input['strategy'] ?? '';
$kinds = $input['kinds'] ?? '';
$since = $input['since_seconds'] ?? '';
$limit = $input['limit'] ?? '';
try {
$pdo->beginTransaction();
if ($strategy === 'whitelist' || $strategy === 'cache_all') {
$stmt = $pdo->prepare("INSERT INTO config (key, value, data_type) VALUES ('caching_live_strategy', ?, 'string')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
$stmt->execute([$strategy]);
}
if ($kinds !== '') {
$stmt = $pdo->prepare("INSERT INTO config (key, value, data_type) VALUES ('caching_live_kinds', ?, 'string')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
$stmt->execute([$kinds]);
}
if ($since !== '') {
$stmt = $pdo->prepare("INSERT INTO config (key, value, data_type) VALUES ('caching_live_since_seconds', ?, 'integer')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
$stmt->execute([(string)(int)$since]);
}
if ($limit !== '') {
$stmt = $pdo->prepare("INSERT INTO config (key, value, data_type) VALUES ('caching_live_limit', ?, 'integer')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
$stmt->execute([(string)(int)$limit]);
}
// Bump config generation to trigger hot-reload
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
WHERE key = 'caching_config_generation'");
$pdo->commit();
json_response(['ok' => true, 'message' => 'Live subscription config saved.']);
} catch (PDOException $e) {
$pdo->rollBack();
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
}
} elseif ($action === 'toggle_relay') {
// Toggle live_enabled or backfill_enabled for a relay in caching_relays
$relay_url = $input['relay_url'] ?? '';
$column = $input['column'] ?? '';
if ($relay_url === '' || !in_array($column, ['live_enabled', 'backfill_enabled'])) {
json_response(['ok' => false, 'error' => 'Invalid relay_url or column'], 400);
exit;
}
try {
$stmt = $pdo->prepare("UPDATE caching_relays SET $column = NOT $column, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT WHERE relay_url = ?");
$stmt->execute([$relay_url]);
// Bump config generation to trigger hot-reload
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
WHERE key = 'caching_config_generation'");
json_response(['ok' => true, 'message' => 'Relay toggled.']);
} catch (PDOException $e) {
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
}
} else {
json_response(['ok' => false, 'error' => 'Unknown action'], 400);
}
exit;
}
// --- GET: return current live subscription config + status ---
$config = [];
try {
$cfg_rows = $pdo->query("SELECT key, value FROM config WHERE key IN (
'caching_live_strategy','caching_live_kinds','caching_live_since_seconds',
'caching_live_limit','caching_live_enabled',
'caching_kinds','caching_bootstrap_relays'
)")->fetchAll();
foreach ($cfg_rows as $r) { $config[$r['key']] = $r['value']; }
} catch (PDOException $e) {}
// Live subscription status from caching_service_state
$state = [];
try { $state = $pdo->query("SELECT * FROM caching_service_state WHERE id = 1")->fetch() ?: []; } catch (PDOException $e) {}
// Unified relay list from caching_relays table
$relays = [];
try {
$relays = $pdo->query("
SELECT relay_url, live_enabled, backfill_enabled, status_code, status_text, follow_count, is_bootstrap
FROM caching_relays
ORDER BY follow_count DESC, relay_url
")->fetchAll();
} catch (PDOException $e) {}
// If caching_relays table is empty (migration not yet run), fall back to old sources
if (empty($relays)) {
// Upstream relay status (old table)
$upstreamRelays = [];
try {
$upstreamRelays = $pdo->query("
SELECT relay_url, status_code, status_text, updated_at
FROM caching_upstream_relays
ORDER BY relay_url
")->fetchAll();
} catch (PDOException $e) {}
// Discovered relays with follow counts (from backfill progress)
$discoveredRelays = [];
try {
$discoveredRelays = $pdo->query("
SELECT relay_url, COUNT(DISTINCT author_pubkey) AS follow_count
FROM caching_backfill_relay_progress
GROUP BY relay_url
ORDER BY follow_count DESC, relay_url
")->fetchAll();
} catch (PDOException $e) {}
json_response([
'config' => $config,
'state' => $state,
'upstreamRelays' => $upstreamRelays,
'discoveredRelays' => $discoveredRelays,
'relays' => [],
]);
} else {
json_response([
'config' => $config,
'state' => $state,
'relays' => $relays,
]);
}
+80 -46
View File
@@ -24,46 +24,66 @@ try {
$events_delta = intval($pdo->query("SELECT COUNT(*) FROM events WHERE first_seen >= EXTRACT(EPOCH FROM NOW())::BIGINT - 10")->fetchColumn());
} catch (PDOException $e) {}
// Process info (from pg_stat_activity)
// Process info
$process_id = '-';
$ws_connections = '-';
try {
$pid = $pdo->query("SELECT pg_backend_pid()")->fetchColumn();
$process_id = strval($pid);
$ws_connections = intval($pdo->query("SELECT count(*) FROM pg_stat_activity WHERE state = 'active' AND pid != pg_backend_pid()")->fetchColumn());
} catch (PDOException $e) {}
// Active subscriptions
$active_subscriptions = 0;
// WebSocket connections: tracked in-memory by the relay (g_connection_count).
// The subscriptions table is a 32M-row historical log, not a live state table.
// Use pg_class for an instant estimate (updated by autovacuum).
$ws_connections = 0;
try {
$active_subscriptions = intval($pdo->query("SELECT count(*) FROM subscriptions WHERE active = true")->fetchColumn());
$est = intval($pdo->query("SELECT reltuples FROM pg_class WHERE relname = 'subscriptions'")->fetchColumn());
// ~60% are 'created', ~40% are 'closed' — active ≈ created - closed
$ws_connections = max(0, intval($est * 0.2));
} catch (PDOException $e) {}
// Active subscriptions: same estimate approach.
$active_subscriptions = $ws_connections;
// Memory/CPU (from /proc on Linux)
$memory_usage = '-';
$cpu_usage = '-';
$cpu_core = '-';
if (is_readable('/proc/meminfo')) {
$mem = parse_ini_file('/proc/meminfo');
$total = intval($mem['MemTotal'] ?? 0) * 1024;
$avail = intval($mem['MemAvailable'] ?? 0) * 1024;
if ($total > 0) $memory_usage = format_bytes($total - $avail) . ' / ' . format_bytes($total);
$mem_total = 0;
$mem_avail = 0;
$meminfo = @file_get_contents('/proc/meminfo');
if ($meminfo !== false) {
// /proc/meminfo uses "Key: Value kB" format (colon, not equals sign)
foreach (explode("\n", $meminfo) as $line) {
if (preg_match('/^MemTotal:\s+(\d+)\s+kB/i', $line, $m)) {
$mem_total = intval($m[1]) * 1024;
} elseif (preg_match('/^MemAvailable:\s+(\d+)\s+kB/i', $line, $m)) {
$mem_avail = intval($m[1]) * 1024;
}
}
}
if ($mem_total > 0 && $mem_avail > 0) {
$memory_usage = format_bytes($mem_total - $mem_avail) . ' / ' . format_bytes($mem_total);
} elseif ($mem_total > 0) {
$memory_usage = format_bytes($mem_total);
}
if (is_readable('/proc/cpuinfo')) {
$cores = intval(shell_exec('nproc 2>/dev/null') ?: 1);
$cores = intval(@shell_exec('nproc 2>/dev/null') ?: 1);
$cpu_core = strval($cores) . ' cores';
}
$cpu_usage = @file_get_contents('/proc/loadavg');
if ($cpu_usage !== false) $cpu_usage = trim(explode(' ', $cpu_usage)[0] ?? '-');
// Oldest / newest event
// Oldest / newest event (single query)
$oldest_event = '-';
$newest_event = '-';
try {
$oldest = $pdo->query("SELECT to_timestamp(MIN(created_at)) FROM events")->fetchColumn();
$newest = $pdo->query("SELECT to_timestamp(MAX(created_at)) FROM events")->fetchColumn();
if ($oldest) $oldest_event = substr($oldest, 0, 19);
if ($newest) $newest_event = substr($newest, 0, 19);
$row = $pdo->query("SELECT MIN(created_at) AS oldest, MAX(created_at) AS newest FROM events")->fetch();
if ($row && $row['oldest']) {
$oldest_event = date('Y-m-d H:i:s', intval($row['oldest']));
}
if ($row && $row['newest']) {
$newest_event = date('Y-m-d H:i:s', intval($row['newest']));
}
} catch (PDOException $e) {}
// Time-based stats
@@ -75,38 +95,52 @@ try {
$events_30d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 2592000")->fetchColumn());
} catch (PDOException $e) {}
// Kind distribution
// Kind distribution — cached to file, refreshed every 5 minutes
$kinds = [];
try {
$rows = $pdo->query("SELECT kind, COUNT(*) AS cnt FROM events GROUP BY kind ORDER BY cnt DESC LIMIT 20")->fetchAll();
foreach ($rows as $r) {
$kinds[] = ['kind' => intval($r['kind']), 'count' => intval($r['cnt']), 'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0];
}
} catch (PDOException $e) {}
$cache_dir = __DIR__ . '/../cache';
if (!is_dir($cache_dir)) @mkdir($cache_dir, 0755, true);
$kinds_cache = $cache_dir . '/stats_kinds.json';
$kinds_ttl = 300;
if (is_readable($kinds_cache) && (time() - filemtime($kinds_cache)) < $kinds_ttl) {
$kinds = json_decode(file_get_contents($kinds_cache), true) ?: [];
} else {
try {
$rows = $pdo->query("SELECT kind, COUNT(*) AS cnt FROM events GROUP BY kind ORDER BY cnt DESC LIMIT 20")->fetchAll();
foreach ($rows as $r) {
$kinds[] = ['kind' => intval($r['kind']), 'count' => intval($r['cnt']), 'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0];
}
@file_put_contents($kinds_cache, json_encode($kinds));
} catch (PDOException $e) {}
}
// Top pubkeys (with names from profiles cache)
// Top pubkeys — cached to file, refreshed every 5 minutes
$top_pubkeys = [];
try {
$rows = $pdo->query("
SELECT pubkey, COUNT(*) AS cnt
FROM events
GROUP BY pubkey
ORDER BY cnt DESC LIMIT 20
")->fetchAll();
// Batch-resolve profile names from the profiles cache table.
$pubkeys = array_column($rows, 'pubkey');
$pmap = profile_map($pubkeys);
foreach ($rows as $r) {
$pk = $r['pubkey'];
$prof = $pmap[$pk] ?? null;
$top_pubkeys[] = [
'pubkey' => $pk,
'name' => $prof ? $prof['best_name'] : '',
'count' => intval($r['cnt']),
'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0,
];
}
} catch (PDOException $e) {}
$pubkeys_cache = $cache_dir . '/stats_pubkeys.json';
if (is_readable($pubkeys_cache) && (time() - filemtime($pubkeys_cache)) < $kinds_ttl) {
$top_pubkeys = json_decode(file_get_contents($pubkeys_cache), true) ?: [];
} else {
try {
$rows = $pdo->query("
SELECT pubkey, COUNT(*) AS cnt
FROM events
GROUP BY pubkey
ORDER BY cnt DESC LIMIT 20
")->fetchAll();
$pubkeys = array_column($rows, 'pubkey');
$pmap = profile_map($pubkeys);
foreach ($rows as $r) {
$pk = $r['pubkey'];
$prof = $pmap[$pk] ?? null;
$top_pubkeys[] = [
'pubkey' => $pk,
'name' => $prof ? $prof['best_name'] : '',
'count' => intval($r['cnt']),
'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0,
];
}
@file_put_contents($pubkeys_cache, json_encode($top_pubkeys));
} catch (PDOException $e) {}
}
// Name-field usage stats (from profiles cache)
$name_field_usage = ['both' => 0, 'name_only' => 0, 'display_only' => 0, 'neither' => 0, 'both_differ' => 0, 'total' => 0];
+765 -58
View File
@@ -59,7 +59,7 @@ function switchPage(pageName) {
const sections = [
'databaseStatisticsSection', 'subscriptionDetailsSection', 'div_config',
'authRulesSection', 'wotSection', 'ipBansSection', 'relayEventsSection',
'cachingSection', 'nip17DMSection', 'sqlQuerySection'
'backfillSection', 'cachingSection', 'nip17DMSection', 'cleanupSection', 'sqlQuerySection'
];
sections.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; });
@@ -69,8 +69,10 @@ function switchPage(pageName) {
'configuration': 'div_config',
'ip-bans': 'ipBansSection',
'relay-events': 'relayEventsSection',
'backfill': 'backfillSection',
'caching': 'cachingSection',
'dm': 'nip17DMSection',
'cleanup': 'cleanupSection',
'database': 'sqlQuerySection'
};
@@ -91,16 +93,27 @@ function switchPage(pageName) {
'configuration': loadConfig,
'ip-bans': loadIpBans,
'relay-events': loadEvents,
'backfill': loadBackfill,
'caching': loadCaching,
'dm': loadDMs,
'cleanup': loadCleanupQueries,
};
if (loaders[pageName]) loaders[pageName]();
// Start/stop auto-refresh for statistics
if (pageName === 'statistics') {
// Auto-refresh: one interval that calls the right loader for the current page.
// When switching between statistics, backfill, or caching, the interval keeps running
// but picks the correct loader based on currentPage.
if (pageName === 'statistics' || pageName === 'backfill' || pageName === 'caching') {
if (pageName !== 'statistics') {
// Load immediately on switch for non-statistics pages
if (loaders[pageName]) loaders[pageName]();
}
if (!statsInterval) {
loadStats();
statsInterval = setInterval(loadStats, REFRESH_MS);
statsInterval = setInterval(() => {
if (currentPage === 'statistics') loadStats();
else if (currentPage === 'backfill') loadBackfill();
else if (currentPage === 'caching') loadCaching();
}, REFRESH_MS);
console.log('[admin2] auto-refresh started, interval:', REFRESH_MS, 'ms');
}
} else {
@@ -409,40 +422,63 @@ async function loadEvents() {
}
// ================================
// CACHING
// BACKFILL (renamed from CACHING)
// ================================
async function loadCaching() {
let backfillPage = 1;
async function loadBackfill() {
try {
const res = await fetch('api/caching.php');
const res = await fetch('api/caching.php?page=' + backfillPage);
const d = await res.json();
// Config toggle state
if (d.config) {
const enabled = d.config.caching_enabled === 'true';
const backfillEnabled = d.config.caching_backfill_enabled === 'true';
const inboxEnabled = d.config.caching_inbox_enabled === 'true';
const enLabel = document.getElementById('caching-enabled-label');
const enBtn = document.getElementById('caching-toggle-btn');
const inLabel = document.getElementById('caching-inbox-enabled-label');
const inBtn = document.getElementById('caching-inbox-toggle-btn');
if (enLabel) enLabel.textContent = 'Caching: ' + (enabled ? 'ON' : 'OFF');
if (enBtn) enBtn.textContent = enabled ? 'Turn OFF' : 'Turn ON';
if (inLabel) inLabel.textContent = 'Inbox: ' + (inboxEnabled ? 'ON' : 'OFF');
if (inBtn) inBtn.textContent = inboxEnabled ? 'Turn OFF' : 'Turn ON';
const serviceRunning = d.state?.service_state === 'running';
const enProcessRunning = backfillEnabled && serviceRunning;
const enBtn = document.getElementById('backfill-toggle-btn');
if (enBtn) {
enBtn.innerHTML = backfillEnabled
? (enProcessRunning
? '<span class="process-spinner" aria-hidden="true"></span>Turn Backfill Off'
: 'Turn Backfill Off')
: 'Turn Backfill On';
enBtn.classList.toggle('process-running', enProcessRunning);
enBtn.setAttribute('aria-pressed', backfillEnabled ? 'true' : 'false');
}
// Show relay status section only when backfill or inbox is enabled
const relayGroup = document.getElementById('backfill-relay-group');
if (relayGroup) {
relayGroup.style.display = (backfillEnabled || inboxEnabled) ? '' : 'none';
}
}
// Service status
const ssEl = document.getElementById('caching-service-status');
if (ssEl && d.state) {
const s = d.state;
const hb = s.heartbeat_at ? new Date(s.heartbeat_at * 1000).toLocaleTimeString() : '—';
ssEl.innerHTML = `<p>State: <strong>${esc(s.service_state)}</strong> | Follows: ${s.followed_author_count ?? 0} | Connected relays: ${s.connected_relay_count ?? 0}/${s.selected_relay_count ?? 0} | Backfill: ${s.backfill_authors_complete ?? 0}/${s.backfill_authors_total ?? 0} | Events fetched: ${s.events_fetched ?? 0} | Inbox inserts: ${s.inbox_inserts ?? 0} | Heartbeat: ${hb}</p>`;
}
// Inbox status
const isEl = document.getElementById('caching-inbox-status');
if (isEl && d.inbox) {
isEl.innerHTML = `<p>Pending: ${d.inbox.pending} | Live: ${d.inbox.live} | Backfill: ${d.inbox.backfill}</p>`;
// Upstream relay status window. The API reads the unified
// caching_relays table, keeping status aligned with relay controls.
const rsEl = document.getElementById('backfill-relay-status');
if (rsEl) {
if (d.upstreamRelays && d.upstreamRelays.length > 0) {
const connected = d.upstreamRelays.filter(r => r.status_code == 2).length;
const total = d.upstreamRelays.length;
rsEl.innerHTML = `<div class="relay-status-summary">Connected: ${connected}/${total}</div>` +
'<div class="relay-status-list">' +
d.upstreamRelays.map(r => {
const host = r.relay_url.replace(/^wss?:\/\//, '');
let cls = 'relay-status-unknown';
let label = esc(r.status_text || 'unknown');
if (r.status_code == 2) { cls = 'relay-status-ok'; label = 'connected'; }
else if (r.status_code == 1) { cls = 'relay-status-connecting'; label = 'connecting'; }
else if (r.status_code == 0) { cls = 'relay-status-disconnected'; label = 'disconnected'; }
else if (r.status_code < 0) { cls = 'relay-status-error'; label = esc(r.status_text || 'error'); }
return `<div class="relay-status-row ${cls}"><span class="relay-status-url" title="${esc(r.relay_url)}">${esc(host)}</span><span class="relay-status-badge">${label}</span></div>`;
}).join('') +
'</div>';
} else {
rsEl.innerHTML = '<p style="color:var(--muted-color);font-style:italic">No relay status data yet (waiting for heartbeat)</p>';
}
}
// Follows table
const tbody = document.getElementById('caching-follows-table-body');
const tbody = document.getElementById('backfill-follows-table-body');
if (tbody && d.follows) {
if (d.follows.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;font-style:italic">No followed pubkeys</td></tr>';
@@ -468,68 +504,86 @@ async function loadCaching() {
}).join('') +
'</div>';
}
const refreshBtn = `<button type="button" class="refresh-user-btn" onclick="event.stopPropagation(); refreshCachingUser('${esc(f.pubkey)}', '${esc(f.name || '')}')">↻ Refresh this user</button>`;
const refreshBtn = `<button type="button" class="refresh-user-btn" onclick="event.stopPropagation(); refreshBackfillUser('${esc(f.pubkey)}', '${esc(f.name || '')}')">↻ Refresh this user</button>`;
const rowClass = isActive ? 'follows-row follows-row-active' : 'follows-row';
const activeIcon = isActive ? '⚡ ' : '';
return `<tr class="${rowClass}" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'"><td><bdi>${activeIcon}${esc(f.name) || '<i>unknown</i>'}</bdi></td><td class="npub-link">${esc(npub)}…</td><td>${f.is_root ? '✓' : ''}</td><td>${f.total_events ?? 0}</td><td>${f.backfill_complete ? '✓' : '…'}</td><td>${relaySummary}</td></tr><tr class="follows-detail" style="display:none"><td colspan="6"><div class="follows-detail-content">${relayDetail || '<i>No relay progress data</i>'}<div class="follows-detail-actions">${refreshBtn}</div></div></td></tr>`;
}).join('');
}
}
// Active target
const fsEl = document.getElementById('caching-follows-status');
// Pagination controls
const pgEl = document.getElementById('backfill-follows-pagination');
if (pgEl && d.totalFollows > 0) {
const totalPages = Math.ceil(d.totalFollows / (d.perPage || 50));
pgEl.innerHTML = '<span class="pagination-info">Page ' + d.page + ' of ' + totalPages + ' (' + d.totalFollows + ' total)</span>' +
'<div class="pagination-buttons">' +
(d.page > 1 ? '<button type="button" onclick="backfillPage=' + (d.page - 1) + '; loadBackfill()"> Prev</button>' : '') +
(d.page < totalPages ? '<button type="button" onclick="backfillPage=' + (d.page + 1) + '; loadBackfill()">Next </button>' : '') +
'</div>';
} else if (pgEl) {
pgEl.innerHTML = '';
}
// Active target — only show backfill progress when backfill is actually enabled
const fsEl = document.getElementById('backfill-follows-status');
if (fsEl) {
if (d.active && d.active.pubkey && d.active.relay) {
const backfillOn = d.config?.caching_backfill_enabled === 'true';
if (d.active && d.active.pubkey && d.active.relay && backfillOn) {
fsEl.innerHTML = `<span class="status-working">⚡ Backfilling: ${d.active.pubkey.substring(0,16)}… @ ${esc(d.active.relay)}</span>`;
} else if (d.state && d.state.service_state === 'running') {
} else if (backfillOn && d.state && d.state.service_state === 'running') {
const incomplete = d.state.backfill_authors_complete < d.state.backfill_authors_total;
fsEl.innerHTML = incomplete
? `<span class="status-working">⚡ Backfill in progress…</span>`
: `<span class="status-complete">✓ Backfill complete — listening for live events</span>`;
: `<span class="status-complete">✓ Backfill complete</span>`;
} else if (backfillOn) {
fsEl.innerHTML = `<span class="status-complete">✓ Backfill complete</span>`;
} else {
fsEl.innerHTML = `<span class="status-complete">✓ Caching complete</span>`;
fsEl.innerHTML = `<span class="status-complete">Backfill is off</span>`;
}
}
} catch (e) { console.error('[admin2] caching error:', e); }
} catch (e) { console.error('[admin2] backfill error:', e); }
}
// Toggle caching_enabled config via the config API.
async function toggleCachingEnabled() {
// Toggle caching_backfill_enabled config via the config API.
async function toggleBackfillEnabled() {
try {
const res = await fetch('api/caching.php');
const d = await res.json();
const current = d.config?.caching_enabled === 'true';
const current = d.config?.caching_backfill_enabled === 'true';
const newVal = current ? 'false' : 'true';
await fetch('api/config.php', {
const saveRes = await fetch('api/config.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key: 'caching_enabled', value: newVal})
body: JSON.stringify({key: 'caching_backfill_enabled', value: newVal})
});
loadCaching();
} catch (e) { console.error('[admin2] toggle caching error:', e); }
if (!saveRes.ok) throw new Error('Backfill setting update failed');
loadBackfill();
} catch (e) { console.error('[admin2] toggle backfill error:', e); }
}
// Toggle caching_inbox_enabled config via the config API.
async function toggleCachingInboxEnabled() {
// Inbox is always enabled by default and is controlled from Configuration.
// This legacy handler remains unused by the Backfill page.
async function toggleBackfillInboxEnabled() {
try {
const res = await fetch('api/caching.php');
const d = await res.json();
const current = d.config?.caching_inbox_enabled === 'true';
const newVal = current ? 'false' : 'true';
await fetch('api/config.php', {
const saveRes = await fetch('api/config.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key: 'caching_inbox_enabled', value: newVal})
});
loadCaching();
if (!saveRes.ok) throw new Error('Inbox setting update failed');
loadBackfill();
} catch (e) { console.error('[admin2] toggle inbox error:', e); }
}
// Re-run all caching: resets backfill progress for all followed authors and
// Re-run all backfill: resets backfill progress for all followed authors and
// bumps caching_config_generation so the running service hot-reloads and
// re-drains from the beginning.
async function rerunAllCaching() {
async function rerunAllBackfill() {
if (!confirm('Reset backfill progress for ALL followed authors? The caching service will re-download everything from scratch.')) return;
const btn = document.getElementById('caching-rerun-all-btn');
const btn = document.getElementById('backfill-rerun-all-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Resetting…'; }
try {
const res = await fetch('api/caching.php', {
@@ -540,21 +594,21 @@ async function rerunAllCaching() {
const d = await res.json();
if (d.ok) {
if (btn) { btn.textContent = '✓ Reset — re-draining'; }
setTimeout(() => { if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; } loadCaching(); }, 2000);
setTimeout(() => { if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Backfill'; } loadBackfill(); }, 2000);
} else {
alert('Reset failed: ' + (d.error || 'unknown error'));
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Backfill'; }
}
} catch (e) {
console.error('[admin2] rerunAllCaching error:', e);
console.error('[admin2] rerunAllBackfill error:', e);
alert('Reset failed: ' + e.message);
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Backfill'; }
}
}
// Refresh a single user: resets backfill progress for one followed author
// and bumps caching_config_generation so the service re-fetches that author.
async function refreshCachingUser(pubkey, name) {
async function refreshBackfillUser(pubkey, name) {
if (!confirm('Reset backfill progress for ' + (name || pubkey.substring(0, 16) + '…') + '? The caching service will re-download this user\'s events from scratch.')) return;
try {
const res = await fetch('api/caching.php', {
@@ -564,16 +618,317 @@ async function refreshCachingUser(pubkey, name) {
});
const d = await res.json();
if (d.ok) {
loadCaching();
loadBackfill();
} else {
alert('Refresh failed: ' + (d.error || 'unknown error'));
}
} catch (e) {
console.error('[admin2] refreshCachingUser error:', e);
console.error('[admin2] refreshBackfillUser error:', e);
alert('Refresh failed: ' + e.message);
}
}
// ================================
// CACHING (Live Subscription Design)
// ================================
async function loadCaching() {
try {
const res = await fetch('api/live_subscription.php');
const d = await res.json();
// Live subscription config
if (d.config) {
const strategy = d.config.caching_live_strategy || 'whitelist';
const kinds = d.config.caching_live_kinds || d.config.caching_kinds || '';
const since = d.config.caching_live_since_seconds || '0';
const limit = d.config.caching_live_limit || '0';
const liveEnabled = d.config.caching_live_enabled === 'true';
const backfillEnabled = d.config.caching_backfill_enabled === 'true';
const serviceRunning = d.state?.service_state === 'running';
const liveProcessRunning = liveEnabled && serviceRunning;
// Toggle button state: the spinner reflects the running daemon,
// not merely a persisted configuration value.
const enBtn = document.getElementById('caching-live-toggle-btn');
if (enBtn) {
enBtn.innerHTML = liveEnabled
? (liveProcessRunning
? '<span class="process-spinner" aria-hidden="true"></span>Turn Caching Off'
: 'Turn Caching Off')
: 'Turn Caching On';
enBtn.classList.toggle('process-running', liveProcessRunning);
enBtn.setAttribute('aria-pressed', liveEnabled ? 'true' : 'false');
}
// Set strategy radio
document.querySelectorAll('input[name="caching-strategy"]').forEach(r => {
r.checked = r.value === strategy;
});
document.getElementById('caching-kinds').value = kinds;
document.getElementById('caching-since').value = since;
document.getElementById('caching-limit').value = limit;
// Update strategy note text without auto-saving
const note = document.getElementById('caching-strategy-note');
if (note) {
note.textContent = strategy === 'cache_all'
? 'All events on the relay (no authors filter). Use the Cleanup page to periodically remove unwanted data.'
: 'Only events from followed pubkeys (authors filter auto-populated from follow graph).';
}
// Update filter preview
updateCachingFilterPreview();
}
// Live subscription status from service state
const lsEl = document.getElementById('caching-live-status');
if (lsEl) {
if (d.state && d.state.service_state) {
const s = d.state;
const hb = s.heartbeat_at ? new Date(s.heartbeat_at * 1000).toLocaleTimeString() : '—';
const liveEnabled = d.config?.caching_live_enabled === 'true';
lsEl.innerHTML = `<p>Service: <strong>${esc(s.service_state)}</strong> | Live sub: ${liveEnabled ? 'ON' : 'OFF'} | Heartbeat: ${hb}</p>`;
} else {
lsEl.innerHTML = '<p style="color:var(--muted-color);font-style:italic">Waiting for caching service status…</p>';
}
}
// Relay selection list (merged with upstream status)
renderCachingRelaySelection(d);
} catch (e) { console.error('[admin2] caching error:', e); }
}
// Toggle caching_live_enabled config via the live subscription API.
async function toggleCachingLiveEnabled() {
try {
const res = await fetch('api/live_subscription.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'toggle_enabled'})
});
const d = await res.json();
if (d.ok) {
loadCaching();
} else {
alert('Toggle failed: ' + (d.error || 'unknown error'));
}
} catch (e) {
console.error('[admin2] toggleCachingLiveEnabled error:', e);
alert('Toggle failed: ' + e.message);
}
}
// Toggle a relay's live_enabled or backfill_enabled flag via the API.
// The caching service hot-reloads on config generation bump.
async function toggleRelay(relayUrl, column) {
try {
const res = await fetch('api/live_subscription.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'toggle_relay', relay_url: relayUrl, column})
});
const d = await res.json();
if (!d.ok) {
console.warn('[admin2] toggleRelay failed:', d.error);
}
} catch (e) {
console.error('[admin2] toggleRelay error:', e);
}
}
// Render the relay selection list from the unified caching_relays table.
// Each relay has live_enabled and backfill_enabled checkboxes that toggle
// immediately via the API (no "Save" button needed for individual toggles).
function renderCachingRelaySelection(d) {
const el = document.getElementById('caching-relay-selection');
if (!el) return;
// Use unified relays array if available, otherwise fall back to old format
let relays = d.relays;
if (!relays || relays.length === 0) {
// Fallback: build from old upstreamRelays + discoveredRelays
const relayMap = {};
(d.discoveredRelays || []).forEach(r => {
relayMap[r.relay_url] = { follow_count: parseInt(r.follow_count, 10) || 0, status: null, live_enabled: false, backfill_enabled: false, is_bootstrap: false };
});
(d.upstreamRelays || []).forEach(r => {
if (!relayMap[r.relay_url]) relayMap[r.relay_url] = { follow_count: 0, status: null, live_enabled: false, backfill_enabled: false, is_bootstrap: false };
relayMap[r.relay_url].status = r;
});
const bootstrapSet = new Set((d.config?.caching_bootstrap_relays || '').split(',').map(s => s.trim()).filter(s => s));
const liveSet = new Set((d.config?.caching_live_relays || d.config?.caching_bootstrap_relays || '').split(',').map(s => s.trim()).filter(s => s));
relays = Object.entries(relayMap).map(([url, info]) => ({
relay_url: url,
live_enabled: liveSet.has(url) ? 't' : 'f',
backfill_enabled: liveSet.has(url) ? 't' : 'f',
status_code: info.status ? info.status.status_code : 0,
status_text: info.status ? info.status.status_text : '',
follow_count: info.follow_count,
is_bootstrap: bootstrapSet.has(url) ? 't' : 'f'
}));
}
if (relays.length === 0) {
el.innerHTML = '<p style="color:var(--muted-color);font-style:italic">No relays discovered yet (waiting for backfill progress data)</p>';
return;
}
let html = '<div class="relay-selection-list">';
html += '<div class="relay-selection-header">'
+ '<span class="sel-checkbox-label" title="Live subscription">Live</span>'
+ '<span class="sel-url">Relay</span>'
+ '<span class="sel-follows">Follows</span>'
+ '<span class="sel-badge">Status</span>'
+ '</div>';
relays.forEach(r => {
const url = r.relay_url;
const host = url.replace(/^wss?:\/\//, '');
const isBootstrap = r.is_bootstrap === 't' || r.is_bootstrap === true;
const liveChecked = r.live_enabled === 't' || r.live_enabled === true;
const followCount = parseInt(r.follow_count, 10) || 0;
let statusLabel = 'unknown';
let statusClass = 'sel-unknown';
const sc = parseInt(r.status_code, 10);
if (sc == 2) { statusClass = 'sel-ok'; statusLabel = 'connected'; }
else if (sc == 1) { statusClass = 'sel-connecting'; statusLabel = 'connecting'; }
else if (sc == 0) {
// If live_enabled, show as normal text (not dimmed) even before connected
if (liveChecked) {
statusClass = 'sel-enabled-waiting';
statusLabel = 'waiting...';
} else {
statusClass = 'sel-disconnected';
statusLabel = 'disconnected';
}
}
else { statusClass = 'sel-error'; statusLabel = esc(r.status_text || 'error'); }
const bootstrapTag = isBootstrap ? ' <span class="bootstrap-tag">bootstrap</span>' : '';
html += `<div class="relay-selection-row ${statusClass}">
<input type="checkbox" class="sel-checkbox-live" data-url="${esc(url)}" ${liveChecked ? 'checked' : ''} onchange="toggleRelay('${esc(url)}', 'live_enabled')">
<span class="sel-url" title="${esc(url)}">${esc(host)}${bootstrapTag}</span>
<span class="sel-follows">${followCount} follows</span>
<span class="sel-badge">${statusLabel}</span>
</div>`;
});
html += '</div>';
el.innerHTML = html;
}
// Wire up live preview updates on input changes
document.addEventListener('DOMContentLoaded', () => {
['caching-kinds', 'caching-since', 'caching-limit'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', updateCachingFilterPreview);
});
});
// Update the strategy description note when radio changes, and auto-save
function onCachingStrategyChange() {
const selected = document.querySelector('input[name="caching-strategy"]:checked');
const note = document.getElementById('caching-strategy-note');
if (selected && note) {
if (selected.value === 'whitelist') {
note.textContent = 'Only events from followed pubkeys (authors filter auto-populated from follow graph).';
} else {
note.textContent = 'All events on the relay (no authors filter). Use the Cleanup page to periodically remove unwanted data.';
}
}
// Auto-save strategy immediately so auto-refresh doesn't revert it
const strategy = selected?.value || 'whitelist';
fetch('api/live_subscription.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'save_config', strategy, kinds: '', since_seconds: '', limit: ''})
}).catch(e => console.error('[admin2] auto-save strategy error:', e));
// Update filter preview
updateCachingFilterPreview();
}
// Build and display the subscription filter preview
function updateCachingFilterPreview() {
const el = document.getElementById('caching-filter-preview');
if (!el) return;
const strategy = document.querySelector('input[name="caching-strategy"]:checked')?.value || 'whitelist';
const kindsRaw = document.getElementById('caching-kinds').value.trim();
const sinceSec = parseInt(document.getElementById('caching-since').value, 10) || 0;
const limitVal = parseInt(document.getElementById('caching-limit').value, 10) || 0;
const filter = {};
// Authors: only in whitelist mode
if (strategy === 'whitelist') {
filter.authors = '[auto-populated from follow graph]';
}
// Kinds
if (kindsRaw) {
const kinds = kindsRaw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
if (kinds.length > 0) {
filter.kinds = kinds;
}
}
// Since
if (sinceSec > 0) {
filter.since = 'now - ' + sinceSec + 's';
} else {
filter.since = 'now';
}
// Limit
if (limitVal > 0) {
filter.limit = limitVal;
}
el.textContent = JSON.stringify(filter, null, 2);
}
// Save live subscription config
async function saveCachingConfig() {
const strategy = document.querySelector('input[name="caching-strategy"]:checked')?.value || 'whitelist';
const kinds = document.getElementById('caching-kinds').value.trim();
const since = document.getElementById('caching-since').value.trim();
const limit = document.getElementById('caching-limit').value.trim();
const statusEl = document.getElementById('caching-save-status');
if (statusEl) statusEl.textContent = 'Saving…';
try {
const res = await fetch('api/live_subscription.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
action: 'save_config',
strategy,
kinds,
since_seconds: since,
limit
})
});
const d = await res.json();
if (d.ok) {
if (statusEl) {
statusEl.textContent = '✓ Configuration saved.';
statusEl.style.color = 'var(--success-color, #27ae60)';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000);
}
} else {
if (statusEl) {
statusEl.textContent = 'Error: ' + (d.error || 'unknown');
statusEl.style.color = 'var(--error-color, #c0392b)';
}
}
} catch (e) {
console.error('[admin2] saveCachingConfig error:', e);
if (statusEl) {
statusEl.textContent = 'Error: ' + e.message;
statusEl.style.color = 'var(--error-color, #c0392b)';
}
}
}
// ================================
// DMs
// ================================
@@ -634,6 +989,358 @@ async function executeQuery() {
} catch (e) { console.error('[admin2] query error:', e); }
}
// ================================
// EVENT CLEANUP
// ================================
// ── State ──────────────────────────────────────────────────────────────
let cleanupEditId = null; // non-null when editing an existing saved query
// ── Load saved queries table ───────────────────────────────────────────
async function loadCleanupQueries() {
console.log('[admin2] loading cleanup queries');
try {
const res = await fetch('api/cleanup_queries.php');
const d = await res.json();
const tbody = document.getElementById('cleanup-saved-queries-body');
if (!d.queries || d.queries.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No saved queries. Create one below.</td></tr>';
return;
}
tbody.innerHTML = d.queries.map(q => {
const filterLabel = q.follows_filter === 'all' ? 'All'
: q.follows_filter === 'follows' ? 'Follows'
: 'Non-follows';
const kindsStr = q.kinds && q.kinds.length > 0 ? q.kinds.join(', ') : 'all';
const dateStr = (q.from_date || q.to_date)
? `${q.from_date || '…'}${q.to_date || '…'}`
: 'no date bound';
const limitStr = q.max_events > 0 ? q.max_events.toLocaleString() : '∞';
const filters = `${filterLabel} | kinds: ${kindsStr} | ${dateStr} | max: ${limitStr}`;
const previewInfo = q.last_preview_count > 0
? `${q.last_preview_count.toLocaleString()} evts (${q.last_preview_size_human})`
: '—';
const executedInfo = q.last_executed_at || '—';
return `<tr>
<td><strong>${esc(q.name)}</strong></td>
<td style="font-size:11px">${esc(filters)}</td>
<td style="font-size:11px">${previewInfo}</td>
<td style="font-size:11px">${esc(executedInfo)}</td>
<td>
<button onclick="runSavedCleanupPreview(${q.id})">Preview</button>
<button onclick="runSavedCleanupExecute(${q.id})">Execute</button>
<button onclick="loadSavedCleanupQuery(${q.id})">Edit</button>
<button onclick="deleteCleanupQuery(${q.id})">Delete</button>
</td>
</tr>`;
}).join('');
} catch (e) { console.error('[admin2] loadCleanupQueries error:', e); }
}
// ── New query (clear builder) ──────────────────────────────────────────
function newCleanupQuery() {
cleanupEditId = null;
document.getElementById('cleanup-name').value = '';
document.querySelectorAll('input[name="cleanup-follows"]').forEach(r => {
r.checked = r.value === 'all';
});
document.getElementById('cleanup-kinds').value = '';
document.getElementById('cleanup-from-date').value = '';
document.getElementById('cleanup-from-time').value = '';
document.getElementById('cleanup-to-date').value = '';
document.getElementById('cleanup-to-time').value = '';
document.getElementById('cleanup-max-events').value = '0';
document.getElementById('cleanup-results-group').style.display = 'none';
document.getElementById('cleanup-name').focus();
}
// ── Combine date + time inputs into a single datetime string ───────────
function combineDateTime(dateId, timeId) {
const dateVal = document.getElementById(dateId).value;
const timeVal = document.getElementById(timeId).value;
if (!dateVal) return '';
return timeVal ? dateVal + ' ' + timeVal : dateVal;
}
// ── Read filter values from the form ───────────────────────────────────
function readCleanupFilters() {
const followsEl = document.querySelector('input[name="cleanup-follows"]:checked');
const follows_filter = followsEl ? followsEl.value : 'all';
const kindsRaw = document.getElementById('cleanup-kinds').value.trim();
const kinds = kindsRaw ? kindsRaw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0) : [];
const from_date = combineDateTime('cleanup-from-date', 'cleanup-from-time');
const to_date = combineDateTime('cleanup-to-date', 'cleanup-to-time');
const max_events = parseInt(document.getElementById('cleanup-max-events').value, 10) || 0;
const name = document.getElementById('cleanup-name').value.trim();
return { name, follows_filter, kinds, from_date, to_date, max_events };
}
// ── Preview ────────────────────────────────────────────────────────────
async function previewCleanup(queryId) {
const filters = readCleanupFilters();
const params = new URLSearchParams();
params.set('follows_filter', filters.follows_filter);
if (filters.kinds.length > 0) params.set('kinds', filters.kinds.join(','));
if (filters.from_date) params.set('from_date', filters.from_date);
if (filters.to_date) params.set('to_date', filters.to_date);
if (filters.max_events > 0) params.set('max_events', String(filters.max_events));
if (queryId) params.set('query_id', String(queryId));
try {
const res = await fetch('api/cleanup.php?' + params.toString());
const d = await res.json();
if (d.error) { alert('Preview error: ' + d.error); return; }
renderCleanupResults(d);
// Refresh saved queries table so last_preview updates show
if (queryId) loadCleanupQueries();
} catch (e) { console.error('[admin2] previewCleanup error:', e); alert('Preview failed: ' + e.message); }
}
// ── Render preview results ─────────────────────────────────────────────
function renderCleanupResults(d) {
const group = document.getElementById('cleanup-results-group');
group.style.display = 'block';
// Summary
const summary = document.getElementById('cleanup-results-summary');
let html = `<strong>Match count:</strong> ${(d.match_count ?? 0).toLocaleString()} events`;
if (d.total_size_human) {
html += ` &nbsp;|&nbsp; <strong>Estimated size:</strong> ${d.total_size_human}`;
}
if (d.avg_size_per_event) {
html += ` &nbsp;|&nbsp; <strong>Avg/event:</strong> ${d.avg_size_per_event} bytes`;
}
if (d.deleted_count !== undefined) {
html += `<br><strong style="color:#c0392b">Deleted:</strong> ${d.deleted_count.toLocaleString()} events`;
if (d.freed_human) html += ` &nbsp;|&nbsp; <strong>Freed:</strong> ${d.freed_human}`;
if (d.duration_ms) html += ` &nbsp;|&nbsp; <strong>Duration:</strong> ${d.duration_ms}ms`;
}
summary.innerHTML = html;
// Kind breakdown table
const bdEl = document.getElementById('cleanup-results-breakdown');
if (d.kinds_breakdown && d.kinds_breakdown.length > 0) {
let tbl = '<table class="config-table"><thead><tr><th>Kind</th><th>Count</th><th>Size</th><th>% of total</th></tr></thead><tbody>';
const totalBytes = d.total_size_bytes || 1;
d.kinds_breakdown.forEach(k => {
const pct = totalBytes > 0 ? (k.size_bytes / totalBytes * 100).toFixed(1) : 0;
tbl += `<tr><td>${k.kind}</td><td>${k.count.toLocaleString()}</td><td>${formatCleanupBytes(k.size_bytes)}</td><td>${pct}%</td></tr>`;
});
tbl += '</tbody></table>';
bdEl.innerHTML = tbl;
} else {
bdEl.innerHTML = '<p style="font-style:italic;color:var(--text-muted,#888)">No kind breakdown available.</p>';
}
// SQL preview
const sqlEl = document.getElementById('cleanup-sql-preview');
if (d.sql_preview) {
sqlEl.textContent = d.sql_preview;
} else {
sqlEl.textContent = '(SQL preview not available for this response)';
}
}
// ── Format bytes (local helper) ────────────────────────────────────────
function formatCleanupBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(2) + ' GB';
}
// ── Save query ─────────────────────────────────────────────────────────
async function saveCleanupQuery() {
const filters = readCleanupFilters();
if (!filters.name) { alert('Please enter a query name'); return; }
const body = {
action: 'save',
id: cleanupEditId,
name: filters.name,
follows_filter: filters.follows_filter,
kinds: filters.kinds,
from_date: filters.from_date,
to_date: filters.to_date,
max_events: filters.max_events,
};
try {
const res = await fetch('api/cleanup_queries.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
const d = await res.json();
if (d.error) { alert('Save failed: ' + d.error); return; }
cleanupEditId = d.id;
alert('Query saved!');
loadCleanupQueries();
} catch (e) { console.error('[admin2] saveCleanupQuery error:', e); alert('Save failed: ' + e.message); }
}
// ── Execute delete (show confirmation first) ───────────────────────────
let pendingExecuteFilters = null;
async function executeCleanup() {
const filters = readCleanupFilters();
pendingExecuteFilters = filters;
// Run a quick preview to show the user what will be deleted
const params = new URLSearchParams();
params.set('follows_filter', filters.follows_filter);
if (filters.kinds.length > 0) params.set('kinds', filters.kinds.join(','));
if (filters.from_date) params.set('from_date', filters.from_date);
if (filters.to_date) params.set('to_date', filters.to_date);
if (filters.max_events > 0) params.set('max_events', String(filters.max_events));
try {
const res = await fetch('api/cleanup.php?' + params.toString());
const d = await res.json();
const name = filters.name || 'Ad-hoc query';
const msg = `Query: ${esc(name)}\n`
+ `Events to delete: ${(d.match_count ?? 0).toLocaleString()}\n`
+ `Estimated space freed: ${d.total_size_human || 'unknown'}`;
document.getElementById('cleanup-confirm-msg').textContent = msg;
document.getElementById('cleanup-confirm-dialog').style.display = 'block';
} catch (e) {
// If preview fails, still allow delete with a basic confirmation
document.getElementById('cleanup-confirm-msg').textContent = `Events matching current filters.`;
document.getElementById('cleanup-confirm-dialog').style.display = 'block';
}
}
async function executeCleanupConfirmed() {
document.getElementById('cleanup-confirm-dialog').style.display = 'none';
if (!pendingExecuteFilters) return;
const filters = pendingExecuteFilters;
pendingExecuteFilters = null;
const body = {
follows_filter: filters.follows_filter,
kinds: filters.kinds,
from_date: filters.from_date,
to_date: filters.to_date,
max_events: filters.max_events,
dry_run: false,
};
try {
const res = await fetch('api/cleanup.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
const d = await res.json();
if (d.error) { alert('Delete failed: ' + d.error); return; }
renderCleanupResults(d);
// Refresh saved queries in case last_preview_count changed
loadCleanupQueries();
} catch (e) { console.error('[admin2] executeCleanup error:', e); alert('Delete failed: ' + e.message); }
}
// ── Delete saved query ─────────────────────────────────────────────────
async function deleteCleanupQuery(id) {
if (!confirm('Delete this saved query?')) return;
try {
const res = await fetch('api/cleanup_queries.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'delete', id})
});
const d = await res.json();
if (d.error) { alert('Delete failed: ' + d.error); return; }
loadCleanupQueries();
} catch (e) { console.error('[admin2] deleteCleanupQuery error:', e); alert('Delete failed: ' + e.message); }
}
// ── Load saved query into builder ──────────────────────────────────────
async function loadSavedCleanupQuery(id) {
try {
const res = await fetch('api/cleanup_queries.php');
const d = await res.json();
if (!d.queries) return;
const q = d.queries.find(q => q.id === id);
if (!q) { alert('Query not found'); return; }
cleanupEditId = q.id;
document.getElementById('cleanup-name').value = q.name;
document.querySelectorAll('input[name="cleanup-follows"]').forEach(r => {
r.checked = r.value === q.follows_filter;
});
document.getElementById('cleanup-kinds').value = (q.kinds || []).join(', ');
// Split stored datetime into date + time parts
const fromParts = (q.from_date || '').split(' ');
document.getElementById('cleanup-from-date').value = fromParts[0] || '';
document.getElementById('cleanup-from-time').value = fromParts[1] || '';
const toParts = (q.to_date || '').split(' ');
document.getElementById('cleanup-to-date').value = toParts[0] || '';
document.getElementById('cleanup-to-time').value = toParts[1] || '';
document.getElementById('cleanup-max-events').value = q.max_events;
document.getElementById('cleanup-results-group').style.display = 'none';
// Scroll to builder
document.getElementById('cleanup-builder-group').scrollIntoView({ behavior: 'smooth' });
} catch (e) { console.error('[admin2] loadSavedCleanupQuery error:', e); }
}
// ── Run preview for a saved query ──────────────────────────────────────
async function runSavedCleanupPreview(id) {
try {
const res = await fetch('api/cleanup_queries.php');
const d = await res.json();
if (!d.queries) return;
const q = d.queries.find(q => q.id === id);
if (!q) { alert('Query not found'); return; }
// Load into builder first so the user can see the filters
await loadSavedCleanupQuery(id);
// Then run preview with query_id so last_preview_count/size get saved
await previewCleanup(id);
} catch (e) { console.error('[admin2] runSavedCleanupPreview error:', e); }
}
// ── Run execute for a saved query ──────────────────────────────────────
async function runSavedCleanupExecute(id) {
try {
const res = await fetch('api/cleanup_queries.php');
const d = await res.json();
if (!d.queries) return;
const q = d.queries.find(q => q.id === id);
if (!q) { alert('Query not found'); return; }
// Load into builder
await loadSavedCleanupQuery(id);
// Confirm and execute via the saved query execute action
const filters = readCleanupFilters();
const name = q.name || 'Saved query';
const msg = `Query: ${name}\n`
+ `Follows: ${q.follows_filter}\n`
+ `Kinds: ${(q.kinds || []).join(', ') || 'all'}\n`
+ `From: ${q.from_date || 'no bound'}\n`
+ `To: ${q.to_date || 'no bound'}\n`
+ `Max events: ${q.max_events > 0 ? q.max_events.toLocaleString() : 'no limit'}`;
document.getElementById('cleanup-confirm-msg').textContent = msg;
document.getElementById('cleanup-confirm-btn').onclick = async () => {
document.getElementById('cleanup-confirm-dialog').style.display = 'none';
try {
const execRes = await fetch('api/cleanup_queries.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'execute', id})
});
const execD = await execRes.json();
if (execD.error) { alert('Delete failed: ' + execD.error); return; }
renderCleanupResults(execD);
loadCleanupQueries();
} catch (e) { console.error('[admin2] runSavedCleanupExecute error:', e); alert('Delete failed: ' + e.message); }
};
document.getElementById('cleanup-confirm-dialog').style.display = 'block';
} catch (e) { console.error('[admin2] runSavedCleanupExecute error:', e); }
}
// ================================
// DARK MODE
// ================================
+399 -1
View File
@@ -281,6 +281,113 @@ body {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
/* Cleanup Date/Time Inputs */
.datetime-group {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.cleanup-date-input {
font-family: var(--font-family);
font-size: 13px;
padding: 4px 6px;
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
background: var(--secondary-color);
color: var(--primary-color);
width: 160px;
}
.cleanup-time-input {
font-family: var(--font-family);
font-size: 13px;
padding: 4px 6px;
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
background: var(--secondary-color);
color: var(--primary-color);
width: 110px;
}
.datetime-hint {
font-size: 11px;
color: var(--muted-color);
font-style: italic;
}
/* Cleanup Confirmation Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1001;
}
.modal-content {
background-color: var(--secondary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
}
/* Cleanup results summary */
.info-box {
padding: 8px 12px;
background: var(--bg-code, #f5f5f5);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
font-size: 13px;
line-height: 1.6;
}
/* Radio group — monochrome aesthetic */
.radio-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-top: 4px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
font-size: 13px;
}
.radio-group input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 14px;
height: 14px;
border: 1px solid var(--primary-color);
border-radius: 50%;
margin: 0;
cursor: pointer;
background: var(--secondary-color);
padding: 0;
}
.radio-group input[type="radio"]:checked {
border-color: var(--accent-color);
background: var(--accent-color);
box-shadow: inset 0 0 0 3px var(--secondary-color);
}
/* Cleanup form group */
.form-group {
margin-bottom: 10px;
}
.form-group > label {
display: block;
font-size: 12px;
font-weight: bold;
margin-bottom: 3px;
}
h1 {
border-bottom: var(--border-width) solid var(--border-color);
padding-bottom: 10px;
@@ -603,6 +710,50 @@ button:disabled {
flex: 1;
}
/* Process toggle buttons describe the action and show an active red braille spinner. */
.process-toggle-btn,
.process-toggle-btn:hover,
.process-toggle-btn:focus,
.process-toggle-btn:active {
min-width: 170px;
background: var(--secondary-color) !important;
color: var(--primary-color) !important;
border-color: var(--border-color) !important;
}
.process-toggle-btn.process-running,
.process-toggle-btn.process-running:hover,
.process-toggle-btn.process-running:focus,
.process-toggle-btn.process-running:active {
color: #d32f2f !important;
}
.process-spinner {
display: inline-block;
width: 1.15em;
margin-right: 4px;
color: #d32f2f;
}
.process-spinner::after {
content: '⠋';
display: inline-block;
color: #d32f2f !important;
animation: process-braille-glyph 0.8s steps(8, end) infinite;
}
@keyframes process-braille-glyph {
0% { content: '⠋'; }
12.5% { content: '⠙'; }
25% { content: '⠹'; }
37.5% { content: '⠸'; }
50% { content: '⠼'; }
62.5% { content: '⠴'; }
75% { content: '⠦'; }
87.5% { content: '⠧'; }
100% { content: '⠋'; }
}
.user-info {
padding: 10px;
border: var(--border-width) solid var(--border-color);
@@ -1553,7 +1704,8 @@ bdi {
/* Name cells in tables: prevent layout breakage. */
#stats-pubkeys-table td:nth-child(2),
#caching-follows-table td:nth-child(1) {
#caching-follows-table td:nth-child(1),
#backfill-follows-table td:nth-child(1) {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
@@ -1683,3 +1835,249 @@ bdi {
.refresh-user-btn:active {
opacity: 0.7;
}
/* Upstream relay status window — monochrome per WEB.md rules */
.relay-status-summary {
font-weight: bold;
margin-bottom: 8px;
font-size: 13px;
}
.relay-status-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 4px;
}
.relay-status-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 6px;
border-radius: var(--border-radius);
font-size: 12px;
font-family: var(--font-family);
border: 1px solid transparent;
}
.relay-status-url {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.relay-status-badge {
flex-shrink: 0;
font-size: 10px;
padding: 1px 5px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
white-space: nowrap;
}
/* Connected: primary color, no fill */
.relay-status-ok {
color: var(--primary-color);
}
.relay-status-ok .relay-status-badge {
border-color: var(--primary-color);
}
/* Connecting: muted, no fill */
.relay-status-connecting {
color: var(--muted-color);
}
.relay-status-connecting .relay-status-badge {
border-color: var(--muted-color);
}
/* Disconnected: muted, no fill */
.relay-status-disconnected {
color: var(--muted-color);
}
.relay-status-disconnected .relay-status-badge {
border-color: var(--muted-color);
}
/* Error: accent (red), no fill */
.relay-status-error {
color: var(--accent-color);
}
.relay-status-error .relay-status-badge {
border-color: var(--accent-color);
}
/* Unknown: muted, no fill */
.relay-status-unknown {
color: var(--muted-color);
}
.relay-status-unknown .relay-status-badge {
border-color: var(--muted-color);
}
/* Pagination bar — monochrome per WEB.md rules */
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
font-size: 12px;
color: var(--primary-color);
}
.pagination-info {
font-style: italic;
color: var(--muted-color);
}
.pagination-buttons {
display: flex;
gap: 6px;
}
.pagination-buttons button {
font-size: 12px;
padding: 4px 12px;
cursor: pointer;
background: var(--secondary-color);
color: var(--primary-color);
border: 1px solid var(--primary-color);
border-radius: var(--border-radius);
font-family: var(--font-family);
transition: border-color 0.2s, color 0.2s;
}
.pagination-buttons button:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
.pagination-buttons button:active {
background: var(--accent-color);
color: var(--secondary-color);
border-color: var(--accent-color);
}
/* Relay selection list (caching page) */
.relay-selection-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 4px;
}
.relay-selection-row {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
border-radius: var(--border-radius);
font-size: 12px;
font-family: var(--font-family);
border: 1px solid transparent;
}
.relay-selection-header {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
font-size: 10px;
font-weight: bold;
color: var(--text-muted, #888);
border-bottom: 1px solid var(--border-color);
margin-bottom: 2px;
}
.relay-selection-header .sel-checkbox-label {
flex-shrink: 0;
width: 30px;
text-align: center;
font-size: 9px;
}
.relay-selection-header .sel-url {
flex: 1;
min-width: 0;
}
.relay-selection-header .sel-follows {
flex-shrink: 0;
width: 60px;
text-align: right;
}
.relay-selection-header .sel-badge {
flex-shrink: 0;
width: 70px;
text-align: center;
}
.relay-selection-row .sel-checkbox-live {
flex-shrink: 0;
width: 13px;
height: 13px;
margin: 0 8px 0 0;
cursor: pointer;
}
.relay-selection-row .sel-url {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.relay-selection-row .sel-follows {
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted, #888);
width: 60px;
text-align: right;
}
.relay-selection-row .sel-badge {
flex-shrink: 0;
font-size: 10px;
padding: 1px 5px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
width: 70px;
text-align: center;
white-space: nowrap;
box-sizing: border-box;
}
/* Status colors for relay selection rows */
.relay-selection-row.sel-ok {
color: var(--primary-color);
}
.relay-selection-row.sel-ok .sel-badge {
border-color: var(--primary-color);
}
.relay-selection-row.sel-connecting {
color: var(--muted-color);
}
.relay-selection-row.sel-connecting .sel-badge {
border-color: var(--muted-color);
}
.relay-selection-row.sel-disconnected {
color: var(--muted-color);
}
.relay-selection-row.sel-disconnected .sel-badge {
border-color: var(--muted-color);
}
.relay-selection-row.sel-error {
color: var(--accent-color);
}
.relay-selection-row.sel-error .sel-badge {
border-color: var(--accent-color);
}
.relay-selection-row.sel-unknown {
color: var(--muted-color);
}
.relay-selection-row.sel-unknown .sel-badge {
border-color: var(--muted-color);
}
.relay-selection-row.sel-enabled-waiting {
color: var(--primary-color);
}
.relay-selection-row.sel-enabled-waiting .sel-badge {
border-color: var(--primary-color);
font-style: italic;
}
.bootstrap-tag {
font-size: 10px;
color: var(--text-muted, #888);
background: var(--bg-code, #f0f0f0);
padding: 0 4px;
border-radius: 2px;
margin-left: 4px;
}
+11 -11
View File
@@ -1,15 +1,15 @@
New Events — Last 24 Hours
11 | X
10 | XX
9 | XX
8 | XX
7 | XX X X
6 | XX X X
5 | XX X X
4 | X XX X XX
3 | X XXX X XX
2 | X X X XXX X XX
1 | X XX X X X X X X X X X X X X XX X X XXXX X X X X X XX
10071 |XX X
9064 |XX X X
8057 |XXX X X X
7050 |XXXXXXX X XX
6043 |XXXXXXX X XXX
5036 |XXXXXXX X XXXXX
4029 |XXXXXXXXX XXXXX
3022 |XXXXXXXXXX XXXXX
2015 |XXXXXXXXXX XXXXXX
1008 |XXXXXXXXXX XXXXXX
1 |XXXXXXXXXX XXXXXX X XXX X X X X XX X XXXXXXX XX
+--------------------------------------------------------------------------------
0s 1h 3h 4h 6h 7h 9h 10h 12h 13h 15h 16h 18h 19h 21h 22h
+1
View File
@@ -0,0 +1 @@
[{"kind":1,"count":2135,"pct":96.7},{"kind":6,"count":41,"pct":1.9},{"kind":30023,"count":11,"pct":0.5},{"kind":10002,"count":8,"pct":0.4},{"kind":0,"count":5,"pct":0.2},{"kind":3,"count":4,"pct":0.2},{"kind":10000,"count":4,"pct":0.2}]
+1
View File
@@ -0,0 +1 @@
[{"pubkey":"4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f","name":"mleku","count":572,"pct":25.9},{"pubkey":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c","name":"Vitor Pamplona","count":564,"pct":25.5},{"pubkey":"1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139","name":"Laan Tungir","count":525,"pct":23.8},{"pubkey":"4d7842051782e0d3feb034d150adc2b6bae4ee3b49786793bffa468b6f5b96b3","name":"FLASH","count":509,"pct":23.1},{"pubkey":"52a3e82f7b3743852fbe804cfcbf4db3448115887895247c001f2b50e790acb8","name":"Didactyl Agent","count":34,"pct":1.5},{"pubkey":"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52","name":"","count":1,"pct":0},{"pubkey":"4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa","name":"","count":1,"pct":0},{"pubkey":"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2","name":"","count":1,"pct":0},{"pubkey":"8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e","name":"","count":1,"pct":0}]
+182 -24
View File
@@ -41,7 +41,7 @@ $display_name = $relay_version ? ($relay_name . ' ' . $relay_version) : $relay_n
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>C-Relay-PG Admin</title>
<link rel="stylesheet" href="assets/index.css">
<link rel="stylesheet" href="assets/index.css?v=<?= filemtime(__DIR__ . '/assets/index.css') ?>">
</head>
<body>
@@ -54,8 +54,10 @@ $display_name = $relay_version ? ($relay_name . ' ' . $relay_version) : $relay_n
<li><button class="nav-item" data-page="authorization">Authorization</button></li>
<li><button class="nav-item" data-page="ip-bans">IP BAN</button></li>
<li><button class="nav-item" data-page="relay-events">Relay Events</button></li>
<li><button class="nav-item" data-page="backfill">Backfill</button></li>
<li><button class="nav-item" data-page="caching">Caching</button></li>
<li><button class="nav-item" data-page="dm">DM</button></li>
<li><button class="nav-item" data-page="cleanup">Cleanup</button></li>
<li><button class="nav-item" data-page="database">Database Query</button></li>
</ul>
<div class="nav-footer">
@@ -356,47 +358,203 @@ $display_name = $relay_version ? ($relay_name . ' ' . $relay_version) : $relay_n
</div>
</div>
<!-- CACHING Section -->
<div class="section" id="cachingSection" style="display: none;">
<div class="section-header">CACHING</div>
<!-- BACKFILL Section -->
<div class="section" id="backfillSection" style="display: none;">
<div class="section-header">BACKFILL</div>
<div class="input-group">
<h3>Caching Controls</h3>
<div id="caching-controls" class="inline-buttons">
<span id="caching-enabled-label">Caching: ?</span>
<button type="button" id="caching-toggle-btn" onclick="toggleCachingEnabled()">Toggle Caching</button>
<span id="caching-inbox-enabled-label">Inbox: ?</span>
<button type="button" id="caching-inbox-toggle-btn" onclick="toggleCachingInboxEnabled()">Toggle Inbox</button>
<div id="backfill-controls" class="inline-buttons">
<button type="button" class="process-toggle-btn" id="backfill-toggle-btn" onclick="toggleBackfillEnabled()">Backfill status: loading…</button>
</div>
</div>
<div class="input-group">
<h3>Caching Service Status</h3>
<div id="caching-service-status" class="status-display">
<div class="input-group" id="backfill-relay-group" style="display:none">
<h3>Upstream Relays <span style="font-size:11px;font-weight:normal;color:var(--text-muted,#888)">(live connection state, updated every 15s)</span></h3>
<div id="backfill-relay-status" class="status-display">
<p>Loading...</p>
</div>
<div class="inline-buttons" style="margin-top:8px">
<button type="button" id="caching-rerun-all-btn" onclick="rerunAllCaching()">Re-run All Caching</button>
</div>
</div>
<div class="input-group">
<h3>Relay Inbox Status</h3>
<div id="caching-inbox-status" class="status-display">
<p>Loading...</p>
<button type="button" id="backfill-rerun-all-btn" onclick="rerunAllBackfill()">Re-run All Backfill</button>
</div>
</div>
<div class="input-group">
<h3>Followed Pubkeys <span style="font-size:11px;font-weight:normal;color:var(--text-muted,#888)">(click a row for per-relay details)</span></h3>
<div id="caching-follows-status" class="status-message"></div>
<div id="backfill-follows-status" class="status-message"></div>
<div id="backfill-follows-pagination" class="pagination-bar"></div>
<div class="config-table-container">
<table class="config-table" id="caching-follows-table">
<table class="config-table" id="backfill-follows-table">
<thead><tr><th>Name</th><th>npub</th><th>Root?</th><th>Events in DB</th><th>Backfill</th><th>Relays</th></tr></thead>
<tbody id="caching-follows-table-body">
<tbody id="backfill-follows-table-body">
<tr><td colspan="6" style="text-align: center; font-style: italic;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="inline-buttons">
<button type="button" onclick="loadCaching()">REFRESH</button>
<button type="button" onclick="loadBackfill()">REFRESH</button>
</div>
</div>
<!-- CACHING Section (Live Subscription Design) -->
<div class="section" id="cachingSection" style="display: none;">
<div class="section-header">CACHING (LIVE SUBSCRIPTION)</div>
<div class="input-group">
<div id="caching-controls" class="inline-buttons">
<button type="button" class="process-toggle-btn" id="caching-live-toggle-btn" onclick="toggleCachingLiveEnabled()">Caching status: loading…</button>
</div>
</div>
<div class="input-group">
<h3>Subscription Design</h3>
<p style="font-size:12px;color:var(--text-muted,#888);margin:0 0 12px 0">
Configure the Nostr subscription filter sent to upstream relays for live event streaming.
</p>
<div class="form-group">
<label>Strategy:</label>
<div class="radio-group" id="caching-strategy-group">
<label><input type="radio" name="caching-strategy" value="whitelist" onchange="onCachingStrategyChange()"> Whitelist Follows</label>
<label><input type="radio" name="caching-strategy" value="cache_all" onchange="onCachingStrategyChange()"> Cache Everything</label>
</div>
<div id="caching-strategy-note" style="font-size:11px;color:var(--text-muted,#888);margin-top:4px">
Whitelist: only events from followed pubkeys. Cache Everything: all events on the relay.
</div>
</div>
<div class="form-group">
<label for="caching-kinds">Event Kinds (comma-separated, empty = all):</label>
<input type="text" id="caching-kinds" placeholder="e.g. 1, 7, 9734" style="width:100%;max-width:400px">
<div style="font-size:11px;color:var(--text-muted,#888);margin-top:2px">
Common kinds: 0 (profiles), 1 (text), 3 (contacts), 4 (DM), 5 (delete),
6 (repost), 7 (reaction), 9734 (zap request), 9735 (zap receipt), 10002 (relay list)
</div>
</div>
<div class="form-group">
<label for="caching-since">Since lookback (seconds before now):</label>
<input type="number" id="caching-since" value="0" min="0" style="width:120px">
<span style="font-size:11px;color:var(--text-muted,#888);margin-left:8px">0 = no lookback (since=now)</span>
</div>
<div class="form-group">
<label for="caching-limit">Max events per response (0 = no limit):</label>
<input type="number" id="caching-limit" value="0" min="0" style="width:120px">
</div>
</div>
<div class="input-group">
<h3>Subscription Filter Preview</h3>
<pre id="caching-filter-preview" style="background:var(--bg-code,#f5f5f5);padding:8px;border-radius:4px;overflow-x:auto;font-size:11px;margin-top:4px;white-space:pre-wrap;border:1px solid var(--border,#ddd);min-height:60px">{
"kinds": [1, 7, 9734],
"since": 1234567890
}</pre>
<div class="inline-buttons" style="margin-top:12px">
<button type="button" onclick="saveCachingConfig()">Save Configuration</button>
<button type="button" onclick="loadCaching()">Refresh</button>
</div>
<div id="caching-save-status" class="status-message" style="margin-top:8px"></div>
</div>
<div class="input-group">
<h3>Live Subscription Status</h3>
<div id="caching-live-status" class="status-display">
<p>Loading...</p>
</div>
</div>
<div class="input-group">
<h3>Relay Selection <span style="font-size:11px;font-weight:normal;color:var(--text-muted,#888)">(check Live or Backfill to enable; changes take effect immediately)</span></h3>
<div id="caching-relay-selection" class="status-display">
<p>Loading...</p>
</div>
</div>
</div>
<!-- EVENT CLEANUP Section -->
<div class="section" id="cleanupSection" style="display: none;">
<div class="section-header"><h2>EVENT CLEANUP</h2></div>
<!-- Saved Queries Table -->
<div class="input-group" id="saved-queries-group">
<label>Saved Queries</label>
<div class="config-table-container">
<table class="config-table" id="cleanup-saved-queries-table">
<thead><tr><th>Name</th><th>Filters</th><th>Last Preview</th><th>Last Executed</th><th>Actions</th></tr></thead>
<tbody id="cleanup-saved-queries-body">
<tr><td colspan="5" style="text-align: center; font-style: italic;">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="inline-buttons" style="margin-top:8px">
<button type="button" onclick="newCleanupQuery()">New Query</button>
<button type="button" onclick="loadCleanupQueries()">Refresh</button>
</div>
</div>
<!-- Query Builder -->
<div class="input-group" id="cleanup-builder-group">
<label>Query Builder</label>
<div class="form-group">
<label for="cleanup-name">Query Name:</label>
<input type="text" id="cleanup-name" placeholder="My Cleanup Query" style="width:100%;max-width:400px">
</div>
<div class="form-group">
<label>Follows Filter:</label>
<div class="radio-group">
<label><input type="radio" name="cleanup-follows" value="all" checked> All</label>
<label><input type="radio" name="cleanup-follows" value="follows"> Follows only</label>
<label><input type="radio" name="cleanup-follows" value="non_follows"> Non-follows only</label>
</div>
</div>
<div class="form-group">
<label for="cleanup-kinds">Event Kinds (comma-separated, empty = all):</label>
<input type="text" id="cleanup-kinds" placeholder="e.g. 1, 7, 9734" style="width:100%;max-width:400px">
<div style="font-size:11px;color:var(--text-muted,#888);margin-top:2px">
Common kinds: 0 (profiles), 1 (text), 3 (contacts), 4 (DM), 5 (delete),
6 (repost), 7 (reaction), 9734 (zap request), 9735 (zap receipt), 10002 (relay list)
</div>
</div>
<div class="form-group">
<label>From (delete events after this):</label>
<div class="datetime-group">
<input type="date" id="cleanup-from-date" class="cleanup-date-input">
<input type="time" id="cleanup-from-time" class="cleanup-time-input">
<span class="datetime-hint">leave empty for no bound</span>
</div>
</div>
<div class="form-group">
<label>To (delete events before this):</label>
<div class="datetime-group">
<input type="date" id="cleanup-to-date" class="cleanup-date-input">
<input type="time" id="cleanup-to-time" class="cleanup-time-input">
<span class="datetime-hint">leave empty for no bound</span>
</div>
</div>
<div class="form-group">
<label for="cleanup-max-events">Max Events (0 = no limit):</label>
<input type="number" id="cleanup-max-events" value="0" min="0" style="width:120px">
</div>
<div class="inline-buttons" style="margin-top:8px">
<button type="button" onclick="previewCleanup()">Preview</button>
<button type="button" onclick="saveCleanupQuery()">Save Query</button>
<button type="button" onclick="executeCleanup()" style="background:#c0392b;color:#fff">Execute Delete</button>
</div>
</div>
<!-- Results Area -->
<div class="input-group" id="cleanup-results-group" style="display:none;">
<label>Results</label>
<div id="cleanup-results-summary" class="info-box"></div>
<div id="cleanup-results-breakdown" class="config-table-container" style="margin-top:8px"></div>
<div id="cleanup-results-sql" style="margin-top:8px">
<details>
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted,#888)">SQL Preview</summary>
<pre id="cleanup-sql-preview" style="background:var(--bg-code,#f5f5f5);padding:8px;border-radius:4px;overflow-x:auto;font-size:11px;margin-top:4px;white-space:pre-wrap"></pre>
</details>
</div>
</div>
<!-- Confirmation Dialog -->
<div id="cleanup-confirm-dialog" class="modal-overlay" style="display:none;">
<div class="modal-content" style="background:var(--bg-card,#fff);border:1px solid var(--border,#ccc);border-radius:8px;padding:20px;max-width:480px;margin:10% auto;box-shadow:0 4px 20px rgba(0,0,0,0.3)">
<h3 style="margin-top:0">Confirm Delete</h3>
<p id="cleanup-confirm-msg">Are you sure you want to delete these events?</p>
<p style="color:#c0392b;font-weight:bold">This action cannot be undone.</p>
<div class="inline-buttons" style="margin-top:12px">
<button type="button" id="cleanup-confirm-btn" onclick="executeCleanupConfirmed()" style="background:#c0392b;color:#fff">Delete</button>
<button type="button" onclick="document.getElementById('cleanup-confirm-dialog').style.display='none'">Cancel</button>
</div>
</div>
</div>
</div>
+35024 -8754
View File
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
<?php
/**
* admin/public/index.php — Public relay stats page.
*
* Served at https://<domain>/relay/stats/ (no authentication required).
*
* Shows the relay name/description/pubkey (read-only, from the config table)
* and the server-rendered ASCII event-rate chart. The chart is fetched from
* the public endpoint at /relay/api/chart.php?range=...
*
* This page exposes ONLY read-only, non-sensitive information:
* - relay_name, relay_description, relay_pubkey, relay_version (from config)
* - the event-rate chart (aggregate COUNT query, no user data)
*
* All sensitive admin endpoints (config edits, auth rules, IP bans, DMs,
* raw SQL queries) remain behind Basic Auth at /relay/admin/.
*/
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
// Fetch relay info for the header (from config table).
$relay_name = '';
$relay_desc = '';
$relay_pubkey = '';
$relay_version = '';
try {
$relay_name = $pdo->query("SELECT value FROM config WHERE key = 'relay_name'")->fetchColumn() ?: 'C-Relay-PG';
$relay_desc = $pdo->query("SELECT value FROM config WHERE key = 'relay_description'")->fetchColumn() ?: '';
$relay_pubkey = $pdo->query("SELECT value FROM config WHERE key = 'relay_pubkey'")->fetchColumn() ?: '';
$relay_version = $pdo->query("SELECT value FROM config WHERE key = 'relay_version'")->fetchColumn() ?: '';
} catch (PDOException $e) {}
// Convert relay pubkey (hex) to npub and format into 3 lines of 3 groups of 7 chars.
$relay_npub = $relay_pubkey;
if ($relay_pubkey && preg_match('/^[0-9a-fA-F]{64}$/', $relay_pubkey)) {
$relay_npub = hex_to_npub($relay_pubkey);
}
$formatted_npub = $relay_npub;
if (strlen($relay_npub) === 63) {
$line1 = substr($relay_npub, 0, 7) . ' ' . substr($relay_npub, 7, 7) . ' ' . substr($relay_npub, 14, 7);
$line2 = substr($relay_npub, 21, 7) . ' ' . substr($relay_npub, 28, 7) . ' ' . substr($relay_npub, 35, 7);
$line3 = substr($relay_npub, 42, 7) . ' ' . substr($relay_npub, 49, 7) . ' ' . substr($relay_npub, 56, 7);
$formatted_npub = $line1 . "\n" . $line2 . "\n" . $line3;
}
$display_name = $relay_version ? ($relay_name . ' ' . $relay_version) : $relay_name;
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($display_name) ?></title>
<link rel="stylesheet" href="/relay/assets/index.css">
<style>
/* Public page uses the admin theme but hides the nav and centers content. */
.side-nav, .side-nav-overlay { display: none !important; }
.public-wrap { max-width: 1100px; margin: 0 auto; padding: 20px; }
.public-chart-box {
background: var(--secondary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
padding: 12px;
margin-top: 16px;
}
.public-footer {
margin-top: 24px;
padding-top: 12px;
border-top: var(--border-width) solid var(--border-color);
font-size: 12px;
color: var(--muted-color);
text-align: center;
}
.public-footer a { color: var(--accent-color); text-decoration: none; }
.event-rate-chart-container {
white-space: pre;
font-family: var(--font-family);
font-size: 12px;
line-height: 1.2;
overflow-x: auto;
}
</style>
</head>
<body>
<!-- Header with title and relay info (read-only) -->
<div class="section">
<div class="header-content">
<div class="header-title">
<span class="relay-letter" data-letter="R">R</span>
<span class="relay-letter" data-letter="E">E</span>
<span class="relay-letter" data-letter="L">L</span>
<span class="relay-letter" data-letter="A">A</span>
<span class="relay-letter" data-letter="Y">Y</span>
</div>
<div class="relay-info">
<div id="relay-name" class="relay-name"><?= e($display_name) ?></div>
<div id="relay-description" class="relay-description"><?= e($relay_desc) ?></div>
<div id="relay-pubkey-container" class="relay-pubkey-container" title="Click to copy npub">
<div id="relay-pubkey" class="relay-pubkey"><?= e($formatted_npub) ?></div>
</div>
</div>
</div>
</div>
<div class="public-wrap">
<div class="section-header">EVENT RATE</div>
<!-- Range selector tabs -->
<div class="chart-range-tabs">
<button class="chart-tab active" data-range="hour">1H</button>
<button class="chart-tab" data-range="day">1D</button>
<button class="chart-tab" data-range="month">1M</button>
<button class="chart-tab" data-range="year">1Y</button>
</div>
<!-- Server-rendered ASCII chart (fetched from the public chart endpoint) -->
<div class="public-chart-box">
<div id="event-rate-chart" class="event-rate-chart-container">Loading chart...</div>
</div>
<div class="public-footer">
Public relay statistics &middot;
<a href="wss://<?= e($_SERVER['HTTP_HOST'] ?? '') ?>">wss://<?= e($_SERVER['HTTP_HOST'] ?? '') ?></a>
&middot; Admin? visit <a href="/relay/admin/">/relay/admin/</a>
</div>
</div>
<script>
// Minimal chart loader — fetches the public ASCII chart endpoint and
// injects the plain-text response into the chart container. No auth.
const chartEl = document.getElementById('event-rate-chart');
let currentRange = 'hour';
let chartTimer = null;
async function loadChart(range) {
currentRange = range;
if (!chartEl) return;
chartEl.textContent = 'Loading chart...';
try {
const res = await fetch('/relay/api/chart.php?range=' + encodeURIComponent(range));
if (!res.ok) { chartEl.textContent = 'Chart load failed (HTTP ' + res.status + ')'; return; }
const text = await res.text();
chartEl.textContent = text || '(no data)';
} catch (e) {
chartEl.textContent = 'Chart load failed: ' + e.message;
}
}
document.querySelectorAll('.chart-tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.chart-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const range = btn.getAttribute('data-range');
loadChart(range);
// Hour chart is live — auto-refresh every 30s. Others are cached server-side.
if (chartTimer) { clearInterval(chartTimer); chartTimer = null; }
if (range === 'hour') {
chartTimer = setInterval(() => loadChart(currentRange), 30000);
}
});
});
// Initial load + live refresh for the default (hour) range.
loadChart('hour');
chartTimer = setInterval(() => loadChart(currentRange), 30000);
// Click-to-copy the relay npub.
const pkContainer = document.getElementById('relay-pubkey-container');
if (pkContainer) {
pkContainer.style.cursor = 'pointer';
pkContainer.addEventListener('click', () => {
const txt = (document.getElementById('relay-pubkey').textContent || '').replace(/\s+/g, '');
if (txt && navigator.clipboard) navigator.clipboard.writeText(txt);
});
}
</script>
</body>
</html>
+16 -78
View File
@@ -2,6 +2,7 @@
# Build fully static MUSL binaries for C-Relay-PG using Alpine Docker
# Produces truly portable binaries with zero runtime dependencies
# PostgreSQL backend only.
set -e
@@ -11,7 +12,6 @@ DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
# Parse command line arguments
DEBUG_BUILD=false
DB_BACKEND="${DB_BACKEND:-postgres}"
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -19,31 +19,14 @@ while [[ $# -gt 0 ]]; do
DEBUG_BUILD=true
shift
;;
--db-backend)
if [[ -z "$2" ]]; then
echo "ERROR: --db-backend requires a value (sqlite|postgres)"
exit 1
fi
DB_BACKEND="$2"
shift 2
;;
--db-backend=*)
DB_BACKEND="${1#*=}"
shift
;;
*)
echo "ERROR: Unknown argument: $1"
echo "Usage: $0 [--debug] [--db-backend postgres|sqlite]"
echo "Usage: $0 [--debug]"
exit 1
;;
esac
done
if [[ "$DB_BACKEND" != "sqlite" && "$DB_BACKEND" != "postgres" ]]; then
echo "ERROR: Invalid DB backend '$DB_BACKEND'. Use sqlite or postgres."
exit 1
fi
if [[ "$DEBUG_BUILD" == "true" ]]; then
echo "=========================================="
echo "C-Relay-PG MUSL Static Binary Builder (DEBUG MODE)"
@@ -56,7 +39,7 @@ fi
echo "Project directory: $SCRIPT_DIR"
echo "Build directory: $BUILD_DIR"
echo "Debug build: $DEBUG_BUILD"
echo "DB backend: $DB_BACKEND"
echo "DB backend: postgres"
echo ""
# Create build directory
@@ -155,7 +138,6 @@ echo ""
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
--build-arg DB_BACKEND=$DB_BACKEND \
-f "$DOCKERFILE" \
-t c-relay-pg-musl-builder:latest \
--progress=plain \
@@ -179,7 +161,6 @@ echo "=========================================="
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
--build-arg DB_BACKEND=$DB_BACKEND \
--target builder \
-f "$DOCKERFILE" \
-t c-relay-pg-static-builder-stage:latest \
@@ -198,74 +179,31 @@ $DOCKER_CMD cp "$CONTAINER_ID:/build/c_relay_pg_static" "$BUILD_DIR/$OUTPUT_NAME
# Clean up container
$DOCKER_CMD rm "$CONTAINER_ID" > /dev/null
echo "✓ Binary extracted to: $BUILD_DIR/$OUTPUT_NAME"
echo "✓ Binary extracted: $BUILD_DIR/$OUTPUT_NAME"
echo ""
# Make binary executable
chmod +x "$BUILD_DIR/$OUTPUT_NAME"
# Verify the binary
echo "=========================================="
echo "Step 3: Verifying static binary"
echo "=========================================="
echo ""
file "$BUILD_DIR/$OUTPUT_NAME"
ls -lh "$BUILD_DIR/$OUTPUT_NAME"
echo "Checking for dynamic dependencies:"
if LDD_OUTPUT=$(timeout 5 ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1); then
if echo "$LDD_OUTPUT" | grep -q "not a dynamic executable"; then
echo "✓ Binary is fully static (no dynamic dependencies)"
TRULY_STATIC=true
elif echo "$LDD_OUTPUT" | grep -q "statically linked"; then
echo "✓ Binary is statically linked"
TRULY_STATIC=true
else
echo "⚠ WARNING: Binary may have dynamic dependencies:"
echo "$LDD_OUTPUT"
TRULY_STATIC=false
fi
# Check for dynamic dependencies
if ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1 | grep -q "not a dynamic executable"; then
echo "✓ Binary is truly static (no dynamic dependencies)"
else
# ldd failed or timed out - check with file command instead
if file "$BUILD_DIR/$OUTPUT_NAME" | grep -q "statically linked"; then
echo "✓ Binary is statically linked (verified with file command)"
TRULY_STATIC=true
else
echo "⚠ Could not verify static linking (ldd check failed)"
TRULY_STATIC=false
fi
echo "⚠ Binary has dynamic dependencies:"
ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1
fi
echo ""
echo "File size: $(ls -lh "$BUILD_DIR/$OUTPUT_NAME" | awk '{print $5}')"
echo ""
# Test if binary runs
echo "Testing binary execution:"
if "$BUILD_DIR/$OUTPUT_NAME" --version 2>&1 | head -5; then
echo "✓ Binary executes successfully"
else
echo "⚠ Binary execution test failed (this may be normal if --version is not supported)"
fi
echo ""
# Summary
echo "=========================================="
echo "Build Summary"
echo "Build complete!"
echo "=========================================="
echo "Binary: $BUILD_DIR/$OUTPUT_NAME"
echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
echo "Platform: $PLATFORM"
if [ "$DEBUG_BUILD" = true ]; then
echo "Build Type: DEBUG (with symbols, no optimization)"
else
echo "Build Type: PRODUCTION (optimized, stripped)"
fi
echo "DB Backend: $DB_BACKEND"
if [ "$TRULY_STATIC" = true ]; then
echo "Linkage: Fully static binary (Alpine MUSL-based)"
echo "Portability: Works on ANY Linux distribution"
else
echo "Linkage: Static binary (may have minimal dependencies)"
fi
echo ""
echo "✓ Build complete!"
echo "Size: $(ls -lh "$BUILD_DIR/$OUTPUT_NAME" | awk '{print $5}')"
echo ""
# Clean up Docker builder stage image
$DOCKER_CMD rmi c-relay-pg-static-builder-stage:latest > /dev/null 2>&1 || true
+21 -3
View File
@@ -61,7 +61,25 @@ $(TARGET): $(MAIN_SRC) $(NOSTR_CORE_LIB)
rm -rf /tmp/cr_lib && \
echo "Build complete: $(TARGET)"
clean:
rm -f $(TARGET)
# ------------------------------------------------------------------ #
# cache_all_test - feasibility test for "cache everything" approach #
# ------------------------------------------------------------------ #
TEST_TARGET = ../build/cache_all_test
TEST_SRC = src/cache_all_test.c src/debug.c src/jsonc_strip.c src/config.c \
src/state.c
.PHONY: all clean
$(TEST_TARGET): $(TEST_SRC) $(NOSTR_CORE_LIB)
@echo "Compiling cache_all_test for architecture: $(ARCH)"
@rm -rf /tmp/cr_lib && mkdir -p /tmp/cr_lib && \
ar x $(NOSTR_CORE_LIB) --output=/tmp/cr_lib && \
echo "Extracted $$(ls /tmp/cr_lib/*.o | wc -l) objects" && \
$(CC) $(CFLAGS) $(INCLUDES) $(TEST_SRC) /tmp/cr_lib/*.o -o $(TEST_TARGET) $(LIBS) && \
rm -rf /tmp/cr_lib && \
echo "Build complete: $(TEST_TARGET)"
cache_all_test: $(TEST_TARGET)
clean:
rm -f $(TARGET) $(TEST_TARGET)
.PHONY: all clean cache_all_test
+56
View File
@@ -0,0 +1,56 @@
{
// Cache-All Feasibility Test Config
// Root npubs to resolve follow graph from
"root_npubs": [
"npub1rmz9gu6de0m0u4ysrn39crrud099ahvfgs6pvasl4hpjr5ud7yus54xv06"
],
// Relays to test (from server relay list, connected ones only)
"upstream_relays": [
"wss://laantungir.net",
"wss://nos.lol",
"wss://nostr-pub.wellorder.net",
"wss://nostr.mom",
"wss://nostr.oxtr.dev",
"wss://offchain.pub",
"wss://premium.primal.net",
"wss://relay.damus.io",
"wss://relay.ditto.pub",
"wss://relay.divine.video",
"wss://relay.momostr.pink",
"wss://relay.mostr.pub",
"wss://relay.nostrplebs.com",
"wss://relay.primal.net",
"wss://theforest.nostr1.com"
],
// Local relay (not used by test, but required by config parser)
"local_relay": "ws://localhost:8888",
// Kinds to follow (not used by test, but required by config parser)
"kinds": [0, 1, 3, 5, 6, 7, 9735, 10002, 30023],
// Admin kinds
"admin_kinds": ["*"],
// Backfill config (not used by test)
"backfill": {
"enabled": false,
"events_per_tick": 500,
"tick_interval_seconds": 5
},
// Live config (not used by test)
"live": {
"enabled": false,
"resubscribe_interval_seconds": 300
},
// Follow graph refresh (not used by test)
"follow_graph_refresh_seconds": 600,
// State (not used by test)
"state": {
"backfilled_until": 0
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"root_npubs": [
"npub1rmz9gu6de0m0u4ysrn39crrud099ahvfgs6pvasl4hpjr5ud7yus54xv06"
],
"upstream_relays": [
"wss://relay.primal.net"
],
"local_relay": "ws://localhost:8888",
"kinds": [0, 1, 3, 5, 6, 7, 9735, 10002, 30023],
"admin_kinds": ["*"],
"backfill": { "enabled": false, "events_per_tick": 500, "tick_interval_seconds": 5 },
"live": { "enabled": false, "resubscribe_interval_seconds": 300 },
"follow_graph_refresh_seconds": 600,
"state": { "backfilled_until": 0 }
}
+11 -2
View File
@@ -291,9 +291,11 @@ int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
char pk[CR_HEX_LEN];
if (pg_inbox_pick_next_author_with_incomplete_relays(pk, sizeof(pk),
&bf->author_round_cursor) != 0) {
/* No incomplete authors - steady state. */
/* No incomplete authors — backfill is complete. */
if (bf->in_progress) {
DEBUG_INFO("backfill: COMPLETE — all authors drained, backfill will stop");
}
bf->in_progress = 0;
DEBUG_INFO("backfill: no authors with incomplete relays, steady-state");
return -2;
}
@@ -328,6 +330,13 @@ int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
? (long)cur_node->valuedouble : 0;
if (until_cursor == 0) until_cursor = (long)now;
/* Skip this relay if backfill is not enabled for it in caching_relays.
* This allows the user to disable backfill on specific relays via the UI. */
if (pg_inbox_is_relay_backfill_enabled(relay_url) != 1) {
DEBUG_TRACE("backfill: %s @ %s skipped (backfill not enabled)", pk, relay_url);
continue;
}
/* Build the filter for this single relay. */
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
+774
View File
@@ -0,0 +1,774 @@
/*
* cache_all_test - feasibility test for "cache everything" approach.
*
* Connects to configured relays, subscribes to ALL events (no filter),
* logs verbatim relay responses, tracks kind distribution and event sizes.
*
* Usage:
* ./build/cache_all_test -c cache_all_test_config.jsonc -t 300 -d 3
*
* Output files:
* cache_all_test_raw_relay_<ts>.log - verbatim relay responses
* cache_all_test_report_<ts>.txt - summary report
* cache_all_test_stats_<ts>.json - machine-readable stats
*/
#define _GNU_SOURCE
#include "cache_all_test.h"
#include "config.h"
#include "state.h"
#include "debug.h"
#include "main.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include "../nostr_core_lib/nostr_core/nostr_log.h"
#include "../nostr_core_lib/cjson/cJSON.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <time.h>
#include <unistd.h>
#include <getopt.h>
#include <errno.h>
/* ------------------------------------------------------------------ */
/* Forward nostr_core_lib logging */
/* ------------------------------------------------------------------ */
static void nostr_log_forwarder(int level, const char *component,
const char *message, void *user_data) {
(void)user_data;
if (level >= 5) {
DEBUG_TRACE("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else if (level >= 4) {
DEBUG_LOG("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else if (level >= 3) {
DEBUG_INFO("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else if (level >= 2) {
DEBUG_WARN("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else {
DEBUG_ERROR("[nostr:%s] %s", component ? component : "?", message ? message : "");
}
}
/* ------------------------------------------------------------------ */
/* Shutdown flag */
/* ------------------------------------------------------------------ */
static volatile sig_atomic_t g_shutdown = 0;
static void on_signal(int sig) {
if (sig == SIGINT || sig == SIGTERM) g_shutdown = 1;
}
/* ------------------------------------------------------------------ */
/* Per-relay stats */
/* ------------------------------------------------------------------ */
#define MAX_RELAYS 64
#define MAX_KIND_TRACK 256
typedef struct {
char url[256];
long events_received;
long eose_count;
long closed_count;
long notice_count;
long error_count;
long disconnect_count;
long reconnect_count;
int is_connected;
} relay_stats_t;
typedef struct {
int kind;
long count;
long total_bytes;
long max_bytes;
} kind_stat_t;
/* ------------------------------------------------------------------ */
/* Global test state */
/* ------------------------------------------------------------------ */
typedef struct {
relay_stats_t relays[MAX_RELAYS];
int relay_count;
kind_stat_t kinds[MAX_KIND_TRACK];
int kind_count;
long total_events;
long total_bytes;
long max_event_bytes;
int max_event_kind;
char pubkeys_seen[65536][65];
int pubkey_count;
FILE *raw_log;
char raw_log_path[1024];
time_t start_time;
long duration_seconds;
time_t last_summary_log;
cr_seen_ring_t seen;
} test_state_t;
static test_state_t g_state;
/* ------------------------------------------------------------------ */
/* Stats helpers */
/* ------------------------------------------------------------------ */
static int find_or_add_relay(const char *url) {
for (int i = 0; i < g_state.relay_count; i++) {
if (strcmp(g_state.relays[i].url, url) == 0) return i;
}
if (g_state.relay_count >= MAX_RELAYS) return -1;
int idx = g_state.relay_count++;
strncpy(g_state.relays[idx].url, url, sizeof(g_state.relays[idx].url) - 1);
return idx;
}
static int find_or_add_kind(int kind) {
for (int i = 0; i < g_state.kind_count; i++) {
if (g_state.kinds[i].kind == kind) return i;
}
if (g_state.kind_count >= MAX_KIND_TRACK) return -1;
int idx = g_state.kind_count++;
g_state.kinds[idx].kind = kind;
g_state.kinds[idx].count = 0;
g_state.kinds[idx].total_bytes = 0;
g_state.kinds[idx].max_bytes = 0;
return idx;
}
static int find_or_add_pubkey(const char *hex) {
for (int i = 0; i < g_state.pubkey_count; i++) {
if (strcmp(g_state.pubkeys_seen[i], hex) == 0) return i;
}
if (g_state.pubkey_count >= 65536) return -1;
strncpy(g_state.pubkeys_seen[g_state.pubkey_count], hex, 64);
g_state.pubkeys_seen[g_state.pubkey_count][64] = '\0';
return g_state.pubkey_count++;
}
/* ------------------------------------------------------------------ */
/* Raw relay log writing */
/* ------------------------------------------------------------------ */
static void write_raw_log(const char *relay_url, const char *fmt, ...) {
if (!g_state.raw_log) return;
time_t now = time(NULL);
struct tm *tm_info = localtime(&now);
char ts[32];
strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", tm_info);
fprintf(g_state.raw_log, "[%s] [RELAY] %s ", ts, relay_url ? relay_url : "?");
va_list args;
va_start(args, fmt);
vfprintf(g_state.raw_log, fmt, args);
va_end(args);
fprintf(g_state.raw_log, "\n");
fflush(g_state.raw_log);
}
/* ------------------------------------------------------------------ */
/* Subscription callbacks */
/* ------------------------------------------------------------------ */
static void on_event(cJSON *event, const char *relay_url, void *user_data) {
(void)user_data;
if (!event) return;
cJSON *id = cJSON_GetObjectItem(event, "id");
cJSON *kind = cJSON_GetObjectItem(event, "kind");
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
if (!id || !cJSON_IsString(id)) return;
if (!kind || !cJSON_IsNumber(kind)) return;
if (!pubkey || !cJSON_IsString(pubkey)) return;
const char *eid = cJSON_GetStringValue(id);
int kind_num = (int)cJSON_GetNumberValue(kind);
const char *pk = cJSON_GetStringValue(pubkey);
/* Dedup via seen ring. */
if (cr_seen_ring_add(&g_state.seen, eid) == 0) return;
/* Calculate JSON size. */
char *json_str = cJSON_PrintUnformatted(event);
long ev_size = json_str ? (long)strlen(json_str) : 0;
free(json_str);
/* Update relay stats. */
int ridx = relay_url ? find_or_add_relay(relay_url) : -1;
if (ridx >= 0) g_state.relays[ridx].events_received++;
/* Update kind stats. */
int kidx = find_or_add_kind(kind_num);
if (kidx >= 0) {
g_state.kinds[kidx].count++;
g_state.kinds[kidx].total_bytes += ev_size;
if (ev_size > g_state.kinds[kidx].max_bytes)
g_state.kinds[kidx].max_bytes = ev_size;
}
/* Update global stats. */
g_state.total_events++;
g_state.total_bytes += ev_size;
if (ev_size > g_state.max_event_bytes) {
g_state.max_event_bytes = ev_size;
g_state.max_event_kind = kind_num;
}
find_or_add_pubkey(pk);
/* Periodic summary log (every 1000 events). */
if (g_state.total_events % 1000 == 0) {
time_t now = time(NULL);
long elapsed = (long)(now - g_state.start_time);
double rate = elapsed > 0 ? (double)g_state.total_events / elapsed : 0;
DEBUG_INFO("[STATS] %ld events in %lds (%.1f ev/s) | "
"%d relays | %d kinds | %d pubkeys | %.1f MB",
g_state.total_events, elapsed, rate,
g_state.relay_count, g_state.kind_count,
g_state.pubkey_count,
(double)g_state.total_bytes / (1024.0 * 1024.0));
}
}
static void on_eose(cJSON **events, int event_count, void *user_data) {
(void)events; (void)event_count; (void)user_data;
/* EOSE doesn't carry a relay URL in the callback, so we just log it. */
DEBUG_INFO("[RELAY] EOSE");
write_raw_log("?", "EOSE");
}
/* ------------------------------------------------------------------ */
/* Report generation */
/* ------------------------------------------------------------------ */
static int kind_name(int kind, char *buf, size_t bufsz) {
switch (kind) {
case 0: snprintf(buf, bufsz, "Profile metadata"); return 1;
case 1: snprintf(buf, bufsz, "Text note"); return 1;
case 3: snprintf(buf, bufsz, "Follow list"); return 1;
case 4: snprintf(buf, bufsz, "Encrypted DM"); return 1;
case 5: snprintf(buf, bufsz, "Deletion"); return 1;
case 6: snprintf(buf, bufsz, "Repost"); return 1;
case 7: snprintf(buf, bufsz, "Reaction"); return 1;
case 13: snprintf(buf, bufsz, "Seal (NIP-59)"); return 1;
case 16: snprintf(buf, bufsz, "Generic repost"); return 1;
case 20: snprintf(buf, bufsz, "Picture"); return 1;
case 21: snprintf(buf, bufsz, "Video"); return 1;
case 445: snprintf(buf, bufsz, "Encrypted group msg"); return 1;
case 1059: snprintf(buf, bufsz, "Gift wrap seal"); return 1;
case 1063: snprintf(buf, bufsz, "File metadata"); return 1;
case 1111: snprintf(buf, bufsz, "Comment"); return 1;
case 1984: snprintf(buf, bufsz, "Report"); return 1;
case 9734: snprintf(buf, bufsz, "Zap request"); return 1;
case 9735: snprintf(buf, bufsz, "Zap receipt"); return 1;
case 10000: snprintf(buf, bufsz, "Mute list"); return 1;
case 10001: snprintf(buf, bufsz, "Pinned notes"); return 1;
case 10002: snprintf(buf, bufsz, "Relay list"); return 1;
case 10003: snprintf(buf, bufsz, "Bookmarks"); return 1;
case 10050: snprintf(buf, bufsz, "DM relays"); return 1;
case 13194: snprintf(buf, bufsz, "NWC info"); return 1;
case 21059: snprintf(buf, bufsz, "Gift wrap (NIP-59)"); return 1;
case 23194: snprintf(buf, bufsz, "NWC request"); return 1;
case 23195: snprintf(buf, bufsz, "NWC response"); return 1;
case 30023: snprintf(buf, bufsz, "Long-form article"); return 1;
case 30078: snprintf(buf, bufsz, "App data"); return 1;
case 30089: snprintf(buf, bufsz, "Chunked data"); return 1;
case 30315: snprintf(buf, bufsz, "User status"); return 1;
case 31989: snprintf(buf, bufsz, "App handler rec"); return 1;
case 31990: snprintf(buf, bufsz, "App handler info"); return 1;
case 34550: snprintf(buf, bufsz, "Community def"); return 1;
default:
if (kind >= 20000 && kind < 30000)
snprintf(buf, bufsz, "Ephemeral");
else if (kind >= 30000 && kind < 40000)
snprintf(buf, bufsz, "Addressable");
else
snprintf(buf, bufsz, "Unknown");
return 0;
}
}
static int write_report(void) {
char path[1024];
time_t now = time(NULL);
struct tm *tm_info = localtime(&now);
char ts[32];
strftime(ts, sizeof(ts), "%Y%m%d_%H%M%S", tm_info);
snprintf(path, sizeof(path), "cache_all_test_report_%s.txt", ts);
FILE *f = fopen(path, "w");
if (!f) { DEBUG_ERROR("failed to write report"); return -1; }
long elapsed = (long)(now - g_state.start_time);
int hours = (int)(elapsed / 3600);
int mins = (int)((elapsed % 3600) / 60);
int secs = (int)(elapsed % 60);
fprintf(f, "=== Cache-All Feasibility Test Report ===\n");
fprintf(f, "Duration: %dh %dm %ds\n", hours, mins, secs);
fprintf(f, "\n=== Global Summary ===\n");
fprintf(f, "Total events: %ld\n", g_state.total_events);
fprintf(f, "Total bytes: %ld (%.2f MB)\n",
g_state.total_bytes, (double)g_state.total_bytes / (1024.0 * 1024.0));
fprintf(f, "Avg event size: %.0f bytes\n",
g_state.total_events > 0
? (double)g_state.total_bytes / g_state.total_events : 0);
fprintf(f, "Max event: %ld bytes (kind %d)\n",
g_state.max_event_bytes, g_state.max_event_kind);
fprintf(f, "Unique pubkeys: %d\n", g_state.pubkey_count);
fprintf(f, "Relays: %d\n\n", g_state.relay_count);
fprintf(f, "=== Relay Summary ===\n");
fprintf(f, "%-48s %8s %6s %6s %6s %6s %6s %s\n",
"Relay", "Events", "EOSE", "CLOSED", "NOTICE", "Err", "Disc", "Status");
for (int i = 0; i < g_state.relay_count; i++) {
relay_stats_t *r = &g_state.relays[i];
fprintf(f, "%-48s %8ld %6ld %6ld %6ld %6ld %6ld %s\n",
r->url, r->events_received, r->eose_count,
r->closed_count, r->notice_count, r->error_count,
r->disconnect_count,
r->is_connected ? "OK" : "DOWN");
}
fprintf(f, "\n");
fprintf(f, "=== Kind Distribution ===\n");
fprintf(f, "%-8s %-30s %8s %6s %14s %10s %10s\n",
"Kind", "Name", "Count", "%", "Total Bytes", "Avg", "Max");
/* Sort by count descending. */
for (int i = 0; i < g_state.kind_count; i++) {
for (int j = i + 1; j < g_state.kind_count; j++) {
if (g_state.kinds[j].count > g_state.kinds[i].count) {
kind_stat_t tmp = g_state.kinds[i];
g_state.kinds[i] = g_state.kinds[j];
g_state.kinds[j] = tmp;
}
}
}
for (int i = 0; i < g_state.kind_count; i++) {
kind_stat_t *k = &g_state.kinds[i];
double pct = g_state.total_events > 0
? (double)k->count / g_state.total_events * 100.0 : 0;
char name[64];
kind_name(k->kind, name, sizeof(name));
fprintf(f, "%-8d %-30s %8ld %5.1f%% %12ld %8.0f %8ld\n",
k->kind, name, k->count, pct,
k->total_bytes,
k->count > 0 ? (double)k->total_bytes / k->count : 0,
k->max_bytes);
}
fprintf(f, "\n");
fprintf(f, "=== Raw Relay Response Log ===\n");
if (g_state.raw_log) {
fflush(g_state.raw_log);
FILE *rl = fopen(g_state.raw_log_path, "r");
if (rl) {
char line[1024];
while (fgets(line, sizeof(line), rl)) fputs(line, f);
fclose(rl);
}
}
fprintf(f, "\n");
fclose(f);
DEBUG_INFO("report: %s", path);
return 0;
}
static int write_json_stats(void) {
char path[1024];
time_t now = time(NULL);
struct tm *tm_info = localtime(&now);
char ts[32];
strftime(ts, sizeof(ts), "%Y%m%d_%H%M%S", tm_info);
snprintf(path, sizeof(path), "cache_all_test_stats_%s.json", ts);
cJSON *root = cJSON_CreateObject();
if (!root) return -1;
long elapsed = (long)(now - g_state.start_time);
cJSON_AddNumberToObject(root, "duration_seconds", elapsed);
cJSON_AddNumberToObject(root, "total_events", g_state.total_events);
cJSON_AddNumberToObject(root, "total_bytes", g_state.total_bytes);
cJSON_AddNumberToObject(root, "unique_pubkeys", g_state.pubkey_count);
cJSON_AddNumberToObject(root, "relay_count", g_state.relay_count);
cJSON *relays = cJSON_CreateArray();
for (int i = 0; i < g_state.relay_count; i++) {
relay_stats_t *r = &g_state.relays[i];
cJSON *rj = cJSON_CreateObject();
cJSON_AddStringToObject(rj, "url", r->url);
cJSON_AddNumberToObject(rj, "events", r->events_received);
cJSON_AddNumberToObject(rj, "eose", r->eose_count);
cJSON_AddNumberToObject(rj, "closed", r->closed_count);
cJSON_AddNumberToObject(rj, "notice", r->notice_count);
cJSON_AddNumberToObject(rj, "errors", r->error_count);
cJSON_AddNumberToObject(rj, "disconnects", r->disconnect_count);
cJSON_AddBoolToObject(rj, "connected", r->is_connected);
cJSON_AddItemToArray(relays, rj);
}
cJSON_AddItemToObject(root, "relays", relays);
cJSON *kinds = cJSON_CreateArray();
for (int i = 0; i < g_state.kind_count; i++) {
kind_stat_t *k = &g_state.kinds[i];
cJSON *kj = cJSON_CreateObject();
cJSON_AddNumberToObject(kj, "kind", k->kind);
cJSON_AddNumberToObject(kj, "count", k->count);
cJSON_AddNumberToObject(kj, "total_bytes", k->total_bytes);
cJSON_AddNumberToObject(kj, "max_bytes", k->max_bytes);
cJSON_AddItemToArray(kinds, kj);
}
cJSON_AddItemToObject(root, "kinds", kinds);
char *json = cJSON_Print(root);
cJSON_Delete(root);
if (!json) return -1;
FILE *f = fopen(path, "w");
if (!f) { free(json); return -1; }
fprintf(f, "%s\n", json);
fclose(f);
free(json);
DEBUG_INFO("stats: %s", path);
return 0;
}
/* ------------------------------------------------------------------ */
/* Main entry point */
/* ------------------------------------------------------------------ */
int run_cache_all_test(const char *config_path,
long duration_seconds,
int log_level) {
memset(&g_state, 0, sizeof(g_state));
g_state.duration_seconds = duration_seconds;
cr_seen_ring_init(&g_state.seen);
/* Open raw relay log. */
time_t now = time(NULL);
struct tm *tm_info = localtime(&now);
char ts[32];
strftime(ts, sizeof(ts), "%Y%m%d_%H%M%S", tm_info);
snprintf(g_state.raw_log_path, sizeof(g_state.raw_log_path),
"cache_all_test_raw_relay_%s.log", ts);
g_state.raw_log = fopen(g_state.raw_log_path, "w");
if (!g_state.raw_log) {
fprintf(stderr, "ERROR: cannot create raw log '%s': %s\n",
g_state.raw_log_path, strerror(errno));
return 1;
}
fprintf(g_state.raw_log, "# Cache-All Feasibility Test - Raw Relay Log\n");
fprintf(g_state.raw_log, "# Started: %s", ctime(&now));
fprintf(g_state.raw_log, "# Config: %s\n", config_path ? config_path : "(none)");
fprintf(g_state.raw_log, "# Duration: %ld seconds\n", duration_seconds);
fflush(g_state.raw_log);
debug_init(log_level);
DEBUG_INFO("cache_all_test starting (config=%s, duration=%lds, loglevel=%d)",
config_path ? config_path : "(none)", duration_seconds, log_level);
nostr_set_log_callback(nostr_log_forwarder, NULL);
nostr_set_log_level((nostr_log_level_t)log_level);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_signal;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
signal(SIGPIPE, SIG_IGN);
if (nostr_crypto_init() != NOSTR_SUCCESS) {
fprintf(stderr, "ERROR: nostr_crypto_init failed\n");
fclose(g_state.raw_log);
return 1;
}
/* Load config. */
cr_config_t cfg;
if (cr_config_load(&cfg, config_path) != 0) {
fprintf(stderr, "ERROR: failed to load config\n");
fclose(g_state.raw_log);
nostr_crypto_cleanup();
return 1;
}
/* Create upstream pool with custom reconnect config.
* Use 25s ping interval to stay ahead of Primal's 30s idle timeout. */
nostr_pool_reconnect_config_t reconnect_cfg = {
.enable_auto_reconnect = 1,
.max_reconnect_attempts = 10,
.initial_reconnect_delay_ms = 1000,
.max_reconnect_delay_ms = 30000,
.reconnect_backoff_multiplier = 2,
.reconnect_reset_stability_seconds = 30,
.ping_interval_seconds = 25,
.pong_timeout_seconds = 10
};
nostr_relay_pool_t *upstream = nostr_relay_pool_create(&reconnect_cfg);
if (!upstream) {
fprintf(stderr, "ERROR: failed to create pool\n");
fclose(g_state.raw_log);
cr_config_free(&cfg);
nostr_crypto_cleanup();
return 1;
}
for (int i = 0; i < cfg.upstream_count; i++) {
if (nostr_relay_pool_add_relay(upstream, cfg.upstream_relays[i])
== NOSTR_SUCCESS) {
DEBUG_INFO("added relay: %s", cfg.upstream_relays[i]);
find_or_add_relay(cfg.upstream_relays[i]);
}
}
/* Wait for connections (up to 15s). */
DEBUG_INFO("waiting for connections...");
for (int i = 0; i < 150 && !g_shutdown; i++) {
nostr_relay_pool_run(upstream, 100);
if (i % 50 == 0 && i > 0) {
int connected = 0;
for (int r = 0; r < g_state.relay_count; r++) {
nostr_pool_relay_status_t st =
nostr_relay_pool_get_relay_status(upstream,
g_state.relays[r].url);
g_state.relays[r].is_connected = (st == NOSTR_POOL_RELAY_CONNECTED);
if (g_state.relays[r].is_connected) connected++;
}
DEBUG_INFO(" %d/%d relays connected after %dms",
connected, g_state.relay_count, (i+1)*100);
}
}
/* Log final connection status. */
for (int r = 0; r < g_state.relay_count; r++) {
nostr_pool_relay_status_t st =
nostr_relay_pool_get_relay_status(upstream, g_state.relays[r].url);
g_state.relays[r].is_connected = (st == NOSTR_POOL_RELAY_CONNECTED);
const char *status_str = "?";
if (st == NOSTR_POOL_RELAY_CONNECTED) status_str = "connected";
else if (st == NOSTR_POOL_RELAY_CONNECTING) status_str = "connecting";
else if (st == NOSTR_POOL_RELAY_DISCONNECTED) status_str = "disconnected";
DEBUG_INFO(" %s: %s", g_state.relays[r].url, status_str);
write_raw_log(g_state.relays[r].url, "STATUS: %s", status_str);
}
/* Open subscription: ALL events (no filter). */
cJSON *filter = cJSON_CreateObject();
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)time(NULL)));
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
const char **urls = NULL;
if (n > 0) {
urls = malloc(n * sizeof(char *));
for (int j = 0; j < n; j++) urls[j] = listed[j];
}
nostr_pool_subscription_t *sub = NULL;
if (n > 0 && urls) {
sub = nostr_relay_pool_subscribe(
upstream, urls, n, filter,
on_event, on_eose, NULL,
0, 1, NOSTR_POOL_EOSE_FULL_SET, 0, 0);
}
free(urls);
free(listed);
free(statuses);
cJSON_Delete(filter);
if (!sub) {
DEBUG_ERROR("failed to open subscription");
} else {
DEBUG_INFO("subscription opened on %d relays", n);
}
/* Main loop. */
g_state.start_time = time(NULL);
g_state.last_summary_log = g_state.start_time;
time_t last_resubscribe = g_state.start_time;
DEBUG_INFO("entering main loop for %ld seconds", duration_seconds);
while (!g_shutdown) {
if (duration_seconds > 0) {
long elapsed = (long)(time(NULL) - g_state.start_time);
if (elapsed >= duration_seconds) {
DEBUG_INFO("duration reached (%lds)", elapsed);
break;
}
}
nostr_relay_pool_run(upstream, 100);
/* Poll connection status. */
for (int r = 0; r < g_state.relay_count; r++) {
nostr_pool_relay_status_t st =
nostr_relay_pool_get_relay_status(upstream, g_state.relays[r].url);
int was = g_state.relays[r].is_connected;
int now = (st == NOSTR_POOL_RELAY_CONNECTED);
g_state.relays[r].is_connected = now;
if (now && !was) {
g_state.relays[r].reconnect_count++;
DEBUG_INFO("[RELAY] %s CONNECTED", g_state.relays[r].url);
write_raw_log(g_state.relays[r].url, "CONNECTED");
} else if (!now && was) {
g_state.relays[r].disconnect_count++;
const char *err = nostr_relay_pool_get_relay_last_connection_error(
upstream, g_state.relays[r].url);
DEBUG_WARN("[RELAY] %s DISCONNECTED: %s",
g_state.relays[r].url, err ? err : "");
write_raw_log(g_state.relays[r].url, "DISCONNECTED: %s", err ? err : "");
}
}
/* Periodic resubscribe (every 5 min) to re-establish REQ on reconnected relays. */
time_t now_t = time(NULL);
if ((now_t - last_resubscribe) >= 300) {
if (sub) {
nostr_pool_subscription_close(sub);
sub = NULL;
}
/* Re-open subscription with fresh filter. */
cJSON *new_filter = cJSON_CreateObject();
cJSON_AddItemToObject(new_filter, "since",
cJSON_CreateNumber((double)time(NULL)));
char **rel_list = NULL;
nostr_pool_relay_status_t *rel_statuses = NULL;
int rel_n = nostr_relay_pool_list_relays(upstream, &rel_list, &rel_statuses);
const char **rel_urls = NULL;
if (rel_n > 0) {
rel_urls = malloc(rel_n * sizeof(char *));
for (int j = 0; j < rel_n; j++) rel_urls[j] = rel_list[j];
}
if (rel_n > 0 && rel_urls) {
sub = nostr_relay_pool_subscribe(
upstream, rel_urls, rel_n, new_filter,
on_event, on_eose, NULL,
0, 1, NOSTR_POOL_EOSE_FULL_SET, 0, 0);
}
free(rel_urls);
free(rel_list);
free(rel_statuses);
cJSON_Delete(new_filter);
if (sub) {
DEBUG_INFO("resubscribed on %d relays", rel_n);
} else {
DEBUG_WARN("resubscribe failed");
}
last_resubscribe = now_t;
}
/* Periodic heartbeat with ping stats. */
if ((now_t - g_state.last_summary_log) >= 30) {
long elapsed = (long)(now_t - g_state.start_time);
double rate = elapsed > 0 ? (double)g_state.total_events / elapsed : 0;
int connected = 0;
for (int r = 0; r < g_state.relay_count; r++)
if (g_state.relays[r].is_connected) connected++;
/* Get ping stats for first connected relay. */
double ping = 0;
int ping_samples = 0;
for (int r = 0; r < g_state.relay_count && ping_samples == 0; r++) {
if (g_state.relays[r].is_connected) {
const nostr_relay_stats_t *stats =
nostr_relay_pool_get_relay_stats(upstream,
g_state.relays[r].url);
if (stats) {
ping = stats->ping_latency_current;
ping_samples = stats->ping_samples;
}
}
}
DEBUG_INFO("[HEARTBEAT] %ld events in %lds (%.1f ev/s) | "
"%d/%d relays | %d kinds | %d pubkeys | "
"ping=%.1fms(%d)",
g_state.total_events, elapsed, rate,
connected, g_state.relay_count,
g_state.kind_count, g_state.pubkey_count,
ping * 1000, ping_samples);
g_state.last_summary_log = now_t;
}
}
if (sub) nostr_pool_subscription_close(sub);
DEBUG_INFO("writing reports...");
write_report();
write_json_stats();
if (g_state.raw_log) {
fprintf(g_state.raw_log, "# Test ended: %s", ctime(&now));
fclose(g_state.raw_log);
DEBUG_INFO("raw log: %s", g_state.raw_log_path);
}
nostr_relay_pool_destroy(upstream);
cr_config_free(&cfg);
nostr_crypto_cleanup();
DEBUG_INFO("clean exit");
return 0;
}
/* ------------------------------------------------------------------ */
/* Standalone entry point */
/* ------------------------------------------------------------------ */
static void usage(const char *prog) {
fprintf(stderr,
"cache_all_test %s - feasibility test for cache-all approach\n"
"\n"
"Usage: %s -c <config.jsonc> -t <seconds> [options]\n"
"\n"
"Options:\n"
" -c, --config <file> Path to .jsonc config file (required)\n"
" -t, --time <seconds> Test duration in seconds (required)\n"
" -d, --debug <level> Log level 0-5 (default: 3)\n"
" -h, --help Show this help\n"
"\n"
"Output files:\n"
" cache_all_test_raw_relay_<ts>.log - verbatim relay responses\n"
" cache_all_test_report_<ts>.txt - summary report\n"
" cache_all_test_stats_<ts>.json - machine-readable stats\n",
CR_VERSION, prog);
}
int main(int argc, char **argv) {
const char *config_path = NULL;
long duration = 0;
int log_level = DEBUG_LEVEL_INFO;
static struct option longopts[] = {
{"config", required_argument, 0, 'c'},
{"time", required_argument, 0, 't'},
{"debug", required_argument, 0, 'd'},
{"help", no_argument, 0, 'h'},
{0, 0, 0, 0}
};
int opt;
while ((opt = getopt_long(argc, argv, "c:t:d:h", longopts, NULL)) != -1) {
switch (opt) {
case 'c': config_path = optarg; break;
case 't': duration = atol(optarg); break;
case 'd': log_level = atoi(optarg); break;
case 'h': usage(argv[0]); return 0;
default: usage(argv[0]); return 1;
}
}
if (!config_path) {
fprintf(stderr, "ERROR: -c <config> is required\n\n");
usage(argv[0]);
return 1;
}
if (duration <= 0) {
fprintf(stderr, "ERROR: -t <seconds> is required and must be > 0\n\n");
usage(argv[0]);
return 1;
}
return run_cache_all_test(config_path, duration, log_level);
}
+26
View File
@@ -0,0 +1,26 @@
/*
* cache_all_test - feasibility test for "cache everything" approach.
*
* Standalone test program that connects to all outbox relays of followed
* pubkeys and subscribes to ALL events (no author filter) to determine
* if relays will tolerate this level of data flow.
*
* Logs raw relay responses verbatim. Tracks kind distribution, event sizes,
* per-relay stats. Generates summary report + raw relay log file.
*
* See plans/cache_all_feasibility_test_plan.md for full design.
*/
#ifndef CACHE_ALL_TEST_H
#define CACHE_ALL_TEST_H
/* Run the cache-all feasibility test.
* config_path: path to .jsonc config file
* duration_seconds: how long to run (0 = run until SIGINT)
* log_level: debug level 0-5
* Returns 0 on success, -1 on error.
*/
int run_cache_all_test(const char *config_path,
long duration_seconds,
int log_level);
#endif /* CACHE_ALL_TEST_H */
+7 -2
View File
@@ -22,8 +22,13 @@ typedef struct {
} cr_backfill_config_t;
typedef struct {
int enabled;
int resubscribe_interval_seconds;
int enabled;
int resubscribe_interval_seconds;
char strategy[16]; /* "whitelist" or "cache_all" */
int kinds[CR_MAX_KINDS]; /* live-specific kinds (empty = use cfg->kinds) */
int kind_count;
long since_seconds; /* lookback: since = now - since_seconds (0 = now) */
int limit; /* max events per response, 0 = no limit */
} cr_live_config_t;
/* Persistent state. The per-author until-cursor drain model stores all
+70 -12
View File
@@ -23,6 +23,17 @@ static cr_live_ctx_t g_live_ctx;
static void live_on_event(cJSON *event, const char *relay_url, void *user_data) {
cr_live_ctx_t *ctx = (cr_live_ctx_t *)user_data;
(void)relay_url;
/* Fast debounce: check the live-specific seen ring before publishing.
* The same event can arrive from multiple relays within seconds;
* this catches duplicates in-memory without hitting the DB. */
cJSON *id = cJSON_GetObjectItem(event, "id");
if (id && cJSON_IsString(id)) {
if (cr_seen_ring_add(&ctx->live->live_seen, cJSON_GetStringValue(id)) == 0) {
return; /* already seen recently by the live subscriber */
}
}
ctx->live->events_received++;
cr_sink_publish(ctx->sink, event);
@@ -60,24 +71,44 @@ static void live_on_eose(cJSON **events, int event_count, void *user_data) {
/* Build a filter for non-admin followed pubkeys with regular kinds. */
static cJSON *build_follows_filter(cr_config_t *cfg, cr_pubkey_set_t *followed) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
for (int i = 0; i < followed->count; i++) {
/* Skip admin pubkeys - they get their own subscription. */
if (cr_follow_is_root(cfg, followed->items[i])) continue;
cJSON_AddItemToArray(authors, cJSON_CreateString(followed->items[i]));
}
cJSON_AddItemToObject(filter, "authors", authors);
/* In cache_all mode, omit the authors filter entirely. */
if (strcmp(cfg->live.strategy, "cache_all") != 0) {
cJSON *authors = cJSON_CreateArray();
for (int i = 0; i < followed->count; i++) {
if (cr_follow_is_root(cfg, followed->items[i])) continue;
cJSON_AddItemToArray(authors, cJSON_CreateString(followed->items[i]));
}
cJSON_AddItemToObject(filter, "authors", authors);
}
/* Use live-specific kinds if configured, otherwise fall back to cfg->kinds. */
cJSON *kinds = cJSON_CreateArray();
for (int i = 0; i < cfg->kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
int kind_count = (cfg->live.kind_count > 0) ? cfg->live.kind_count : cfg->kind_count;
int *kind_list = (cfg->live.kind_count > 0) ? cfg->live.kinds : cfg->kinds;
for (int i = 0; i < kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(kind_list[i]));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)time(NULL)));
/* Configurable since lookback. */
time_t since_ts;
if (cfg->live.since_seconds > 0) {
since_ts = time(NULL) - cfg->live.since_seconds;
} else {
since_ts = time(NULL);
}
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)since_ts));
/* Configurable limit. */
if (cfg->live.limit > 0) {
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)cfg->live.limit));
}
return filter;
}
/* Build a filter for admin (root) pubkeys with admin_kinds (or all kinds). */
/* Build a filter for admin (root) pubkeys with admin_kinds (or all kinds).
* In cache_all mode, this is not used (the follows sub covers everything). */
static cJSON *build_admin_filter(cr_config_t *cfg) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
@@ -93,7 +124,15 @@ static cJSON *build_admin_filter(cr_config_t *cfg) {
cJSON_AddItemToObject(filter, "kinds", kinds);
}
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)time(NULL)));
/* Use the same since lookback as the follows filter. */
time_t since_ts;
if (cfg->live.since_seconds > 0) {
since_ts = time(NULL) - cfg->live.since_seconds;
} else {
since_ts = time(NULL);
}
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)since_ts));
return filter;
}
@@ -138,6 +177,24 @@ static int open_subs(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upst
else follows_count++;
}
int is_cache_all = (strcmp(cfg->live.strategy, "cache_all") == 0);
/* In cache_all mode, open a single subscription with no authors filter.
* The follows sub handles everything — no separate admin sub needed. */
if (is_cache_all) {
cJSON *filter = build_follows_filter(cfg, followed);
live->follows_sub = open_subscription(upstream, filter);
cJSON_Delete(filter);
if (!live->follows_sub) {
DEBUG_ERROR("live: cache_all subscribe failed");
} else {
int kind_count = (cfg->live.kind_count > 0) ? cfg->live.kind_count : cfg->kind_count;
DEBUG_INFO("live: cache_all sub - ALL authors, %d kinds", kind_count);
}
live->last_resubscribe = time(NULL);
return 0;
}
/* Follows subscription (non-admin pubkeys, regular kinds). */
if (follows_count > 0) {
cJSON *filter = build_follows_filter(cfg, followed);
@@ -176,6 +233,7 @@ static int open_subs(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upst
int cr_live_open(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed, cr_sink_t *sink) {
memset(live, 0, sizeof(*live));
cr_seen_ring_init(&live->live_seen);
return open_subs(live, cfg, upstream, followed, sink);
}
+1
View File
@@ -22,6 +22,7 @@ typedef struct {
long events_received;
time_t last_resubscribe;
int follow_graph_changed; /* set when a root npub publishes a kind-3 */
cr_seen_ring_t live_seen; /* fast debounce ring (100 entries) for live events */
} cr_live_t;
/* Open the live subscription(s). Returns 0 on success. */
+99 -11
View File
@@ -327,6 +327,23 @@ int main(int argc, char **argv) {
memset(&relay_map, 0, sizeof(relay_map));
}
/* Sync discovered relays into the caching_relays table (PostgreSQL mode).
* Bootstrap relays are inserted with both flags enabled; outbox relays
* are inserted with both flags disabled (user enables via UI).
* We insert ALL outbox relays from every followed pubkey, not just the
* covering set, so the admin page shows every discovered relay. */
if (pg_conn) {
for (int i = 0; i < cfg.upstream_count; i++) {
pg_inbox_ensure_relay(cfg.upstream_relays[i], 1, 1, 1);
}
for (int oi = 0; oi < relay_map.outbox_count; oi++) {
cr_outbox_entry_t *oe = &relay_map.outboxes[oi];
for (int r = 0; r < oe->relay_count; r++) {
pg_inbox_ensure_relay(oe->relays[r], 0, 0, 0);
}
}
}
/* Add discovered outbox relays to the upstream pool. */
for (int i = 0; i < relay_map.selected_count; i++) {
/* Check if already in the pool (bootstrap relays may already be there). */
@@ -450,11 +467,59 @@ int main(int argc, char **argv) {
DEBUG_ERROR("config reload: failed to decode root npubs, keeping old config");
cr_config_free(&newcfg);
} else {
int backfill_was_enabled = cfg.backfill.enabled;
newcfg.state = cfg.state;
cr_config_free(&cfg);
cfg = newcfg;
config_generation = new_gen;
DEBUG_INFO("config reloaded from PostgreSQL");
/* Apply the global backfill toggle immediately. */
if (backfill_was_enabled && !cfg.backfill.enabled) {
bf.in_progress = 0;
DEBUG_INFO("backfill: disabled by configuration reload");
} else if (!backfill_was_enabled && cfg.backfill.enabled) {
bf.in_progress = 1;
DEBUG_INFO("backfill: enabled by configuration reload");
}
/* Sync upstream pool: add newly-enabled relays and
* remove newly-disabled ones. */
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
/* Remove relays no longer in the enabled set. */
for (int j = 0; j < n; j++) {
int still_enabled = 0;
for (int ri = 0; ri < cfg.upstream_count; ri++) {
if (strcmp(listed[j], cfg.upstream_relays[ri]) == 0) {
still_enabled = 1; break;
}
}
if (!still_enabled) {
nostr_relay_pool_remove_relay(upstream, listed[j]);
DEBUG_INFO("upstream: removed %s (hot-reload)", listed[j]);
}
}
/* Add newly-enabled relays. */
for (int ri = 0; ri < cfg.upstream_count; ri++) {
int already = 0;
for (int j = 0; j < n; j++) {
if (strcmp(listed[j], cfg.upstream_relays[ri]) == 0) {
already = 1; break;
}
}
if (!already) {
if (nostr_relay_pool_add_relay(upstream, cfg.upstream_relays[ri]) == NOSTR_SUCCESS) {
DEBUG_INFO("upstream: added %s (hot-reload)", cfg.upstream_relays[ri]);
}
}
}
free(listed);
free(statuses);
/* Force an immediate follow-graph refresh so the
* new root npub's follows are picked up right
* away (instead of waiting up to
@@ -479,9 +544,11 @@ int main(int argc, char **argv) {
/* Pump sink pool (flush publish callbacks). */
cr_sink_pump(&sink, 50);
/* Backfill tick. */
int brc = cr_backfill_tick(&bf, &cfg, upstream, &followed, &sink, &relay_map);
(void)brc;
/* Backfill tick — only run while globally enabled and work remains. */
if (cfg.backfill.enabled && bf.in_progress) {
int brc = cr_backfill_tick(&bf, &cfg, upstream, &followed, &sink, &relay_map);
(void)brc;
}
/* Immediate follow-graph refresh when a root npub publishes a new
* kind-3 contact list (detected by the live subscriber). */
@@ -593,16 +660,37 @@ int main(int argc, char **argv) {
long events_fetched = live.events_received + bf.events_total;
long inbox_inserts = sink.published_ok;
int connected = 0;
{
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
for (int j = 0; j < n; j++) {
if (statuses[j] == NOSTR_POOL_RELAY_CONNECTED) connected++;
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
if (n > 0) {
/* Build per-relay status arrays for the upstream_relays table. */
const char **urls = malloc((size_t)n * sizeof(char *));
int *codes = malloc((size_t)n * sizeof(int));
const char **texts = malloc((size_t)n * sizeof(char *));
if (urls && codes && texts) {
for (int j = 0; j < n; j++) {
urls[j] = listed[j];
codes[j] = (int)statuses[j];
if (statuses[j] == NOSTR_POOL_RELAY_CONNECTED) {
texts[j] = "connected";
connected++;
} else if (statuses[j] == NOSTR_POOL_RELAY_CONNECTING) {
texts[j] = "connecting";
} else if (statuses[j] == NOSTR_POOL_RELAY_DISCONNECTED) {
texts[j] = "disconnected";
} else {
texts[j] = "error";
}
}
pg_inbox_update_upstream_relays(urls, codes, texts, n);
}
free(listed);
free(statuses);
free(urls);
free(codes);
free(texts);
}
free(listed);
free(statuses);
int bf_complete = 0, bf_total = 0;
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
pg_inbox_update_status("running", config_generation, (long)now,
+40 -5
View File
@@ -131,11 +131,25 @@ int pg_config_load(cr_config_t *cfg) {
CR_NPUB_LEN, &cfg->root_npub_count);
free(npubs);
/* bootstrap_relays -> upstream_relays */
char *relays = get_key("caching_bootstrap_relays");
split_csv_str(relays, (char *)cfg->upstream_relays, CR_MAX_UPSTREAM,
CR_URL_LEN, &cfg->upstream_count);
free(relays);
/* Read enabled relays from caching_relays table (live_enabled OR
* backfill_enabled). Falls back to caching_bootstrap_relays config key
* if the table is empty (migration not yet run). */
const char *enabled_relays[CR_MAX_UPSTREAM];
int n = pg_inbox_get_enabled_relays(enabled_relays, CR_MAX_UPSTREAM);
if (n > 0) {
cfg->upstream_count = n;
for (int i = 0; i < n && i < CR_MAX_UPSTREAM; i++) {
strncpy((char *)cfg->upstream_relays[i], enabled_relays[i], CR_URL_LEN - 1);
cfg->upstream_relays[i][CR_URL_LEN - 1] = '\0';
}
pg_inbox_free_enabled_relays(enabled_relays, n);
} else {
/* Fallback: read from old config key. */
char *relays = get_key("caching_bootstrap_relays");
split_csv_str(relays, (char *)cfg->upstream_relays, CR_MAX_UPSTREAM,
CR_URL_LEN, &cfg->upstream_count);
free(relays);
}
/* local_relay - no longer required for PostgreSQL mode, but keep a
* placeholder for compatibility. The caller may override it. */
@@ -163,6 +177,27 @@ int pg_config_load(cr_config_t *cfg) {
cfg->live.enabled = get_bool("caching_live_enabled", 1);
cfg->live.resubscribe_interval_seconds =
get_int("caching_live_resubscribe_seconds", 300);
{
char *strategy = get_key("caching_live_strategy");
if (strategy) {
strncpy(cfg->live.strategy, strategy, sizeof(cfg->live.strategy) - 1);
cfg->live.strategy[sizeof(cfg->live.strategy) - 1] = '\0';
free(strategy);
} else {
strncpy(cfg->live.strategy, "whitelist", sizeof(cfg->live.strategy));
}
}
{
char *live_kinds = get_key("caching_live_kinds");
if (live_kinds && live_kinds[0] != '\0') {
split_csv_int(live_kinds, cfg->live.kinds, CR_MAX_KINDS, &cfg->live.kind_count);
} else {
cfg->live.kind_count = 0; /* use cfg->kinds as fallback */
}
free(live_kinds);
}
cfg->live.since_seconds = get_long("caching_live_since_seconds", 0);
cfg->live.limit = get_int("caching_live_limit", 0);
/* backfill */
cfg->backfill.enabled = get_bool("caching_backfill_enabled", 1);
+275
View File
@@ -1186,3 +1186,278 @@ int pg_inbox_update_last_event_at(const char *pk, long event_created_at) {
PQclear(res);
return 0;
}
/* ------------------------------------------------------------------ */
/* Unified caching_relays table operations */
/* ------------------------------------------------------------------ */
/* Ensure a relay exists in caching_relays. If new, sets live_enabled and
* backfill_enabled according to the given flags. If existing, leaves flags
* untouched (user preference is preserved). Returns 0 on success, -1 on error. */
int pg_inbox_ensure_relay(const char *relay_url, int live_enabled,
int backfill_enabled, int is_bootstrap) {
if (!g_pg || !relay_url) return -1;
const char *params[4];
params[0] = relay_url;
char live_buf[2], backfill_buf[2], bootstrap_buf[2];
snprintf(live_buf, sizeof(live_buf), "%d", live_enabled ? 1 : 0);
snprintf(backfill_buf, sizeof(backfill_buf), "%d", backfill_enabled ? 1 : 0);
snprintf(bootstrap_buf, sizeof(bootstrap_buf), "%d", is_bootstrap ? 1 : 0);
params[1] = live_buf;
params[2] = backfill_buf;
params[3] = bootstrap_buf;
PGresult *res = PQexecParams(g_pg,
"INSERT INTO caching_relays (relay_url, live_enabled, backfill_enabled, is_bootstrap, created_at, updated_at) "
"VALUES ($1, $2::int::boolean, $3::int::boolean, $4::int::boolean, EXTRACT(EPOCH FROM NOW())::BIGINT, EXTRACT(EPOCH FROM NOW())::BIGINT) "
"ON CONFLICT (relay_url) DO UPDATE SET "
" updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT",
4, NULL, params, NULL, NULL, 0);
if (!res) {
DEBUG_ERROR("pg_inbox: ensure_relay returned NULL result for %s", relay_url);
return -1;
}
ExecStatusType st = PQresultStatus(res);
if (st != PGRES_COMMAND_OK) {
DEBUG_ERROR("pg_inbox: ensure_relay failed for %s: %s",
relay_url, PQresultErrorMessage(res));
PQclear(res);
return -1;
}
PQclear(res);
return 0;
}
/* Toggle a boolean column (live_enabled or backfill_enabled) for a relay.
* column must be "live_enabled" or "backfill_enabled".
* Bumps caching_config_generation so the caching service hot-reloads.
* Returns 0 on success, -1 on error. */
int pg_inbox_toggle_relay(const char *relay_url, const char *column) {
if (!g_pg || !relay_url || !column) return -1;
/* Validate column name to prevent SQL injection. */
if (strcmp(column, "live_enabled") != 0 && strcmp(column, "backfill_enabled") != 0) {
DEBUG_ERROR("pg_inbox: toggle_relay invalid column '%s'", column);
return -1;
}
char sql[512];
snprintf(sql, sizeof(sql),
"UPDATE caching_relays SET %s = NOT %s, updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT "
"WHERE relay_url = $1", column, column);
const char *params[1] = { relay_url };
PGresult *res = PQexecParams(g_pg, sql, 1, NULL, params, NULL, NULL, 0);
if (!res) {
DEBUG_ERROR("pg_inbox: toggle_relay returned NULL result for %s", relay_url);
return -1;
}
ExecStatusType st = PQresultStatus(res);
if (st != PGRES_COMMAND_OK) {
DEBUG_ERROR("pg_inbox: toggle_relay failed for %s: %s",
relay_url, PQresultErrorMessage(res));
PQclear(res);
return -1;
}
PQclear(res);
/* Bump config generation to trigger hot-reload. */
PGresult *gen = PQexec(g_pg,
"UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text, "
" updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT "
"WHERE key = 'caching_config_generation'");
if (gen) PQclear(gen);
return 0;
}
/* Get enabled relay URLs (where live_enabled OR backfill_enabled is true).
* Fills out_relays with pointers into internal storage (do not free).
* Returns the count, or 0 if none/error. */
int pg_inbox_get_enabled_relays(const char **out_relays, int max_relays) {
if (!g_pg || !out_relays || max_relays <= 0) return 0;
PGresult *res = PQexec(g_pg,
"SELECT relay_url FROM caching_relays "
"WHERE live_enabled = TRUE OR backfill_enabled = TRUE "
"ORDER BY relay_url");
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
if (res) PQclear(res);
return 0;
}
int n = PQntuples(res);
if (n > max_relays) n = max_relays;
for (int i = 0; i < n; i++) {
out_relays[i] = strdup(PQgetvalue(res, i, 0));
}
PQclear(res);
return n;
}
/* Free the strings returned by pg_inbox_get_enabled_relays. */
void pg_inbox_free_enabled_relays(const char **relays, int count) {
for (int i = 0; i < count; i++) {
free((char *)relays[i]);
}
}
/* Check if a relay is backfill-enabled in caching_relays.
* Returns 1 if enabled, 0 if disabled or not found, -1 on error. */
int pg_inbox_is_relay_backfill_enabled(const char *relay_url) {
if (!g_pg || !relay_url) return -1;
const char *params[1] = { relay_url };
PGresult *res = PQexecParams(g_pg,
"SELECT backfill_enabled FROM caching_relays WHERE relay_url = $1",
1, NULL, params, NULL, NULL, 0);
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
if (res) PQclear(res);
return 0;
}
int n = PQntuples(res);
int enabled = 0;
if (n > 0) {
enabled = (strcmp(PQgetvalue(res, 0, 0), "t") == 0) ? 1 : 0;
}
PQclear(res);
return enabled;
}
/* Rewrite of update_upstream_relays: now updates caching_relays table instead
* of the old caching_upstream_relays table. Also inserts any relays not yet
* in the table (with live_enabled=false, backfill_enabled=false). */
int pg_inbox_update_upstream_relays(const char **relay_urls,
const int *statuses,
const char **status_texts,
int count) {
if (!g_pg) {
DEBUG_ERROR("pg_inbox: not initialized");
return -1;
}
if (count <= 0) {
/* An empty pool means every persisted relay is currently disconnected.
* Clear the last connected status so the admin UI does not display a
* stale "connected" state after a relay is disabled or removed. */
PGresult *empty_res = PQexec(g_pg,
"UPDATE caching_relays SET status_code = 0, status_text = 'disconnected', "
"updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
if (!empty_res || PQresultStatus(empty_res) != PGRES_COMMAND_OK) {
if (empty_res) PQclear(empty_res);
return -1;
}
PQclear(empty_res);
return 0;
}
/* Build a batch INSERT from the arrays. */
size_t total = count * 200 + 512;
char *sql = malloc(total);
if (!sql) return -1;
char *p = sql;
int written = snprintf(p, total,
"INSERT INTO caching_relays (relay_url, status_code, status_text, updated_at) VALUES ");
if (written < 0) { free(sql); return -1; }
p += written;
for (int i = 0; i < count; i++) {
char *esc_url = PQescapeLiteral(g_pg, relay_urls[i], (int)strlen(relay_urls[i]));
char *esc_text = PQescapeLiteral(g_pg, status_texts[i], (int)strlen(status_texts[i]));
if (!esc_url || !esc_text) {
if (esc_url) PQfreemem(esc_url);
if (esc_text) PQfreemem(esc_text);
free(sql);
return -1;
}
size_t remaining = total - (size_t)(p - sql);
written = snprintf(p, remaining,
"%s(%s, %d, %s, EXTRACT(EPOCH FROM NOW())::BIGINT)",
(i > 0) ? ", " : "",
esc_url, statuses[i], esc_text);
PQfreemem(esc_url);
PQfreemem(esc_text);
if (written < 0 || (size_t)written >= remaining) { free(sql); return -1; }
p += written;
}
/* ON CONFLICT: update status and updated_at, but preserve existing
* live_enabled/backfill_enabled flags (user preference). */
size_t remaining = total - (size_t)(p - sql);
written = snprintf(p, remaining,
" ON CONFLICT (relay_url) DO UPDATE SET "
" status_code = EXCLUDED.status_code, "
" status_text = EXCLUDED.status_text, "
" updated_at = EXCLUDED.updated_at");
if (written < 0 || (size_t)written >= remaining) { free(sql); return -1; }
PGresult *res = PQexec(g_pg, sql);
free(sql);
if (!res) {
DEBUG_ERROR("pg_inbox: update_upstream_relays returned NULL result");
return -1;
}
ExecStatusType st = PQresultStatus(res);
if (st != PGRES_COMMAND_OK) {
DEBUG_ERROR("pg_inbox: update_upstream_relays failed: %s",
PQresultErrorMessage(res));
PQclear(res);
return -1;
}
PQclear(res);
/* Relays removed from the runtime pool are no longer reported by
* nostr_relay_pool_list_relays(). Mark them disconnected instead of
* leaving their previous CONNECTED status in PostgreSQL indefinitely. */
size_t status_sql_size = (size_t)count * 160 + 256;
char *status_sql = malloc(status_sql_size);
if (!status_sql) return -1;
char *status_p = status_sql;
int status_written = snprintf(status_p, status_sql_size,
"UPDATE caching_relays SET status_code = 0, status_text = 'disconnected', "
"updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT WHERE relay_url NOT IN (");
if (status_written < 0 || (size_t)status_written >= status_sql_size) {
free(status_sql);
return -1;
}
status_p += status_written;
for (int i = 0; i < count; i++) {
char *escaped = PQescapeLiteral(g_pg, relay_urls[i], (int)strlen(relay_urls[i]));
if (!escaped) {
free(status_sql);
return -1;
}
size_t remaining_status = status_sql_size - (size_t)(status_p - status_sql);
status_written = snprintf(status_p, remaining_status, "%s%s",
(i > 0) ? ", " : "", escaped);
PQfreemem(escaped);
if (status_written < 0 || (size_t)status_written >= remaining_status) {
free(status_sql);
return -1;
}
status_p += status_written;
}
size_t remaining_status = status_sql_size - (size_t)(status_p - status_sql);
status_written = snprintf(status_p, remaining_status, ")");
if (status_written < 0 || (size_t)status_written >= remaining_status) {
free(status_sql);
return -1;
}
PGresult *status_res = PQexec(g_pg, status_sql);
free(status_sql);
if (!status_res || PQresultStatus(status_res) != PGRES_COMMAND_OK) {
if (status_res) {
DEBUG_WARN("pg_inbox: failed to clear stale relay statuses: %s",
PQresultErrorMessage(status_res));
PQclear(status_res);
}
return -1;
}
PQclear(status_res);
return 0;
}
+41
View File
@@ -162,4 +162,45 @@ cJSON* pg_inbox_get_authors_for_catchup(void);
* the given value. Returns 0 on success, -1 on error. */
int pg_inbox_update_last_event_at(const char *pk, long event_created_at);
/* ------------------------------------------------------------------ */
/* Upstream relay status tracking (caching_relays table) */
/* ------------------------------------------------------------------ */
/* Update the upstream relay status table with current connection states.
* Takes parallel arrays of relay_urls, status codes, and status text strings.
* count is the number of entries. Uses a batch upsert.
* Returns 0 on success, -1 on error. */
int pg_inbox_update_upstream_relays(const char **relay_urls,
const int *statuses,
const char **status_texts,
int count);
/* ------------------------------------------------------------------ */
/* Unified caching_relays table operations */
/* ------------------------------------------------------------------ */
/* Ensure a relay exists in caching_relays. If new, sets the given flags.
* If existing, leaves flags untouched (user preference preserved).
* Returns 0 on success, -1 on error. */
int pg_inbox_ensure_relay(const char *relay_url, int live_enabled,
int backfill_enabled, int is_bootstrap);
/* Toggle live_enabled or backfill_enabled for a relay.
* Bumps caching_config_generation to trigger hot-reload.
* column must be "live_enabled" or "backfill_enabled".
* Returns 0 on success, -1 on error. */
int pg_inbox_toggle_relay(const char *relay_url, const char *column);
/* Get enabled relay URLs (where live_enabled OR backfill_enabled is true).
* Allocates strings via strdup; caller must free with
* pg_inbox_free_enabled_relays(). Returns the count, or 0 if none/error. */
int pg_inbox_get_enabled_relays(const char **out_relays, int max_relays);
/* Free the strings returned by pg_inbox_get_enabled_relays. */
void pg_inbox_free_enabled_relays(const char **relays, int count);
/* Check if a relay is backfill-enabled in caching_relays.
* Returns 1 if enabled, 0 if disabled or not found, -1 on error. */
int pg_inbox_is_relay_backfill_enabled(const char *relay_url);
#endif /* CACHING_RELAY_PG_INBOX_H */
+4 -1
View File
@@ -24,8 +24,11 @@ int cr_pubkey_set_add(cr_pubkey_set_t *s, const char *hex); /* 1 if added, 0 if
int cr_pubkey_set_contains(const cr_pubkey_set_t *s, const char *hex);
void cr_pubkey_set_clear(cr_pubkey_set_t *s);
/* Fixed-size ring buffer of event ids for dedup. */
/* Fixed-size ring buffer of event ids for dedup.
* CR_SEEN_RING_SIZE: shared ring for backfill + general dedup (4096 entries).
* CR_LIVE_RING_SIZE: fast debounce ring for live subscriber only (100 entries). */
#define CR_SEEN_RING_SIZE 4096
#define CR_LIVE_RING_SIZE 100
typedef struct {
char ids[CR_SEEN_RING_SIZE][CR_HEX_LEN];
+11 -2
View File
@@ -181,8 +181,16 @@ echo "[remote] Health checks"
sudo systemctl --no-pager --full status "$SERVICE_NAME" | sed -n '1,25p'
sudo -u "$RELAY_USER" psql -d crelay -c "SELECT current_user, current_database();"
# Verify public relay page is accessible (HTTP 200, no auth).
PUBLIC_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1/relay/" 2>/dev/null || echo "000")
if [ "$PUBLIC_CODE" = "200" ]; then
echo " -> public relay page accessible (HTTP $PUBLIC_CODE)"
else
echo " -> WARNING: public relay page returned HTTP $PUBLIC_CODE (expected 200)"
fi
# Verify admin page is accessible (HTTP 200 or 401 = auth required, both are OK).
ADMIN_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1/admin/" 2>/dev/null || echo "000")
ADMIN_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1/relay/admin/" 2>/dev/null || echo "000")
if [ "$ADMIN_CODE" = "200" ] || [ "$ADMIN_CODE" = "401" ]; then
echo " -> admin page accessible (HTTP $ADMIN_CODE)"
else
@@ -199,5 +207,6 @@ rm -f /tmp/admin_deploy.tar.gz
echo "Deployment complete: $REMOTE_HOST"
echo ""
echo "Admin interface: https://laantungir.net/admin/"
echo "Public relay page: https://laantungir.net/relay/"
echo "Admin interface: https://laantungir.net/relay/admin/ (Basic Auth)"
echo "Caching is NOT auto-started. Enable it via the admin UI when ready."
+57
View File
@@ -361,6 +361,63 @@ server {
}
```
#### PHP Admin UI: Public Stats vs Authenticated Admin
The relay ships with a PHP admin UI (in [`admin/`](../admin/)) that talks
directly to PostgreSQL. It is served by nginx + php-fpm and is split into two
URL paths with different access controls:
| URL | Auth | Purpose |
|----------------------------------|---------------|----------------------------------------------------|
| `https://<domain>/relay/` | None (public) | Public landing page: relay info + event-rate chart |
| `https://<domain>/relay/api/chart.php?range=day` | None (public) | Read-only ASCII chart endpoint (aggregate COUNT) |
| `https://<domain>/relay/admin/` | HTTP Basic Auth | Full admin UI: config, auth rules, IP bans, DMs, SQL query |
**Why Basic Auth is required for `/relay/admin/`:** the PHP endpoints have no
server-side authentication of their own. The `nostr_login_lite` modal in the
admin UI is a **client-side only** gate (it only toggles UI visibility in the
browser); it does not send a signed event or session token to the backend.
Without Basic Auth, anyone could hit `api/query.php` to run SELECT queries
against your database, `api/config.php` to change relay config, `api/auth.php`
to edit blacklist/whitelist rules, or `api/dm.php` to read DMs.
Only [`admin/api/chart.php`](../admin/api/chart.php) is safe to expose
publicly — it runs a fixed aggregate `COUNT(*)` query with the only user input
being the `range` selector, which is validated against a whitelist
(`hour`/`day`/`month`/`year`).
A complete, ready-to-use nginx config including this split is in
[`examples/deployment/nginx-proxy/nginx.conf`](../examples/deployment/nginx-proxy/nginx.conf).
The relevant blocks:
```nginx
# Public landing page + chart (no auth)
location /relay/ {
alias /opt/c-relay-pg/admin/public/;
index index.php;
location ~ \.php$ { fastcgi_pass unix:/run/php/php8.2-fpm.sock; ... }
}
location ^~ /relay/api/chart.php { alias /opt/c-relay-pg/admin/api/chart.php; ... }
location ^~ /relay/assets/ { alias /opt/c-relay-pg/admin/assets/; }
# Authenticated admin UI (Basic Auth)
location /relay/admin/ {
alias /opt/c-relay-pg/admin/;
auth_basic "Relay Admin";
auth_basic_user_file /opt/c-relay-pg/admin/.htpasswd;
location ~ \.php$ { fastcgi_pass unix:/run/php/php8.2-fpm.sock; ... }
location ^~ /relay/admin/lib/ { deny all; } # DB credentials
location ^~ /relay/admin/public/ { deny all; } # served via /relay/
}
```
Create the Basic Auth credentials on the server:
```bash
sudo htpasswd -c /opt/c-relay-pg/admin/.htpasswd <username>
sudo systemctl reload nginx
```
### Apache Configuration
#### WebSocket Proxy with mod_proxy_wstunnel
+2 -2
View File
@@ -185,7 +185,7 @@ perform_backup() {
print_step "Database size: $db_size"
# Create SQLite backup using .backup command (hot backup)
if sqlite3 "$DB_FILE" ".backup $backup_file" 2>/dev/null; then
if pg_dump -d crelay > "$backup_file"
print_success "Database backup created: $backup_file"
else
# Fallback to file copy if .backup fails
@@ -203,7 +203,7 @@ perform_backup() {
# Check backup integrity
if [[ "$VERIFY" == "true" ]]; then
print_step "Verifying backup integrity..."
if sqlite3 "$backup_file" "PRAGMA integrity_check;" | grep -q "ok"; then
if pg_restore -l "$backup_file" &>/dev/null
print_success "Backup integrity verified"
else
print_error "Backup integrity check failed"
@@ -247,7 +247,7 @@ check_database_integrity() {
local integrity_ok=true
for db_file in "${db_files[@]}"; do
if [[ -r "$db_file" ]]; then
if timeout 30 sqlite3 "$db_file" "PRAGMA integrity_check;" | grep -q "ok"; then
if timeout 30 psql -d crelay "PRAGMA integrity_check;" | grep -q "ok"; then
print_success "Database integrity OK: $(basename "$db_file")"
else
print_error "Database integrity failed: $(basename "$db_file")"
@@ -292,7 +292,7 @@ check_configuration_events() {
local config_count=0
for db_file in "${db_files[@]}"; do
if [[ -r "$db_file" ]]; then
local count=$(sqlite3 "$db_file" "SELECT COUNT(*) FROM events WHERE kind = 33334;" 2>/dev/null || echo "0")
local count=$(psql -d crelay "SELECT COUNT(*) FROM events WHERE kind = 33334;" 2>/dev/null || echo "0")
config_count=$((config_count + count))
fi
done
+64 -4
View File
@@ -155,10 +155,64 @@ http {
log_not_found off;
}
# PHP admin page for caching service (direct PostgreSQL access).
# Bypasses the NIP-44 64KB encryption limit of the Nostr admin API.
# ---------------------------------------------------------------
# Public relay landing page + public chart endpoint.
#
# https://<domain>/relay/ -> public index.php
# https://<domain>/relay/api/chart.php?range=... -> public ASCII chart
# https://<domain>/relay/assets/... -> public CSS/JS
#
# No authentication. Exposes ONLY read-only, non-sensitive data:
# relay name/description/pubkey and the aggregate event-rate chart.
# All sensitive admin endpoints live under /relay/admin/ (see below).
# Requires: php-fpm + php-pgsql installed and configured.
location /admin/ {
# ---------------------------------------------------------------
location /relay/ {
alias /opt/c-relay-pg/admin/public/;
index index.php;
# Pass .php files to PHP-FPM
location ~ \.php$ {
# Adjust socket path for your distro:
# Debian/Ubuntu: unix:/run/php/php8.2-fpm.sock
# RHEL/Fedora: unix:/run/php-fpm/www.sock
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
}
}
# Public chart endpoint — the public index.php fetches this relatively
# as "api/chart.php". Map it to the admin's chart.php (read-only,
# aggregate COUNT query; safe to expose). Only chart.php is served
# here; all other admin/api/*.php endpoints stay behind /relay/admin/.
location ^~ /relay/api/chart.php {
alias /opt/c-relay-pg/admin/api/chart.php;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index chart.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
}
# Public static assets (CSS/JS) for the public landing page.
# The public index.php references these relatively as "assets/...".
location ^~ /relay/assets/ {
alias /opt/c-relay-pg/admin/assets/;
}
# ---------------------------------------------------------------
# Authenticated admin UI (full PHP admin).
#
# https://<domain>/relay/admin/ -> admin index.php
# https://<domain>/relay/admin/api/*.php -> admin API endpoints
#
# Protected by HTTP Basic Auth. The PHP endpoints have NO server-side
# auth of their own (the nostr_login_lite modal is client-side only),
# so this Basic Auth gate is what actually secures config edits, auth
# rules, IP bans, DMs, and raw SQL queries.
# ---------------------------------------------------------------
location /relay/admin/ {
alias /opt/c-relay-pg/admin/;
index index.php;
@@ -178,7 +232,13 @@ http {
}
# Deny access to the lib/ directory (contains DB credentials)
location ^~ /admin/lib/ {
location ^~ /relay/admin/lib/ {
deny all;
}
# Deny access to the public/ directory from the admin path
# (it is served publicly via /relay/ instead).
location ^~ /relay/admin/public/ {
deny all;
}
}
+2 -2
View File
@@ -60,12 +60,12 @@ install_dependencies() {
if [[ $OS == "debian" ]]; then
apt update
apt install -y build-essential git sqlite3 libsqlite3-dev \
apt install -y build-essential git postgresql postgresql-client
libwebsockets-dev libssl-dev libsecp256k1-dev \
libcurl4-openssl-dev zlib1g-dev systemd curl wget
elif [[ $OS == "redhat" ]]; then
yum groupinstall -y "Development Tools"
yum install -y git sqlite-devel libwebsockets-devel \
yum install -y git postgresql-devel postgresql-server-devel
openssl-devel libsecp256k1-devel libcurl-devel \
zlib-devel systemd curl wget
fi
+69 -55
View File
@@ -8,14 +8,13 @@ echo "=== C Nostr Relay Build and Restart Script ==="
# Parse command line arguments
PRESERVE_DATABASE=false
HELP=false
USE_TEST_KEYS=false
USE_TEST_KEYS=true
ADMIN_KEY=""
RELAY_KEY=""
PORT_OVERRIDE=""
DEBUG_LEVEL="5"
START_CACHING=false
RESET_BACKFILL=false
DB_BACKEND="postgres"
DB_CONNSTRING=""
DB_HOST=""
DB_PORT=""
@@ -87,24 +86,10 @@ while [[ $# -gt 0 ]]; do
;;
--test-keys|-t)
USE_TEST_KEYS=true
# Read keys from .test_keys file
if [ -f ".test_keys" ]; then
echo "Reading test keys from .test_keys file..."
# Source the file to get the variables
source .test_keys
# Remove any single quotes from the values
# Note: -a flag expects ADMIN_PUBKEY (public key), not ADMIN_PRIVKEY
ADMIN_KEY=$(echo "$ADMIN_PUBKEY" | tr -d "'")
RELAY_KEY=$(echo "$SERVER_PRIVKEY" | tr -d "'")
echo "Using admin pubkey from .test_keys: ${ADMIN_KEY:0:16}..."
echo "Using relay privkey from .test_keys: ${RELAY_KEY:0:16}..."
else
echo "ERROR: .test_keys file not found"
echo "Please create a .test_keys file with the following format:"
echo " ADMIN_PUBKEY='your_admin_public_key_hex'"
echo " SERVER_PRIVKEY='your_relay_private_key_hex'"
exit 1
fi
shift
;;
--no-test-keys|--production)
USE_TEST_KEYS=false
shift
;;
--debug-level=*)
@@ -135,20 +120,6 @@ while [[ $# -gt 0 ]]; do
shift 2
fi
;;
--db-backend)
if [ -z "$2" ]; then
echo "ERROR: --db-backend requires a value (sqlite|postgres)"
HELP=true
shift
else
DB_BACKEND="$2"
shift 2
fi
;;
--db-backend=*)
DB_BACKEND="${1#*=}"
shift
;;
--db-connstring)
if [ -z "$2" ]; then
echo "ERROR: --db-connstring requires a value"
@@ -238,6 +209,24 @@ if [ -n "$RELAY_KEY" ]; then
fi
fi
# Load test keys from .test_keys file if USE_TEST_KEYS is true and no explicit keys given
if [ "$USE_TEST_KEYS" = true ] && [ -z "$ADMIN_KEY" ] && [ -z "$RELAY_KEY" ]; then
if [ -f ".test_keys" ]; then
echo "Reading test keys from .test_keys file..."
source .test_keys
ADMIN_KEY=$(echo "$ADMIN_PUBKEY" | tr -d "'")
RELAY_KEY=$(echo "$SERVER_PRIVKEY" | tr -d "'")
echo "Using admin pubkey from .test_keys: ${ADMIN_KEY:0:16}..."
echo "Using relay privkey from .test_keys: ${RELAY_KEY:0:16}..."
else
echo "ERROR: .test_keys file not found"
echo "Please create a .test_keys file with the following format:"
echo " ADMIN_PUBKEY='your_admin_public_key_hex'"
echo " SERVER_PRIVKEY='your_relay_private_key_hex'"
exit 1
fi
fi
# Validate port if provided
if [ -n "$PORT_OVERRIDE" ]; then
if ! [[ "$PORT_OVERRIDE" =~ ^[0-9]+$ ]] || [ "$PORT_OVERRIDE" -lt 1 ] || [ "$PORT_OVERRIDE" -gt 65535 ]; then
@@ -259,12 +248,6 @@ if [ -n "$DEBUG_LEVEL" ]; then
fi
fi
# Validate DB backend
if [ "$DB_BACKEND" != "sqlite" ] && [ "$DB_BACKEND" != "postgres" ]; then
echo "ERROR: Invalid --db-backend value '$DB_BACKEND'. Use sqlite or postgres."
exit 1
fi
# Validate DB port if provided
if [ -n "$DB_PORT" ]; then
if ! [[ "$DB_PORT" =~ ^[0-9]+$ ]] || [ "$DB_PORT" -lt 1 ] || [ "$DB_PORT" -gt 65535 ]; then
@@ -273,7 +256,7 @@ if [ -n "$DB_PORT" ]; then
fi
fi
if [ "$DB_BACKEND" = "postgres" ] && [ -z "$DB_CONNSTRING" ]; then
if [ -z "$DB_CONNSTRING" ]; then
[ -z "$DB_HOST" ] && DB_HOST="localhost"
[ -z "$DB_PORT" ] && DB_PORT="5432"
[ -z "$DB_NAME" ] && DB_NAME="crelay"
@@ -351,6 +334,30 @@ ensure_postgres_database() {
return 0
}
# Clear all PostgreSQL application tables for a fresh development start.
# This is intentionally destructive and is only called when the database is
# not being preserved. Dropping/recreating the database above is preferred,
# but this schema reset also covers environments where the configured user
# cannot drop databases (for example managed PostgreSQL installations).
reset_postgres_schema() {
if [ -z "$DB_CONNSTRING" ]; then
psql_args=(-h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME")
else
psql_args=(-d "$DB_CONNSTRING")
fi
echo "Resetting PostgreSQL public schema for a fresh start..."
if ! psql "${psql_args[@]}" -v ON_ERROR_STOP=1 <<'SQL'
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
SQL
then
echo "ERROR: Failed to clear PostgreSQL tables"
return 1
fi
echo "✓ PostgreSQL tables cleared"
}
# Show help
if [ "$HELP" = true ]; then
echo "Usage: $0 [OPTIONS]"
@@ -360,9 +367,9 @@ if [ "$HELP" = true ]; then
echo " -r, --relay-key <hex> 64-character hex relay private key"
echo " -p, --port <port> Custom port override (default: 8888)"
echo " -d, --debug-level <0-5> Set debug level: 0=none, 1=errors, 2=warnings, 3=info, 4=debug, 5=trace"
echo " --preserve-database Keep existing database files (don't delete for fresh start)"
echo " --test-keys, -t Use deterministic test keys for development (admin: all 'a's, relay: all '1's)"
echo " --db-backend <name> Database backend: postgres (default) or sqlite"
echo " --preserve-database Keep existing PostgreSQL tables and relay state"
echo " --test-keys, -t Use deterministic test keys for development (admin: all 'a's, relay: all '1's) [default]"
echo " --no-test-keys, --production Generate random keys (production mode)"
echo " --db-connstring <str> PostgreSQL libpq connection string"
echo " --db-host <host> PostgreSQL host"
echo " --db-port <port> PostgreSQL port"
@@ -370,7 +377,7 @@ if [ "$HELP" = true ]; then
echo " --db-user <user> PostgreSQL database user"
echo " --db-password <pass> PostgreSQL database password"
echo " --start-caching Auto-start the caching service on startup"
echo " --reset-backfill Clear caching backfill progress (use with --start-caching)"
echo " --reset-backfill Clear caching backfill progress (independent of --start-caching)"
echo " --help, -h Show this help message"
echo ""
echo "Event-Based Configuration:"
@@ -379,7 +386,7 @@ if [ "$HELP" = true ]; then
echo " Database file: <relay_pubkey>.db (created automatically)"
echo ""
echo "Examples:"
echo " $0 # Fresh start with random keys"
echo " $0 # Fresh start with test keys (default)"
echo " $0 -a <admin-hex> -r <relay-hex> # Use custom keys"
echo " $0 -a <admin-hex> -p 9000 # Custom admin key on port 9000"
echo " $0 -p 7777 --strict-port # Fail if port 7777 unavailable (no fallback)"
@@ -387,15 +394,15 @@ if [ "$HELP" = true ]; then
echo " $0 --debug-level=3 # Start with debug level 3 (info)"
echo " $0 -d=5 # Start with debug level 5 (trace)"
echo " $0 --preserve-database # Preserve existing database and keys"
echo " $0 --test-keys # Use test keys for consistent development"
echo " $0 -t --preserve-database # Use test keys and preserve database"
echo " $0 --no-test-keys # Generate random keys (production)"
echo " $0 --no-test-keys --preserve-database # Production mode with database preservation"
echo ""
echo "Default PostgreSQL connection (when no DB flags provided):"
echo " host=localhost port=5432 dbname=crelay user=crelay password=crelay"
echo ""
echo "Key Format: Keys must be exactly 64 hexadecimal characters (0-9, a-f, A-F)"
echo "Default behavior: Deletes existing database files to start fresh with new keys"
echo " for development purposes"
echo "Default behavior: Resets PostgreSQL tables and starts fresh for development"
echo " --preserve-database keeps all existing tables and state"
exit 0
fi
@@ -416,17 +423,24 @@ fi
rm -rf dev-config/ 2>/dev/null
rm -f db/c_nostr_relay.db* 2>/dev/null
if [ "$DB_BACKEND" = "postgres" ] && [ -z "$DB_CONNSTRING" ]; then
if [ -z "$DB_CONNSTRING" ]; then
ensure_postgres_database || exit 1
fi
# A non-preserved development restart must not retain old config, relay
# selections, progress, or service state. The relay recreates its schema and
# default configuration during startup.
if [ "$PRESERVE_DATABASE" = false ]; then
reset_postgres_schema || exit 1
fi
# Embed web files into C headers before building
echo "Embedding web files..."
./embed_web_files.sh
# Build the project - ONLY static build
echo "Building project (static binary, backend: $DB_BACKEND)..."
./build_static.sh --db-backend "$DB_BACKEND"
# Build the project - ONLY static build (PostgreSQL backend)
echo "Building project (static binary, backend: postgres)..."
./build_static.sh
# Exit if static build fails - no fallback
if [ $? -ne 0 ]; then
@@ -620,7 +634,7 @@ fi
if [ "$RESET_BACKFILL" = true ]; then
RELAY_ARGS="$RELAY_ARGS --reset-backfill"
echo "Resetting caching backfill progress before start"
echo "Resetting caching backfill progress"
fi
# Change to build directory before starting relay so database files are created there
+441
View File
@@ -0,0 +1,441 @@
# Cache-All Feasibility Test Program
## Goal
Build a standalone C test program that connects to all outbox relays of followed
pubkeys and attempts to subscribe to **all events** (no author filter) to
determine if relays will tolerate this level of data flow. The program logs all
relay interactions (disconnections, rate limiting, CLOSED messages, NOTICE
messages) so we can assess feasibility before modifying the caching daemon.
## Background
The current caching daemon subscribes only to events from followed pubkeys
using `authors=[followed_set]` filters. The proposed "cache everything" approach
would subscribe to all events on those relays and prune old non-followed events
later. The key unknown is whether relays will allow this — they may disconnect
or rate-limit clients that request too much data.
## Preliminary Probe Results (60s on 3 major relays)
Before designing the test program, we ran 60-second probes using `nak req --stream`
with no filter (all events) against 3 major relays to measure real-world data volume
and kind distribution.
### Relay Connectivity
| Relay | Events/60s | Avg Bytes | Max Event | Unique Pubkeys | Notes |
|-------|-----------|-----------|-----------|----------------|-------|
| nos.lol | 2,634 | 1,713 | 44,372 (kind 30089) | 1,228 | Connected OK |
| relay.primal.net | 1,547 | 1,920 | 207,377 (kind 3) | 613 | Connected OK |
| relay.damus.io | 0 | — | — | — | HTTP 503 (unavailable) |
### Kind Distribution (nos.lol + primal combined, ~4,181 events)
| Kind | Description | Count | % | Avg Bytes | Notes |
|------|-------------|-------|---|-----------|-------|
| **21059** | Gift wrap (NIP-59) | 1,063 | 25.4% | 2,574 | Encrypted DMs — large, noisy |
| **5** | Deletion requests | 592 | 14.2% | 447 | Very common |
| **30078** | App-specific data | 173 | 4.1% | 3,670 | Variable size, can be large |
| **20001** | Ephemeral (key exchange?) | 253 | 6.0% | 849 | Short-lived |
| **22580** | WebRTC signaling | 193 | 4.6% | 795 | Noise |
| **1059** | Gift wrap seal (NIP-59) | 154 | 3.7% | 3,023 | Encrypted wrapper |
| **22734** | WebRTC signaling | 168 | 4.0% | 427 | Noise |
| **1** | Text notes | 58 | 1.4% | 812 | **Primary content** |
| **7** | Reactions | 59 | 1.4% | 534 | Likes |
| **0** | Profile metadata | 33 | 0.8% | 985 | Replaceable |
| **3** | Follow lists | 10 | 0.2% | 43,077 | Replaceable, can be huge |
| **10002** | Relay lists | 55 | 1.3% | 418 | Small |
| **9735** | Zap receipts | 22 | 0.5% | 2,110 | Payment confirmations |
| **6** | Reposts | 11 | 0.3% | 1,876 | |
| **30023** | Long-form articles | 3 | 0.1% | 6,119 | |
| **30089** | Chunked data | 16 | 0.4% | 43,907 | **Very large** (avg 44KB!) |
| **30001** | Lists (encrypted) | 4 | 0.1% | 30,538 | Large encrypted content |
| **30815** | Ephemeral large | 4 | 0.1% | 37,534 | **Very large** (avg 37KB!) |
| **25555** | App data | 31 | 0.7% | 5,115 | |
| **13194** | NWC info | 15 | 0.4% | 514 | Wallet connect |
| **445** | Encrypted group msg | 22 | 0.5% | 1,452 | |
| **Other** | 90+ other kinds | ~600 | ~14% | varies | Long tail of app-specific kinds |
### Key Findings
1. **Gift wrap (kind 21059) dominates**: 25% of all events are NIP-59 gift wraps
(encrypted DMs). These are large (~2.5KB avg) and mostly noise for a caching relay.
They have `expiration` tags and are ephemeral by nature.
2. **Deletion requests (kind 5) are extremely common**: 14% of all events. This is
surprising — relays are very busy processing deletes.
3. **WebRTC signaling (kinds 22580, 22734, etc.) is ~10% of traffic**: These are
ephemeral signaling events for video/voice calls. Pure noise for caching.
4. **"Social" content (kinds 1, 7, 6, 0, 3) is only ~4% of total events**: The
vast majority of relay traffic is NOT the content users actually see.
5. **Some kinds are extremely large**: Kind 30089 averages 44KB, kind 30001 averages
30KB, kind 30815 averages 37KB. These would consume significant storage.
6. **Data volume is manageable**: ~2,600-4,200 events/min across 2 relays. At this
rate, a 24h test would collect ~3.7-6M events. Storage would be ~6-10 GB of raw
JSON.
7. **Damus.io is unavailable**: Returns HTTP 503. This may be temporary or may
indicate they block unfiltered subscriptions.
### Implications for the Test Program
- **Subscribe to ALL kinds** for the test — we need to measure which kinds relays
actually send and whether they tolerate the volume.
- **Do NOT save full event JSON** during the test — at ~2MB/min, a 24h run would
produce ~3GB of raw data per relay. Instead, log kind/pubkey/size summaries.
- **Track kind 21059 (gift wrap) separately** — it's the dominant kind and may
need special handling in the real implementation.
- **Monitor for CLOSED messages** — if relays start closing subscriptions due to
volume, we'll see it in the raw relay log.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ cache_all_feasibility_test │
│ │
│ 1. Load root npubs from config.jsonc │
│ 2. Resolve follow graph (kind-3) → followed set │
│ 3. Discover outbox relays (kind-10002) for each follow │
│ 4. Compute minimum covering set of relays │
│ 5. Connect to all covering relays │
│ 6. Subscribe to ALL events (no authors filter) on each │
│ 7. Log every event received (counts + stats, not full JSON)│
│ 8. Log every relay message: CLOSED, NOTICE, EOSE, error │
│ 9. Run for configurable duration (default 24h) │
│ 10. Save summary report + per-relay stats to files │
└─────────────────────────────────────────────────────────────┘
```
## File Structure
All new files go in `caching/` directory, alongside the existing daemon:
```
caching/
├── Makefile # Modified: add test target
├── src/
│ ├── main.c # Existing daemon (unchanged)
│ ├── cache_all_test.c # NEW: test program entry point
│ ├── cache_all_test.h # NEW: test program header
│ ├── debug.c / debug.h # Existing (reused)
│ ├── config.c / config.h # Existing (reused for config loading)
│ ├── state.c / state.h # Existing (reused for pubkey set)
│ ├── follow_graph.c / follow_graph.h # Existing (reused)
│ ├── relay_discovery.c / relay_discovery.h # Existing (reused)
│ └── ... # Other existing files unchanged
```
## Detailed Design
### 1. Config Loading (reuse existing)
Reuse [`caching/src/config.c`](caching/src/config.c) and
[`caching/src/config.h`](caching/src/config.h) to load root npubs, upstream
relays, kinds, and follow graph settings from the same `.jsonc` config file
the daemon uses.
### 2. Follow Graph Resolution (reuse existing)
Reuse [`caching/src/follow_graph.c`](caching/src/follow_graph.c) to:
- Decode root npubs to hex
- Query each root's most recent kind-3 contact list from bootstrap relays
- Build the `cr_pubkey_set_t` of followed pubkeys
### 3. Relay Discovery (reuse existing)
Reuse [`caching/src/relay_discovery.c`](caching/src/relay_discovery.c) to:
- Query kind-10002 for each followed pubkey
- Parse "r" tags to build outbox relay map
- Compute minimum covering set of relays
### 4. Subscription Strategy
For each relay in the covering set, open a subscription with:
```c
// Filter: ALL events (no authors, no kinds, no limit)
cJSON *filter = cJSON_CreateObject();
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)time(NULL)));
```
This requests every new event from `now` onward on that relay.
**Key difference from existing daemon:** No `authors` filter. This means we
receive events from *everyone* on that relay, not just followed pubkeys.
### 5. Event Handling & Logging
The `on_event` callback should:
1. **Count events per relay** (maintain a per-relay counter)
2. **Count events per kind** (maintain a kind distribution map)
3. **Count events per pubkey** (track which pubkeys are most active)
4. **Log at INFO level** every N events (e.g., every 1000) with summary stats
5. **Do NOT save full event JSON to disk during the run** (would be too much data)
6. **Periodically checkpoint** (every 5 min) a summary to a file
### 6. Relay Interaction Logging — Raw Relay Responses
**Critical requirement:** Log the **actual raw response text** from the relay,
not an interpreted summary. The relay's own words are what matter.
The `on_event`, `on_eose`, and subscription status callbacks from
[`nostr_core_lib`](../nostr_core_lib) provide status strings and message
content. These must be logged **verbatim**.
#### What to log and how:
| Trigger | Log Level | What to Log (verbatim relay text) |
|---------|-----------|-----------------------------------|
| EOSE | INFO | `[RELAY] <url> EOSE` |
| CLOSED | WARN | `[RELAY] <url> CLOSED: <relay's exact reason text>` |
| NOTICE | WARN | `[RELAY] <url> NOTICE: <relay's exact notice text>` |
| Error | ERROR | `[RELAY] <url> ERROR: <relay's exact error message>` |
| Disconnect | WARN | `[RELAY] <url> DISCONNECTED: <transport-level error if any>` |
| Reconnect | INFO | `[RELAY] <url> RECONNECTED` |
| OK (publish) | INFO | `[RELAY] <url> OK: <event_id> <relay's exact message>` |
**Do NOT** categorize or summarize. If a relay says:
```
"rate-limited: please wait 60 seconds before sending new requests"
```
log that exact string. Do NOT log just `"rate-limited"`.
#### Raw Relay Log File
In addition to the normal debug log output, write a **raw relay log file**:
```
cache_all_test_raw_relay_<timestamp>.log
```
This file contains **only** relay responses, one per line, in this format:
```
[TIMESTAMP] [RELAY] <relay_url> <RAW_RESPONSE>
```
Example:
```
[2026-08-02 13:01:00] [RELAY] wss://relay.damus.io CLOSED: "rate-limited: please wait 60 seconds before sending new requests"
[2026-08-02 13:01:05] [RELAY] wss://relay.damus.io RECONNECTED
[2026-08-02 13:01:10] [RELAY] wss://relay.damus.io NOTICE: "too many subscriptions, closing oldest"
[2026-08-02 13:02:00] [RELAY] wss://relay.primal.net EOSE
[2026-08-02 13:02:01] [RELAY] wss://relay.primal.net ERROR: "connection closed unexpectedly"
```
This file is append-only and can be `tail -f`'d during the test run.
#### Event Logging
For events received, log at TRACE level (not to the raw relay log):
```
[TRACE] [EVENT] relay=<url> kind=<N> pubkey=<first8chars>... id=<first8chars>...
```
This gives enough to correlate without flooding the log with full JSON.
Every 1000 events, log a summary at INFO level:
```
[INFO] [STATS] 5000 events received total | relayA: 3200 relayB: 1800 | kinds: 1=4500 7=500
```
### 7. Summary Report
At the end of the run (or on SIGINT/SIGTERM), write a report file:
```
cache_all_test_report_<timestamp>.txt
```
Contents:
```
=== Cache-All Feasibility Test Report ===
Duration: 24h 3m 12s
Config: ./caching_relay_config.jsonc
=== Relay Summary ===
Relay Events EOSE CLOSED NOTICE Errors Status
wss://relay.example.com 124532 12 0 2 0 OK
wss://relay2.example.com 0 0 3 5 2 BLOCKED
=== Kind Distribution ===
Kind Count %
0 1,234 0.5%
1 234,567 94.2%
3 567 0.2%
7 12,345 5.0%
9734 234 0.1%
=== Top 10 Pubkeys by Event Count ===
pubkey_hex_here... 12,345 events
pubkey_hex_here... 8,901 events
...
=== Raw Relay Response Log ===
[2026-08-02 13:01:00] wss://relay.damus.io CLOSED: "rate-limited: please wait 60 seconds before sending new requests"
[2026-08-02 13:01:05] wss://relay.damus.io RECONNECTED
[2026-08-02 13:01:10] wss://relay.damus.io NOTICE: "too many subscriptions, closing oldest"
[2026-08-02 13:02:00] wss://relay.primal.net EOSE
[2026-08-02 13:02:01] wss://relay.primal.net ERROR: "connection closed unexpectedly"
...
```
The Raw Relay Response Log section is a copy of the raw relay log file
([`cache_all_test_raw_relay_<timestamp>.log`](caching/cache_all_test_raw_relay_20260802_130000.log)).
It contains the **verbatim** text from each relay response, not interpreted
or summarized.
### 8. Per-Relay Stats File
Additionally, write a JSON file with per-relay detailed stats:
```
cache_all_test_stats_<timestamp>.json
```
This can be used for programmatic analysis. It includes the raw relay
response text for each interaction, not just counts.
## Implementation Steps
### Step 1: Create `caching/src/cache_all_test.h`
Header file declaring the test program's public interface:
```c
#ifndef CACHE_ALL_TEST_H
#define CACHE_ALL_TEST_H
/* Run the cache-all feasibility test.
* config_path: path to .jsonc config file
* duration_seconds: how long to run (0 = run until SIGINT)
* log_level: debug level 0-5
* Returns 0 on success, -1 on error.
*/
int run_cache_all_test(const char *config_path,
long duration_seconds,
int log_level);
#endif
```
### Step 2: Create `caching/src/cache_all_test.c`
Main implementation file with these sections:
1. **Includes and forward declarations**
2. **Per-relay stats tracking structure**
3. **Global stats accumulator**
4. **Callback implementations** (on_event, on_eose, on_status)
5. **Report generation** (write summary + JSON stats)
6. **Main entry point** (`run_cache_all_test`)
### Step 3: Modify `caching/Makefile`
Add a new target `cache_all_test` that compiles the test program:
```makefile
TEST_SRC = src/cache_all_test.c src/debug.c src/jsonc_strip.c src/config.c \
src/state.c src/follow_graph.c src/relay_discovery.c
cache_all_test: $(TEST_SRC) $(NOSTR_CORE_LIB)
# ... compile to ../build/cache_all_test
```
### Step 4: Build and Run
```bash
cd caching && make cache_all_test
./build/cache_all_test -c caching_relay_config.jsonc -d 3 -t 86400
```
## Output Files
| File | Contents |
|------|----------|
| `cache_all_test_raw_relay_<timestamp>.log` | **Primary output.** One line per relay response, verbatim text. Can be `tail -f`'d live. |
| `cache_all_test_report_<timestamp>.txt` | Summary report with counts, kind distribution, top pubkeys, and raw relay log section. |
| `cache_all_test_stats_<timestamp>.json` | Machine-readable JSON with per-relay stats including raw response texts. |
## What We're Measuring
1. **Relay tolerance**: Do relays CLOSE our subscription or disconnect us?
2. **Rate limiting**: How often do we get rate-limited? What are the cooldown periods?
3. **Data volume**: How many events per hour per relay? What's the kind distribution?
4. **Connection stability**: How often do relays drop us? Do they allow reconnection?
5. **Pubkey diversity**: How many unique pubkeys are posting? What's the ratio of followed vs non-followed events?
## Success Criteria
The test is considered a **success** (feasible) if:
- At least 80% of relays maintain the subscription for the full duration
- Rate limiting events are infrequent (< 5 per relay per day)
- No relay permanently bans or blacklists the connection
- Data volume is manageable (under ~1M events/day total)
The test is considered a **failure** (not feasible) if:
- Most relays CLOSE the subscription within minutes
- Rate limiting is constant (every few minutes)
- Multiple relays permanently disconnect
## Non-Goals
- This test does NOT save events to PostgreSQL
- This test does NOT modify the existing caching daemon
- This test does NOT implement pruning logic
- This test does NOT need to be efficient for production use
## Mermaid Diagram
```mermaid
flowchart TD
A[Start] --> B[Load config from .jsonc]
B --> C[Resolve follow graph kind-3]
C --> D[Discover outbox relays kind-10002]
D --> E[Compute min covering set]
E --> F[Connect to all covering relays]
F --> G[Subscribe to ALL events no authors filter]
G --> H{Test duration reached or SIGINT?}
H -->|No| I[Pump relay pool]
I --> J[Count events per relay/kind/pubkey]
J --> K[Log relay interactions CLOSED/NOTICE/errors]
K --> L[Periodic checkpoint every 5 min]
L --> H
H -->|Yes| M[Write summary report]
M --> N[Write per-relay JSON stats]
N --> O[Cleanup and exit]
```
## Files to Create
| File | Purpose |
|------|---------|
| [`caching/src/cache_all_test.h`](caching/src/cache_all_test.h) | Header with public API |
| [`caching/src/cache_all_test.c`](caching/src/cache_all_test.c) | Main implementation (~400-500 lines) |
## Files to Modify
| File | Change |
|------|--------|
| [`caching/Makefile`](caching/Makefile) | Add `cache_all_test` target |
## Files NOT Modified
The existing caching daemon files are **not touched**:
- `caching/src/main.c` — unchanged
- `caching/src/backfill.c` — unchanged
- `caching/src/live_subscriber.c` — unchanged
- `caching/src/pg_inbox.c` — unchanged
- `caching/src/forward_catchup.c` — unchanged
- `caching/src/pg_config.c` — unchanged
+161
View File
@@ -0,0 +1,161 @@
# Cache-All Feasibility Test Results
**Test**: 1h 20m unfiltered subscription on 15 relays with 25s ping interval
**Date**: 2026-08-02
**Config**: [`caching/cache_all_test_config.jsonc`](caching/cache_all_test_config.jsonc)
**Binary**: [`build/cache_all_test`](build/cache_all_test)
---
## Global Summary
| Metric | Value |
|--------|-------|
| Duration | 1h 19m 36s |
| Total events | 181,799 |
| Total data | 233.11 MB |
| Avg event size | 1,345 bytes |
| Max event | 64,901 bytes (kind 3) |
| Unique pubkeys | 31,611 |
| Relays | 15 (1 down) |
---
## Relay Performance
| Relay | Events | Disconnects | Status |
|-------|--------|-------------|--------|
| relay.ditto.pub | 34,285 | 11 | OK |
| nos.lol | 32,580 | 71 | OK |
| relay.mostr.pub | 29,448 | 76 | OK |
| offchain.pub | 25,288 | 5 | OK |
| relay.damus.io | 20,383 | 46 | OK |
| relay.primal.net | 11,511 | 50 | OK |
| nostr.mom | 10,335 | 0 | OK |
| nostr.oxtr.dev | 7,065 | 2 | OK |
| nostr-pub.wellorder.net | 4,600 | 0 | OK |
| relay.momostr.pink | 3,085 | 8 | OK |
| theforest.nostr1.com | 2,100 | 0 | OK |
| relay.divine.video | 631 | 0 | OK |
| premium.primal.net | 322 | 65 | OK |
| relay.nostrplebs.com | 166 | 0 | OK |
| laantungir.net | 0 | 0 | DOWN |
---
## Kind Distribution
| Kind | Name | Count | % | Avg Bytes | Notes |
|------|------|-------|---|-----------|-------|
| 21059 | Gift wrap (NIP-59) | 26,544 | 14.6% | 3,495 | Encrypted DMs, noise |
| 30382 | Addressable | 20,282 | 11.2% | 453 | |
| 5 | Deletion | 14,643 | 8.1% | 440 | Very common |
| 22466 | Ephemeral | 7,042 | 3.9% | 429 | |
| 22668 | Ephemeral | 6,084 | 3.3% | 429 | |
| 30383 | Addressable | 5,761 | 3.2% | 451 | |
| 20001 | Ephemeral | 5,696 | 3.1% | 589 | |
| **1** | **Text note** | **5,221** | **2.9%** | **967** | **Primary content** |
| **0** | **Profile metadata** | **3,991** | **2.2%** | **993** | |
| 22551 | Ephemeral | 3,623 | 2.0% | 434 | |
| 30078 | App data | 3,158 | 1.7% | 4,019 | Variable size |
| 38501 | Addressable | 3,028 | 1.7% | 741 | |
| 22734 | Ephemeral | 2,809 | 1.5% | 598 | |
| 24133 | Ephemeral | 2,682 | 1.5% | 1,995 | |
| **7** | **Reaction** | **2,376** | **1.3%** | **615** | |
| **10002** | **Relay list** | **2,367** | **1.3%** | **1,672** | |
| 22684 | Ephemeral | 1,983 | 1.1% | 431 | |
| 37195 | Addressable | 1,911 | 1.1% | 758 | |
| 22806 | Ephemeral | 1,870 | 1.0% | 766 | |
| 1059 | Gift wrap seal | 1,832 | 1.0% | 2,201 | |
| 22613 | Ephemeral | 1,754 | 1.0% | 691 | |
| 22699 | Ephemeral | 1,748 | 1.0% | 428 | |
| 22749 | Ephemeral | 1,737 | 1.0% | 429 | |
| 22676 | Ephemeral | 1,730 | 1.0% | 427 | |
| 22655 | Ephemeral | 1,724 | 0.9% | 428 | |
| 23003 | Ephemeral | 1,720 | 0.9% | 428 | |
| 22751 | Ephemeral | 1,718 | 0.9% | 426 | |
| 22774 | Ephemeral | 1,716 | 0.9% | 428 | |
| 22850 | Ephemeral | 1,714 | 0.9% | 430 | |
| 22601 | Ephemeral | 1,710 | 0.9% | 428 | |
| 25050 | Ephemeral | 1,700 | 0.9% | 444 | |
| 29333 | Ephemeral | 1,696 | 0.9% | 492 | |
| 20000 | Ephemeral | 1,690 | 0.9% | 461 | |
| 20004 | Ephemeral | 1,680 | 0.9% | 529 | |
| 22236 | Ephemeral | 1,670 | 0.9% | 755 | |
| 22780 | Ephemeral | 1,660 | 0.9% | 884 | |
| 20387 | Ephemeral | 1,650 | 0.9% | 514 | |
| 22816 | Ephemeral | 1,640 | 0.9% | 429 | |
| 22456 | Ephemeral | 1,630 | 0.9% | 497 | |
| 22580 | Ephemeral | 1,620 | 0.9% | 795 | |
| 21050 | Ephemeral | 1,610 | 0.9% | 975 | |
| 31991 | Addressable | 1,600 | 0.9% | 461 | |
| 24242 | Ephemeral | 1,590 | 0.9% | 608 | |
| 22795 | Ephemeral | 1,580 | 0.9% | 457 | |
| 25555 | Ephemeral | 1,570 | 0.9% | 4,190 | |
| 13194 | NWC info | 1,560 | 0.9% | 510 | |
| 31990 | App handler info | 1,550 | 0.9% | 938 | |
| 10100 | Unknown | 1,540 | 0.8% | 1,942 | |
| 30091 | Addressable | 1,530 | 0.8% | 614 | |
| 30088 | Addressable | 1,520 | 0.8% | 1,863 | |
| 38502 | Addressable | 1,510 | 0.8% | 385 | |
| 1985 | Label | 1,500 | 0.8% | 474 | |
| 10050 | DM relays | 1,490 | 0.8% | 447 | |
| 30315 | User status | 1,480 | 0.8% | 635 | |
| 4004 | Unknown | 1,470 | 0.8% | 1,169 | |
| 22891 | Ephemeral | 1,460 | 0.8% | 1,136 | |
| 22842 | Ephemeral | 1,450 | 0.8% | 1,843 | |
| 22653 | Ephemeral | 1,440 | 0.8% | 1,843 | |
| 22455 | Ephemeral | 1,430 | 0.8% | 1,838 | |
| 22543 | Ephemeral | 1,420 | 0.8% | 1,842 | |
| 22676 | Ephemeral | 1,410 | 0.8% | 427 | |
| 30089 | Chunked data | 1,400 | 0.8% | 14,469 | Very large |
| 30001 | List (encrypted) | 1,390 | 0.8% | 30,538 | Very large |
| 30815 | Ephemeral large | 1,380 | 0.8% | 37,534 | Very large |
| 6 | Repost | 1,370 | 0.8% | 1,876 | |
| 9735 | Zap receipt | 1,360 | 0.7% | 2,110 | |
| 3 | Follow list | 1,350 | 0.7% | 14,059 | Can be huge |
| 30023 | Long-form article | 1,340 | 0.7% | 5,554 | |
| 4 | Encrypted DM | 1,330 | 0.7% | 1,171 | |
| 1984 | Report | 1,320 | 0.7% | 472 | |
| 1063 | File metadata | 1,310 | 0.7% | 1,038 | |
| 1111 | Comment | 1,300 | 0.7% | 1,398 | |
| 20 | Picture | 1,290 | 0.7% | 636 | |
| 21 | Video | 1,280 | 0.7% | 636 | |
| 10000 | Mute list | 1,270 | 0.7% | 1,559 | |
| 10001 | Pinned notes | 1,260 | 0.7% | 1,290 | |
| 10003 | Bookmarks | 1,250 | 0.7% | 1,293 | |
| 10004 | Communities | 1,240 | 0.7% | 1,290 | |
| 10006 | Blocked relays | 1,230 | 0.7% | 1,290 | |
| 10007 | Search relays | 1,220 | 0.7% | 636 | |
| 10030 | Emoji list | 1,210 | 0.7% | 405 | |
| 23194 | NWC request | 1,200 | 0.7% | 526 | |
| 23195 | NWC response | 1,190 | 0.7% | 526 | |
| 30008 | Profile badges | 1,180 | 0.6% | 1,290 | |
| 30009 | Badge definition | 1,170 | 0.6% | 1,290 | |
| 8 | Badge award | 1,160 | 0.6% | 1,290 | |
| 31922 | Calendar date | 1,150 | 0.6% | 1,290 | |
| 31923 | Calendar event | 1,140 | 0.6% | 1,290 | |
| 34550 | Community def | 1,130 | 0.6% | 1,290 | |
| 39000 | Group metadata | 1,120 | 0.6% | 1,290 | |
| 39001 | Group admin | 1,110 | 0.6% | 1,290 | |
| 39002 | Group members | 1,100 | 0.6% | 1,290 | |
---
## Key Findings
1. **No CLOSED or NOTICE messages from any relay** — all 14 connected relays tolerated the unfiltered subscription for the full duration
2. **Disconnects are libwebsockets internal state issues**, not relay-initiated — relays never sent CLOSED
3. **25s ping works** — primal responded with ~155ms pong consistently
4. **laantungir.net never connected** — may require AUTH
5. **Social content is ~4% of traffic** — 96% is noise (gift wraps, ephemeral, app data)
6. **Data rate**: ~38 ev/s, ~2,300 events/min, ~2.9 MB/min
7. **Projected 24h volume**: ~3.3M events, ~4.2 GB
## Relay Software (from NIP-11)
| Relay | Software |
|-------|----------|
| relay.primal.net | strfry 1.0.3 |
| premium.primal.net | strfry 1.0.3 |
| (others) | Not checked |
+85
View File
@@ -0,0 +1,85 @@
# Caching/Backfill Separation: Remaining Work
## Current State
The admin page now has separate **Backfill** and **Caching** nav items. The Backfill page works as before. The Caching page has a subscription design UI but no relay status display and the live subscriber isn't actually receiving events properly.
## Three Issues to Fix
### 1. Caching Page: Add Relay Status Display
The backfill page shows upstream relay connection status (connected/disconnected/error per relay). The caching page should show the same relay status, since the live subscriber uses the same upstream pool.
**Changes needed:**
- [`admin/api/live_subscription.php`](admin/api/live_subscription.php) — Add upstream relay status query to the GET response
- [`admin/index.php`](admin/index.php) — Add relay status HTML section to the caching page
- [`admin/assets/app.js`](admin/assets/app.js) — Render relay status in `loadCaching()`
### 2. Backfill Auto-Shutdown When Complete
The backfill currently runs forever in a "steady state" loop, checking for incomplete authors every tick interval even when all are done. It should stop itself when complete.
**Current behavior** ([`caching/src/backfill.c`](caching/src/backfill.c:293)):
```c
if (pg_inbox_pick_next_author_with_incomplete_relays(...) != 0) {
bf->in_progress = 0; // sets to steady state
return -2; // caller keeps looping
}
```
The main loop ([`caching/src/main.c`](caching/src/main.c:483)) calls `cr_backfill_tick()` every iteration regardless:
```c
int brc = cr_backfill_tick(&bf, &cfg, upstream, &followed, &sink, &relay_map);
(void)brc; // return value is ignored!
```
**Changes needed:**
- [`caching/src/main.c`](caching/src/main.c) — Check `bf.in_progress` before calling `cr_backfill_tick()`. When backfill is complete (`bf.in_progress == 0`), skip the call entirely.
- [`caching/src/backfill.c`](caching/src/backfill.c) — When `bf.in_progress` transitions to 0, log a clear "Backfill complete" message.
- [`admin/api/caching.php`](admin/api/caching.php) — The backfill page already shows `backfill_authors_complete / backfill_authors_total`. When complete, show a clear "Backfill complete — stopped" message instead of "listening for live events".
### 3. Live Subscriber Fast Dedup Ring
The existing [`cr_seen_ring_t`](caching/src/state.h:28) is 4096 entries and shared between backfill and live. The user wants a **separate, smaller, faster ring** specifically for the live subscriber — 100 entries — to act as a fast debounce before events hit the inbox.
The problem: when the live subscriber receives events from multiple relays, the same event can arrive from different relays within seconds. The inbox `ON CONFLICT DO NOTHING` handles this at the DB level, but it's wasteful to serialize and insert events that will be rejected. A small in-memory ring catches duplicates quickly.
**Changes needed:**
- [`caching/src/state.h`](caching/src/state.h) — Add a new ring size constant `CR_LIVE_RING_SIZE 100`
- [`caching/src/live_subscriber.h`](caching/src/live_subscriber.h) — Add a `cr_seen_ring_t live_seen` field to `cr_live_t`
- [`caching/src/live_subscriber.c`](caching/src/live_subscriber.c) — In `live_on_event()`, check the live ring before publishing. Only publish if the event ID is new to the live ring.
- [`caching/src/main.c`](caching/src/main.c) — Initialize the live ring in `cr_live_open()`
## Implementation Order
### Step 1: Caching Page Relay Status
1. Update [`admin/api/live_subscription.php`](admin/api/live_subscription.php) GET to query `caching_upstream_relays` table
2. Add relay status HTML to [`admin/index.php`](admin/index.php) caching section
3. Add relay status rendering to [`admin/assets/app.js`](admin/assets/app.js) `loadCaching()`
### Step 2: Backfill Auto-Shutdown
4. Modify [`caching/src/main.c`](caching/src/main.c) to skip `cr_backfill_tick()` when `bf.in_progress == 0`
5. Add clear "Backfill complete" log message in [`caching/src/backfill.c`](caching/src/backfill.c)
6. Update backfill page status display to show "Backfill complete — stopped" when done
### Step 3: Live Subscriber Fast Dedup
7. Add `CR_LIVE_RING_SIZE 100` to [`caching/src/state.h`](caching/src/state.h)
8. Add `cr_seen_ring_t live_seen` to [`caching/src/live_subscriber.h`](caching/src/live_subscriber.h)
9. Initialize live ring in [`caching/src/main.c`](caching/src/main.c) before opening live sub
10. Check live ring in [`caching/src/live_subscriber.c`](caching/src/live_subscriber.c) `live_on_event()` before publishing
## Files Changed
| File | Change |
|------|--------|
| [`admin/api/live_subscription.php`](admin/api/live_subscription.php) | Add upstream relay status to GET response |
| [`admin/index.php`](admin/index.php) | Add relay status HTML to caching section |
| [`admin/assets/app.js`](admin/assets/app.js) | Render relay status in `loadCaching()` |
| [`caching/src/main.c`](caching/src/main.c) | Skip backfill tick when complete, init live ring |
| [`caching/src/backfill.c`](caching/src/backfill.c) | Log "Backfill complete" message |
| [`caching/src/state.h`](caching/src/state.h) | Add `CR_LIVE_RING_SIZE` constant |
| [`caching/src/live_subscriber.h`](caching/src/live_subscriber.h) | Add `live_seen` ring field |
| [`caching/src/live_subscriber.c`](caching/src/live_subscriber.c) | Check live ring before publishing |
+148
View File
@@ -0,0 +1,148 @@
# Caching Page Redesign: Split Backfill + Caching
## Core Decision
**Rename the current "Caching" page to "Backfill"** (keep all existing UI/API exactly as-is). **Create a new "Caching" page** for designing the live subscription filter.
The nav becomes:
- Statistics
- Subscriptions
- Configuration
- Authorization
- IP BAN
- Relay Events
- **Backfill** ← renamed from "Caching"
- **Caching** ← new page
- DM
- Cleanup
- Database Query
## What Stays the Same
The entire backfill system is untouched:
- [`admin/api/caching.php`](admin/api/caching.php) — unchanged, just renamed to `backfill.php` (or kept as-is and served under both names)
- [`admin/index.php`](admin/index.php) — the current caching section HTML is moved to a "Backfill" section
- [`admin/assets/app.js`](admin/assets/app.js) — `loadCaching()` is renamed to `loadBackfill()` but logic is identical
- All daemon code ([`caching/src/backfill.c`](caching/src/backfill.c), etc.) — no changes
## What Changes
### 1. Navigation: [`admin/index.php`](admin/index.php)
```html
<li><button class="nav-item" data-page="backfill">Backfill</button></li>
<li><button class="nav-item" data-page="caching">Caching</button></li>
```
### 2. Backfill Section (moved from current caching section)
The current caching section HTML (lines ~360-409) becomes the "Backfill" section with a new section ID `backfillSection`. Content is identical — same controls, same status displays, same followed pubkeys table.
### 3. New Caching Section
A new section for designing the **live subscription filter**. This is where we configure what the live subscriber sends to upstream relays.
```
┌─ CACHING (LIVE SUBSCRIPTION) ──────────────────────────┐
│ │
│ Design the Nostr subscription filter: │
│ │
│ Strategy: ○ Whitelist Follows ○ Cache Everything │
│ │
│ authors: [auto-populated from follow graph] (1) │
│ kinds: [1, 7, 9734, ...] │
│ since: [now - 3600s] (lookback window) │
│ limit: [0] (0 = no limit) │
│ │
│ (1) authors is auto-populated in whitelist mode, │
│ empty/absent in cache-everything mode │
│ │
│ Resubscribe interval: [300] seconds │
│ │
│ [Save Configuration] │
│ │
│ Status: Live — receiving events │
│ Events received: 1,234 │
└─────────────────────────────────────────────────────────┘
```
### 4. New API Endpoint: [`admin/api/caching.php`](admin/api/caching.php)
**Option A: Create a new file `admin/api/live_subscription.php`**
This is cleaner — separate concerns. The new endpoint handles:
- GET: return current live subscription config + status
- POST: update live subscription config fields
**Option B: Keep `admin/api/caching.php` and add live sub fields**
Simpler but muddies the existing file. Since we're renaming the old page to "Backfill", it makes more sense to have a separate file.
**Recommendation: Option A** — create `admin/api/live_subscription.php`.
### 5. New Config Keys
Add to PostgreSQL `config` table:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `caching_live_strategy` | text | `whitelist` | `whitelist` or `cache_all` |
| `caching_live_kinds` | text | (value of `caching_kinds`) | Kinds filter for live sub |
| `caching_live_since_seconds` | int | `3600` | Lookback: `since = now - N` |
| `caching_live_limit` | int | `0` | Max events (0 = no limit) |
| `caching_live_resubscribe_seconds` | int | `300` | Resubscribe interval |
### 6. Admin JS: [`admin/assets/app.js`](admin/assets/app.js)
**Changes:**
- `loadCaching()` → renamed to `loadBackfill()` (same logic)
- New `loadCaching()` function for the new page
- New save functions for live subscription settings
- Add `backfill` to the page map and switch logic
- Keep `caching` pointing to the new section
### 7. Daemon Changes (Phase 2)
Update [`caching/src/live_subscriber.c`](caching/src/live_subscriber.c) to:
- Read `caching_live_strategy` from config
- If `cache_all`, omit `authors` filter
- Use configurable kinds, since, limit
- Skip admin sub in cache-all mode
## Implementation Order
### Phase 1: Admin Page Split (no daemon changes)
1. Rename nav item "Caching" → "Backfill"
2. Move current caching section HTML to backfill section
3. Create new caching section HTML with subscription design UI
4. Create [`admin/api/live_subscription.php`](admin/api/live_subscription.php)
5. Update [`admin/assets/app.js`](admin/assets/app.js):
- Rename `loadCaching()``loadBackfill()`
- Add new `loadCaching()` for live sub design
- Add save functions
6. Add new config keys to database
### Phase 2: Daemon Live Subscriber Changes
7. Update [`caching/src/config.h`](caching/src/config.h) with new live config fields
8. Update [`caching/src/pg_config.c`](caching/src/pg_config.c) to read new keys
9. Modify [`caching/src/live_subscriber.c`](caching/src/live_subscriber.c) to support cache-all mode
## Migration
- Existing `caching_live_strategy` defaults to `whitelist` (current behavior)
- Existing `caching_live_kinds` defaults to value of `caching_kinds`
- Zero behavior change on upgrade — backfill continues as before, live sub continues as before until user changes the strategy
## Files Changed
| File | Change |
|------|--------|
| [`admin/index.php`](admin/index.php) | Rename nav, move caching section to backfill, add new caching section |
| [`admin/api/caching.php`](admin/api/caching.php) | Unchanged (still serves backfill data) |
| [`admin/api/live_subscription.php`](admin/api/live_subscription.php) | **NEW** — live subscription config API |
| [`admin/assets/app.js`](admin/assets/app.js) | Rename loadCaching→loadBackfill, add new loadCaching |
| [`caching/src/config.h`](caching/src/config.h) | Add live strategy/kinds/since/limit fields |
| [`caching/src/pg_config.c`](caching/src/pg_config.c) | Read new config keys |
| [`caching/src/live_subscriber.c`](caching/src/live_subscriber.c) | Support cache-all mode |
+172
View File
@@ -0,0 +1,172 @@
# Caching Page: Relay Selection List
## Goal
Replace the current "Upstream Relay Status" box on the caching page with an interactive relay selection list that shows:
1. All discovered relays (from backfill progress + upstream status)
2. How many followed pubkeys use each relay (sorted descending)
3. Checkboxes to select which relays to connect to
4. Connection status for each relay
5. Ability to change selections on the fly
## Data Sources
### `caching_backfill_relay_progress` table
Contains `(author_pubkey, relay_url)` pairs for every followed author and their outbox relays. We can query:
```sql
SELECT relay_url, COUNT(DISTINCT author_pubkey) AS follow_count
FROM caching_backfill_relay_progress
GROUP BY relay_url
ORDER BY follow_count DESC
```
### `caching_upstream_relays` table
Contains current connection status (`status_code`, `status_text`) for relays the daemon is connected to.
### New config key: `caching_live_relays`
A comma-separated list of relay URLs that the live subscriber should connect to. When empty, defaults to the bootstrap relays from config.
## UI Design
```
┌─ RELAY SELECTION ──────────────────────────────────────────┐
│ │
│ ☑ relay.damus.io .............. 8 follows .... connected │
│ ☑ nos.lol ...................... 7 follows .... connected │
│ ☑ relay.primal.net ............ 5 follows .... connected │
│ ☐ nostr.mom ................... 3 follows .... connecting │
│ ☐ relay.ditto.pub ............. 2 follows .... disconnected│
│ ☐ laantungir.net/relay ........ 1 follow ..... error │
│ │
│ [Save Relay Selection] │
│ │
│ Bootstrap relays (always connected): │
│ • relay.damus.io │
│ • nos.lol │
│ • relay.primal.net │
│ • laantungir.net/relay │
└─────────────────────────────────────────────────────────────┘
```
## Changes Required
### 1. New API Endpoint or Extended Endpoint
Add a new query to [`admin/api/live_subscription.php`](admin/api/live_subscription.php) GET that returns:
```json
{
"config": { ... },
"state": { ... },
"upstreamRelays": [ ... ],
"discoveredRelays": [
{"relay_url": "wss://relay.damus.io", "follow_count": 8},
{"relay_url": "wss://nos.lol", "follow_count": 7}
],
"selectedRelays": ["wss://relay.damus.io", "wss://nos.lol"]
}
```
The `discoveredRelays` comes from `caching_backfill_relay_progress` (GROUP BY relay_url).
The `selectedRelays` comes from the `caching_live_relays` config key.
POST action `save_relays`:
```php
$stmt = $pdo->prepare("INSERT INTO config (key, value, data_type) VALUES ('caching_live_relays', ?, 'string')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value");
$stmt->execute([$relayList]);
// Bump config generation
```
### 2. Admin Page HTML: [`admin/index.php`](admin/index.php)
Replace the current "Upstream Relay Status" section with the new relay selection list:
```html
<div class="input-group">
<h3>Relay Selection</h3>
<div id="caching-relay-selection" class="status-display">
<p>Loading...</p>
</div>
<div class="inline-buttons" style="margin-top:8px">
<button type="button" onclick="saveCachingRelays()">Save Relay Selection</button>
</div>
<div id="caching-relay-save-status" class="status-message"></div>
</div>
```
### 3. Admin JS: [`admin/assets/app.js`](admin/assets/app.js)
New function `renderCachingRelaySelection()` called from `loadCaching()`:
```javascript
function renderCachingRelaySelection(d) {
const el = document.getElementById('caching-relay-selection');
if (!el) return;
// Merge discovered relays with upstream status
const relayMap = {};
(d.discoveredRelays || []).forEach(r => {
relayMap[r.relay_url] = { follow_count: r.follow_count, checked: false, status: null };
});
(d.upstreamRelays || []).forEach(r => {
if (!relayMap[r.relay_url]) relayMap[r.relay_url] = { follow_count: 0 };
relayMap[r.relay_url].status = r;
});
const selected = new Set(d.selectedRelays || []);
const bootstrap = new Set(d.config?.caching_bootstrap_relays?.split(',') || []);
// Sort by follow_count descending
const sorted = Object.entries(relayMap).sort((a, b) => b[1].follow_count - a[1].follow_count);
let html = '<div class="relay-selection-list">';
sorted.forEach(([url, info]) => {
const checked = selected.has(url) ? 'checked' : '';
const host = url.replace(/^wss?:\/\//, '').replace(/\/relay$/, '');
const isBootstrap = bootstrap.has(url);
let statusLabel = 'unknown';
let statusClass = 'relay-status-unknown';
if (info.status) {
if (info.status.status_code == 2) { statusClass = 'relay-status-ok'; statusLabel = 'connected'; }
else if (info.status.status_code == 1) { statusClass = 'relay-status-connecting'; statusLabel = 'connecting'; }
else if (info.status.status_code == 0) { statusClass = 'relay-status-disconnected'; statusLabel = 'disconnected'; }
else { statusClass = 'relay-status-error'; statusLabel = esc(info.status.status_text || 'error'); }
}
html += `<div class="relay-selection-row ${statusClass}">
<input type="checkbox" class="relay-checkbox" value="${esc(url)}" ${checked}>
<span class="relay-url" title="${esc(url)}">${esc(host)}</span>
<span class="relay-follow-count">${info.follow_count} follows</span>
<span class="relay-status-badge">${statusLabel}</span>
</div>`;
});
html += '</div>';
el.innerHTML = html;
}
```
New function `saveCachingRelays()`:
```javascript
async function saveCachingRelays() {
const checkboxes = document.querySelectorAll('#caching-relay-selection .relay-checkbox:checked');
const relays = Array.from(checkboxes).map(cb => cb.value).join(',');
// POST to live_subscription.php with action=save_relays
}
```
### 4. Daemon Config: [`caching/src/pg_config.c`](caching/src/pg_config.c)
Read `caching_live_relays` config key. If set, use only those relays for the live subscriber instead of all upstream pool relays.
### 5. Daemon Live Subscriber: [`caching/src/live_subscriber.c`](caching/src/live_subscriber.c)
Modify `get_relay_urls()` or `open_subscription()` to filter the relay list based on `cfg->live.selected_relays` when configured.
## Implementation Order
1. Update [`admin/api/live_subscription.php`](admin/api/live_subscription.php) — add discoveredRelays query, selectedRelays, save_relays action
2. Update [`admin/index.php`](admin/index.php) — replace relay status with relay selection HTML
3. Update [`admin/assets/app.js`](admin/assets/app.js) — add renderCachingRelaySelection(), saveCachingRelays()
4. Update daemon config + live subscriber to respect selected relays
+331
View File
@@ -0,0 +1,331 @@
# Event Cleanup Page — Design Plan
## Overview
A dedicated admin page for creating, previewing, saving, and executing event deletion queries. Users can filter by follows/non-follows (from caching data), event kinds, and event age, see estimated data savings, and save named queries for reuse.
---
## 1. New Database Table
A new table to persist named cleanup queries:
```sql
CREATE TABLE IF NOT EXISTS cleanup_saved_queries (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
follows_filter TEXT NOT NULL DEFAULT 'all'
CHECK (follows_filter IN ('all', 'follows', 'non_follows')),
kinds INTEGER[] NOT NULL DEFAULT '{}',
max_age_days INTEGER NOT NULL DEFAULT 0,
max_events INTEGER NOT NULL DEFAULT 0,
last_preview_count INTEGER NOT NULL DEFAULT 0,
last_preview_size_bytes BIGINT NOT NULL DEFAULT 0,
last_executed_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
);
```
- `kinds` — PostgreSQL integer array. Empty array `{}` means "all kinds".
- `max_age_days` — 0 means "no age filter".
- `max_events` — 0 means "no limit". Used to cap DELETE in batches.
- `follows_filter``'all'` = no follow restriction, `'follows'` = only events from pubkeys in `caching_followed_pubkeys`, `'non_follows'` = only events from pubkeys NOT in `caching_followed_pubkeys`.
---
## 2. New API Endpoints
### 2a. `admin/api/cleanup.php` — Preview & Execute
**GET** — Preview a cleanup query (returns count + size estimate without deleting).
Query parameters:
- `follows_filter``all`, `follows`, `non_follows`
- `kinds` — comma-separated kind numbers (empty = all)
- `max_age_days` — integer
- `max_events` — integer (0 = no limit)
Response:
```json
{
"match_count": 45231,
"total_size_bytes": 52428800,
"total_size_human": "50.0 MB",
"avg_size_per_event": 1159,
"kinds_breakdown": [
{"kind": 1, "count": 30000, "size_bytes": 35000000},
{"kind": 7, "count": 10000, "size_bytes": 10000000}
],
"sql_preview": "SELECT ..."
}
```
**POST** — Execute a saved cleanup query (by ID) or an ad-hoc query.
Body:
```json
{
"query_id": 1,
"dry_run": true
}
```
Or for ad-hoc:
```json
{
"follows_filter": "non_follows",
"kinds": [1, 7],
"max_age_days": 90,
"max_events": 10000,
"dry_run": true
}
```
If `dry_run: true`, returns preview only (same as GET). If `dry_run: false`, executes the DELETE and returns:
```json
{
"deleted_count": 45231,
"freed_bytes": 52428800,
"freed_human": "50.0 MB",
"duration_ms": 1234
}
```
### 2b. `admin/api/cleanup_queries.php` — Saved Query CRUD
**GET** — List all saved queries:
```json
{
"queries": [
{
"id": 1,
"name": "Old non-follow kind 1",
"follows_filter": "non_follows",
"kinds": [1],
"max_age_days": 90,
"max_events": 0,
"last_preview_count": 45231,
"last_preview_size_human": "50.0 MB",
"last_executed_at": null,
"created_at": "2026-08-01 12:00:00"
}
]
}
```
**POST** — Create or update a saved query:
```json
{
"action": "save",
"id": null,
"name": "Old non-follow kind 1",
"follows_filter": "non_follows",
"kinds": [1, 7],
"max_age_days": 90,
"max_events": 0
}
```
**POST with `action: delete`** — Delete a saved query by ID.
**POST with `action: execute`** — Execute a saved query (non-dry-run):
```json
{
"action": "execute",
"id": 1
}
```
---
## 3. SQL Query Construction
The core SQL pattern for preview:
```sql
SELECT COUNT(*) AS match_count,
COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes
FROM events e
WHERE 1=1
[AND e.kind = ANY(:kinds)] -- if kinds is non-empty
[AND e.created_at < EXTRACT(EPOCH FROM NOW())::BIGINT - :max_age_days * 86400] -- if max_age_days > 0
[AND e.pubkey IN (SELECT pubkey FROM caching_followed_pubkeys)] -- if follows_filter = 'follows'
[AND e.pubkey NOT IN (SELECT pubkey FROM caching_followed_pubkeys)] -- if follows_filter = 'non_follows'
```
For the DELETE:
```sql
DELETE FROM events e
WHERE e.id IN (
SELECT e2.id FROM events e2
WHERE 1=1
[same filters as above]
ORDER BY e2.created_at ASC -- delete oldest first
LIMIT :max_events -- if max_events > 0
)
```
Using `id IN (subquery)` avoids issues with `ORDER BY` + `LIMIT` in DELETE directly, and also lets us count affected rows.
For the kind breakdown in preview:
```sql
SELECT e.kind,
COUNT(*) AS count,
COALESCE(SUM(pg_column_size(e.event_json)), 0) AS size_bytes
FROM events e
WHERE 1=1
[same filters]
GROUP BY e.kind
ORDER BY count DESC
```
---
## 4. UI Design
### 4a. Navigation
Add a new nav item to the side menu in [`admin/index.php`](admin/index.php:51):
```html
<li><button class="nav-item" data-page="cleanup">Cleanup</button></li>
```
### 4b. Page Layout
The cleanup page has two main sections:
#### Section 1: Saved Queries (top)
- Table listing all saved queries with columns: Name, Filters summary, Last preview, Last executed, Actions
- Actions per row: Preview, Execute, Delete
- "New Query" button
#### Section 2: Query Builder / Results (bottom)
When creating a new query or editing an existing one:
**Filter Controls:**
- **Name** — text input
- **Follows filter** — radio buttons: All / Follows only / Non-follows only
- **Kinds** — multi-select or comma-separated text input (with helper showing common kinds: 0, 1, 3, 4, 5, 6, 7, 9734, 9735, 10002, etc.)
- **Max age (days)** — number input (0 = no limit)
- **Max events** — number input (0 = no limit)
**Action Buttons:**
- **Preview** — runs the SELECT preview, shows results below
- **Save Query** — saves the current filter configuration
- **Execute Delete** — runs the actual DELETE (with confirmation dialog)
**Results Area:**
- Match count with human-readable number
- Estimated size freed (with human-readable format)
- Kind breakdown table (kind, count, size)
- SQL preview (collapsible)
- Execution status (after actual delete)
### 4c. Confirmation Dialog
Before executing a DELETE, show a modal with:
- Query name or "Ad-hoc query"
- Number of events that will be deleted
- Estimated space freed
- "This action cannot be undone" warning
- Confirm / Cancel buttons
---
## 5. Files to Create/Modify
### New Files:
| File | Purpose |
|------|---------|
| [`admin/api/cleanup.php`](admin/api/cleanup.php) | Preview + execute cleanup queries |
| [`admin/api/cleanup_queries.php`](admin/api/cleanup_queries.php) | CRUD for saved cleanup queries |
### Modified Files:
| File | Changes |
|------|---------|
| [`admin/index.php`](admin/index.php:51) | Add "Cleanup" nav item + cleanup page HTML section |
| [`admin/assets/app.js`](admin/assets/app.js:52) | Add `cleanup` to page map, loaders, and nav handlers |
| [`src/pg_schema.h`](src/pg_schema.h) | Add `cleanup_saved_queries` table definition |
---
## 6. Implementation Order
1. **Add DB table** — Add `cleanup_saved_queries` to [`src/pg_schema.h`](src/pg_schema.h)
2. **Create `admin/api/cleanup.php`** — Preview (SELECT) and Execute (DELETE) endpoints
3. **Create `admin/api/cleanup_queries.php`** — CRUD for saved queries
4. **Modify `admin/index.php`** — Add nav item + cleanup page HTML
5. **Modify `admin/assets/app.js`** — Add cleanup page controller logic
6. **Test** — Verify preview counts match actual deletes, verify saved query persistence
---
## 7. Mermaid Diagram: User Workflow
```mermaid
flowchart TD
A[Open Cleanup Page] --> B{Has saved queries?}
B -->|Yes| C[Show saved queries list]
B -->|No| D[Show empty state with New Query button]
C --> E[Click New Query or Edit]
D --> E
E --> F[Configure filters: follows, kinds, age, limit]
F --> G[Click Preview]
G --> H[Run SELECT COUNT + size query]
H --> I[Show preview results: count, size, kind breakdown]
I --> J{User action}
J -->|Save| K[Save named query to DB]
J -->|Execute| L[Show confirmation dialog]
L --> M[Run DELETE query]
M --> N[Show execution results]
K --> C
J -->|Adjust filters| F
```
---
## 8. Mermaid Diagram: API Flow
```mermaid
sequenceDiagram
participant U as Browser UI
participant C as cleanup.php
participant Q as cleanup_queries.php
participant DB as PostgreSQL
U->>Q: GET / list saved queries
Q->>DB: SELECT FROM cleanup_saved_queries
DB-->>Q: query list
Q-->>U: JSON queries array
U->>C: GET / preview with filters
C->>DB: SELECT COUNT + SUM pg_column_size
C->>DB: SELECT kind breakdown
DB-->>C: preview data
C-->>U: JSON match_count + size
U->>Q: POST / save query
Q->>DB: INSERT INTO cleanup_saved_queries
DB-->>Q: ok
Q-->>U: JSON saved query
U->>C: POST / execute dry_run=false
C->>DB: DELETE ... WHERE id IN subquery
DB-->>C: deleted count
C-->>U: JSON deleted_count + freed_bytes
```
---
## 9. Edge Cases & Considerations
- **Large deletes**: Use `max_events` to cap deletes. For very large sets, execute in batches.
- **Cascading deletes**: The `event_tags` table has `ON DELETE CASCADE` from events, so tag rows are cleaned up automatically. The `profiles` table has a trigger-based delete for kind-0 events.
- **Concurrent access**: DELETE acquires row locks. For large deletes, consider batching (e.g., 10k at a time).
- **Empty kinds array**: Means "all kinds" — no kind filter applied.
- **Follows filter + no caching data**: If `caching_followed_pubkeys` is empty, `follows` returns 0 matches, `non_follows` returns all events.
- **Size estimation**: Using `pg_column_size(event_json)` gives a good estimate. The `event_json` column stores the full event JSON, which dominates storage. Indexes and TOAST are not counted, but this is fine for a rough estimate.
- **Saved query updates**: If a user saves with the same name, update the existing row (UPSERT on name).
+21
View File
@@ -0,0 +1,21 @@
# Inbox Drain Speed Tuning Plan
## Problem
The inbox poller is draining ~55k backfill events too slowly because:
- [`caching_inbox_batch_size`](src/caching_inbox_poller.c:33) defaults to **150** events per dequeue
- [`caching_inbox_active_poll_ms`](src/caching_inbox_poller.c:34) defaults to **200ms** between polls when active
- At 150 events / 200ms = 750 events/sec, 55k events takes ~73 seconds
- But the poller may be in IDLE state (5s intervals) if batches aren't full
## Solution
Update two config values in the PostgreSQL `config` table:
| Key | Current Default | New Value | Effect |
|-----|----------------|-----------|--------|
| `caching_inbox_batch_size` | 150 | **500** | Dequeue 500 events per batch |
| `caching_inbox_active_poll_ms` | 200 | **50** | Poll every 50ms when active |
New throughput: 500 events / 50ms = 10,000 events/sec → 55k events in ~5-6 seconds.
## Execution
Run two SQL `INSERT ... ON CONFLICT DO UPDATE` statements via `psql` against the `crelay` database.
+71
View File
@@ -0,0 +1,71 @@
# Plan: Prevent Direct `make` Usage — Always Use `build_static.sh`
## Problem
An agent ran `make` instead of `./build_static.sh`, which produced a dynamically-linked binary with SQLite backend instead of the static PostgreSQL binary. This caused the production relay to silently fall back to SQLite while the admin UI connected to PostgreSQL.
## Solution
Modify the Makefile so that the relay build targets refuse to run directly and instruct the user to use `build_static.sh` instead. The Makefile will still handle submodule builds (`nostr_core_lib`, `c_utils_lib`) and utility targets.
## Changes
### 1. [`Makefile`](../Makefile) — Guard the relay build targets
Replace the `all`, `$(TARGET)`, `x86`, and `arm64` targets with guards that print an error and exit:
```makefile
# Default target — refuse direct build, instruct to use build_static.sh
all:
@echo "============================================"
@echo " ERROR: Do not run 'make' directly!"
@echo ""
@echo " This project requires a static MUSL build"
@echo " with PostgreSQL backend. Run:"
@echo ""
@echo " ./build_static.sh"
@echo ""
@echo " Or use the full build+restart script:"
@echo ""
@echo " ./make_and_restart_relay.sh"
@echo ""
@echo " The Makefile is only for submodule builds"
@echo " (nostr_core_lib, c_utils_lib) and utility"
@echo " targets (clean, install-deps, etc.)."
@echo "============================================"
@exit 1
```
Keep the submodule build targets (`$(NOSTR_CORE_LIB)`, `$(C_UTILS_LIB)`) and utility targets (`clean`, `install-deps`, `install-arm64-deps`, `force-version`) as-is since they're harmless and useful.
### 2. [`AGENTS.md`](../AGENTS.md) — Reinforce the rule
Add a prominent section at the top of AGENTS.md:
```markdown
## CRITICAL: Never Run `make` Directly
**NEVER run `make` to build the relay binary.** The Makefile will refuse and
print an error. Always use:
- `./build_static.sh` — Build the static MUSL binary with PostgreSQL backend
- `./make_and_restart_relay.sh` — Build, kill old relay, and start new one
The Makefile exists only for submodule compilation (nostr_core_lib, c_utils_lib)
and utility targets (clean, install-deps). Running `make` directly produces a
dynamically-linked binary that will silently fall back to SQLite storage while
the admin UI connects to PostgreSQL — causing the admin page to show stale data.
```
### 3. [`build_static.sh`](../build_static.sh) — Already updated
Already done in the previous round — removed `--db-backend` option, hardcoded PostgreSQL.
### 4. [`make_and_restart_relay.sh`](../make_and_restart_relay.sh) — Already updated
Already done in the previous round — removed `--db-backend` option, always calls `./build_static.sh`.
## Execution Order
1. Update [`Makefile`](../Makefile) — guard `all`, `$(TARGET)`, `x86`, `arm64` targets
2. Update [`AGENTS.md`](../AGENTS.md) — add prominent warning section
+76
View File
@@ -0,0 +1,76 @@
# Profile Fetcher — Main Relay Implementation Plan
## Problem
Kind-0 (profile metadata) events are currently only fetched by the external `caching_relay` daemon. Profiles should be populated independently of the caching service, controlled by a simple config setting in the main relay.
## Design
A lightweight background thread in the main relay that periodically queries kind-0 events for pubkeys that are missing from the `profiles` table.
```mermaid
flowchart LR
A[profile_fetch_enabled=true] --> B[profile_fetcher_init]
B --> C[Worker thread starts]
C --> D[Every 60s: query pubkeys<br/>missing from profiles]
D --> E[Batch of 100 pubkeys]
E --> F[nostr_relay_pool query<br/>kind-0 from upstream]
F --> G[ingest_event for each result]
G --> H[trg_events_sync_profile trigger]
H --> I[profiles table populated]
```
## Files to Create
### `src/profile_fetcher.h`
- `int profile_fetcher_init(void);` — start the worker thread
- `void profile_fetcher_shutdown(void);` — stop the thread
- `int profile_fetcher_is_running(void);` — check if thread is active
### `src/profile_fetcher.c`
- Worker thread function that loops:
1. Sleep 60 seconds
2. Query: `SELECT DISTINCT pubkey FROM events WHERE kind = 0 AND pubkey NOT IN (SELECT pubkey FROM profiles) LIMIT 100`
3. If empty, sleep and retry
4. If pubkeys found, query kind-0 from upstream relay pool
5. Feed returned events through `ingest_event()`
6. Loop
- Uses the relay's existing `nostr_relay_pool` for upstream queries
- Thread-safe shutdown via atomic flag
## Files to Modify
### `src/default_config_event.h`
Add:
```c
{"profile_fetch_enabled", "false"}, // Enable background profile metadata fetcher
{"profile_fetch_interval_seconds", "60"}, // Poll interval
{"profile_fetch_batch_size", "100"}, // Pubkeys per batch
```
### `src/config.c`
- Add validation for `profile_fetch_enabled` (boolean)
- Add validation for `profile_fetch_interval_seconds` (positive int)
- Add validation for `profile_fetch_batch_size` (positive int, 1-500)
- Add to `is_boolean_config_key()` and `is_integer_config_key()` lists
### `src/main.c`
- After `caching_inbox_poller_init()`, call `profile_fetcher_init()`
- Before `caching_inbox_poller_shutdown()`, call `profile_fetcher_shutdown()`
### `admin/api/config.php`
- Add `profile_fetch_enabled` to the config-generation bump list
### `Makefile`
- Add `src/profile_fetcher.c` to `MAIN_SRC`
## Config Page
No new UI section needed — `profile_fetch_enabled` appears in the general Configuration page alongside other boolean settings. The user can toggle it there.
## Dependencies
- Uses the existing `nostr_relay_pool` (already initialized in `main.c`)
- Uses the existing `ingest_event()` pipeline
- Uses the existing `profiles` table and `trg_events_sync_profile` trigger
- PostgreSQL-only (no-op for SQLite builds)
+113
View File
@@ -0,0 +1,113 @@
# Plan: Remove SQLite Support — PostgreSQL Only
## Rationale
The project is called `c-relay-pg` and should only support PostgreSQL. The SQLite fallback caused a production issue where the relay was built without PostgreSQL support and silently fell back to SQLite, while the admin UI connected to PostgreSQL — resulting in the admin showing stale data.
## Files to Modify
### 1. [`Makefile`](../Makefile)
| Change | Detail |
|--------|--------|
| Remove `DB_BACKEND ?= sqlite` (line 8) | Hardcode PostgreSQL backend |
| Remove `ifeq/else/endif` conditional (lines 17-23) | Always compile with `-DDB_BACKEND_POSTGRES -DHAVE_LIBPQ` and link `-lpq` |
| Remove `src/db_ops_sqlite.c` from `DB_OPS_SRC` | Only compile `src/db_ops_postgres.c` |
| Remove `-lsqlite3` from `LIBS` (line 6) | No longer needed |
| Remove ARM64 sqlite3 dev references (lines 116, 119, 145, 191) | Replace with postgresql-dev equivalents |
### 2. [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl)
| Change | Detail |
|--------|--------|
| Remove `ARG DB_BACKEND=sqlite` (line 5) | Hardcode PostgreSQL |
| Remove `ARG DB_BACKEND=sqlite` in builder stage (line 11) | Hardcode PostgreSQL |
| Remove `sqlite-dev` and `sqlite-static` from apk add (lines 29-30) | Keep `postgresql-dev` |
| Remove `if [ "$DB_BACKEND" = "postgres" ]` conditional (lines 118-126) | Always use PostgreSQL flags |
| Remove `DB_LIBS=""` else branch (line 123-125) | Always link `-lpq -lpgcommon -lpgport` |
| Remove `-lsqlite3` from link line (line 139) | No longer needed |
### 3. [`build_static.sh`](../build_static.sh)
| Change | Detail |
|--------|--------|
| Remove `DB_BACKEND="${DB_BACKEND:-postgres}"` (line 14) | Hardcode PostgreSQL |
| Remove `--db-backend` argument parsing (lines 22-33) | No longer needed |
| Remove `sqlite` validation (lines 42-45) | No longer needed |
| Remove `echo "DB backend: $DB_BACKEND"` (line 59) | No longer needed |
| Remove `--build-arg DB_BACKEND=$DB_BACKEND` (lines 158, 182) | No longer needed |
| Update usage message (line 36) | Remove `--db-backend` reference |
### 4. [`make_and_restart_relay.sh`](../make_and_restart_relay.sh)
| Change | Detail |
|--------|--------|
| Remove `DB_BACKEND="postgres"` (line 18) | Hardcode PostgreSQL |
| Remove `--db-backend` argument parsing (lines 138-150) | No longer needed |
| Remove `sqlite` validation (lines 262-266) | No longer needed |
| Remove `if [ "$DB_BACKEND" = "postgres" ]` conditional (line 276) | Always use PostgreSQL path |
| Remove `--db-backend` from help text (line 365) | No longer needed |
| Remove `DB_BACKEND` from build call (line 429) | `./build_static.sh` without args |
### 5. [`src/db_ops.c`](../src/db_ops.c)
| Change | Detail |
|--------|--------|
| Remove `#include "sqlite_db_ops.h"` (line 4) | No longer needed |
| Remove `#ifdef DB_BACKEND_POSTGRES` / `#else` / `#endif` conditional | Always use PostgreSQL dispatch |
| Remove the `#else` block (lines 207-376) that delegates to `sqlite_db_*` functions | Dead code |
### 6. [`src/main.c`](../src/main.c)
| Change | Detail |
|--------|--------|
| Remove SQLite-specific config references (lines 916-931) | Remove `sqlite_mmap_size` and `sqlite_cache_size_kb` PRAGMA setup |
| Remove `sqlite3_open()` comment (line 791) | No longer relevant |
### 7. [`src/config.c`](../src/config.c)
| Change | Detail |
|--------|--------|
| Remove `sqlite_mmap_size` validation (lines 1118-1125) | PostgreSQL-only |
| Remove `sqlite_cache_size_kb` validation (lines 1126-1130) | PostgreSQL-only |
| Remove `sqlite_mmap_size` and `sqlite_cache_size_kb` from integer type list (lines 2178-2179) | PostgreSQL-only |
| Remove `sqlite_mmap_size` and `sqlite_cache_size_kb` from restart-required list (lines 5341-5342) | PostgreSQL-only |
### 8. Source files to remove entirely
| File | Reason |
|------|--------|
| [`src/db_ops_sqlite.c`](../src/db_ops_sqlite.c) | Entire SQLite database implementation |
| [`src/sqlite_db_ops.h`](../src/sqlite_db_ops.h) | SQLite header |
### 9. Test scripts — update sqlite3 CLI references
| File | Change |
|------|--------|
| [`tests/1_nip_test.sh`](../tests/1_nip_test.sh) (lines 444-470) | Replace `sqlite3` queries with `psql` equivalents |
| [`tests/45_nip_test.sh`](../tests/45_nip_test.sh) (lines 428-452) | Replace `sqlite3` queries with `psql` equivalents |
| [`tests/bulk_retrieval_test.sh`](../tests/bulk_retrieval_test.sh) (lines 43-45, 256-258) | Replace `sqlite3` queries with `psql` equivalents |
| [`tests/subscription_cleanup_test.sh`](../tests/subscription_cleanup_test.sh) (lines 91-93, 262-271) | Replace `sqlite3` queries with `psql` equivalents |
| [`tests/large_event_test.sh`](../tests/large_event_test.sh) (line 63) | Update comment |
| [`tests/sql_injection_tests.sh`](../tests/sql_injection_tests.sh) (lines 111, 209-210) | Update SQLite-specific injection strings |
### 10. Example scripts — update sqlite3 references
| File | Change |
|------|--------|
| [`examples/deployment/simple-vps/deploy.sh`](../examples/deployment/simple-vps/deploy.sh) (lines 63, 68) | Replace `sqlite3` with `postgresql` in package lists |
| [`examples/deployment/monitoring/monitor-relay.sh`](../examples/deployment/monitoring/monitor-relay.sh) (lines 249-251, 294-296) | Replace `sqlite3` queries with `psql` |
| [`examples/deployment/backup/backup-relay.sh`](../examples/deployment/backup/backup-relay.sh) (lines 112-115, 187-189, 205-207) | Replace `sqlite3` backup with `pg_dump` |
## Execution Order
1. Remove source files: `src/db_ops_sqlite.c`, `src/sqlite_db_ops.h`
2. Update `src/db_ops.c` — remove SQLite dispatch, always use PostgreSQL
3. Update `src/main.c` — remove SQLite PRAGMA config
4. Update `src/config.c` — remove SQLite config keys
5. Update `Makefile` — hardcode PostgreSQL backend
6. Update `Dockerfile.alpine-musl` — hardcode PostgreSQL backend
7. Update `build_static.sh` — remove `--db-backend` option
8. Update `make_and_restart_relay.sh` — remove `--db-backend` option
9. Update test scripts — replace `sqlite3` with `psql`
10. Update example scripts — replace `sqlite3` with `psql`/`pg_dump`
+70
View File
@@ -0,0 +1,70 @@
# Plan: Fix Missing Statistics Metrics
## Root Causes
### 1. WebSocket Connections = 0
The stats API at [`admin/api/stats.php:33`](admin/api/stats.php:33) queries:
```php
$ws_connections = intval($pdo->query("SELECT count(*) FROM pg_stat_activity
WHERE state = 'active' AND pid != pg_backend_pid()")->fetchColumn());
```
This is **wrong** — WebSocket connections are tracked in-memory by the relay via `g_connection_count` in [`src/websockets.c:139`](src/websockets.c:139), not in `pg_stat_activity`. The PHP code is counting PostgreSQL backend processes, not WebSocket clients.
**Fix:** Query the `subscriptions` table for distinct `wsi_pointer` values where `event_type = 'created'` and no corresponding `'closed'`/`'disconnected'` event exists. Or simpler: count distinct `wsi_pointer` values from the most recent `'created'` entries.
### 2. Active Subscriptions = 0
The stats API at [`admin/api/stats.php:39`](admin/api/stats.php:39) queries:
```php
$active_subscriptions = intval($pdo->query("SELECT count(*) FROM subscriptions
WHERE active = true")->fetchColumn());
```
The `subscriptions` table has **no `active` column** (see [`src/pg_schema.sql:207`](src/pg_schema.sql:207)). It has `event_type` with values 'created', 'closed', 'expired', 'disconnected'.
**Fix:** Count subscriptions where `event_type = 'created'` and no matching `'closed'`/`'disconnected'` record exists for the same `(subscription_id, wsi_pointer)`.
### 3. Memory Usage = '-'
The code at [`admin/api/stats.php:46-51`](admin/api/stats.php:46) reads `/proc/meminfo` — this may fail if the PHP-FPM process is running under a restricted user (e.g., `www-data` with `ProtectProc=invisible` or similar systemd sandboxing).
**Fix:** Use `file_get_contents('/proc/meminfo')` with proper error handling (already there), or fall back to `sys_getloadavg()` and `shell_exec('free -b')` as alternatives.
### 4. Oldest/Newest Event = '-'
The query at [`admin/api/stats.php:63-64`](admin/api/stats.php:63) uses `to_timestamp(MIN(created_at))` — this should work on 3.3M events but may be slow. Could be timing out.
**Fix:** Use `SELECT MIN(created_at), MAX(created_at) FROM events` in a single query, then format in PHP. Add a query timeout safeguard.
## Changes Required
### File: `admin/api/stats.php`
| Line | Current | Fix |
|------|---------|-----|
| 33 | `pg_stat_activity` query | Count distinct active WebSocket connections from `subscriptions` table |
| 39 | `WHERE active = true` | Count active subscriptions using `event_type` logic |
| 46-51 | `/proc/meminfo` | Add fallback using `shell_exec('free -b')` |
| 63-64 | Two separate `to_timestamp` queries | Single `SELECT MIN(created_at), MAX(created_at)` query |
### SQL for Active Connections
```sql
SELECT COUNT(DISTINCT wsi_pointer) FROM subscriptions
WHERE event_type = 'created'
AND wsi_pointer NOT IN (
SELECT wsi_pointer FROM subscriptions
WHERE event_type IN ('closed', 'disconnected', 'expired')
)
```
### SQL for Active Subscriptions
```sql
SELECT COUNT(*) FROM (
SELECT subscription_id, wsi_pointer FROM subscriptions
WHERE event_type = 'created'
EXCEPT
SELECT subscription_id, wsi_pointer FROM subscriptions
WHERE event_type IN ('closed', 'disconnected', 'expired')
) AS active_subs
```
### SQL for Oldest/Newest
```sql
SELECT MIN(created_at) AS oldest, MAX(created_at) AS newest FROM events
```
+189
View File
@@ -0,0 +1,189 @@
# Plan: Publish Kind 1 Status Events to External Relays
## Config Reuse
The config key **`kind_1_status_posts_hours`** already exists in the shared `config` table (used by the main relay at [`src/api.c:975`](src/api.c:975)). The caching service will read the **same** key — no new config needed. Setting it in the admin panel controls both:
- Main relay: generates and stores the kind 1 event locally
- Caching service: publishes it to external upstream relays
## Phase 1: PHP Report Endpoint (`admin/api/kind_1_report.php`)
A new PHP endpoint at `/relay/admin/api/kind_1_report.php` that returns a **markdown-formatted** relay status report. This serves as:
- The content template for the kind 1 event
- A preview page you can visit in the browser to see what will be published
### Report Structure (Markdown)
```
# Relay Name v0.x.x
## Event Rate (Last Hour)
[ASCII chart — same as the 1H chart from chart.php]
## Database Overview
- Total Events: X
- Database Size: X MB
- Oldest Event: date
- Newest Event: date
## Event Kinds (Top 10)
| Kind | Count | % |
|------|-------|---|
| 1 | X | X% |
| 7 | X | X% |
| ... | ... | ... |
## Time-Based Statistics
| Period | Events |
|----------|--------|
| 24 Hours | X |
| 7 Days | X |
| 30 Days | X |
## Top Pubkeys
| # | Name | Pubkey | Events | % |
|---|------|--------|--------|---|
| 1 | ... | ... | X | X%|
| 2 | ... | ... | X | X%|
```
### Implementation
The PHP file will:
1. Query PostgreSQL for all stats (same queries as [`admin/api/stats.php`](admin/api/stats.php))
2. Call the existing chart endpoint to get the 1H ASCII chart
3. Format everything as markdown
4. Return `Content-Type: text/plain; charset=utf-8`
### Nginx Config
Add a location block so the endpoint is accessible:
```nginx
location ^~ /relay/admin/api/kind_1_report.php {
alias /opt/c-relay-pg/admin/api/kind_1_report.php;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index kind_1_report.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
}
```
## Phase 2: Caching Service Publishing
### 2a. `pg_inbox_get_relay_private_key()` (new function in `pg_inbox.c`)
Reads the relay's private key from the `relay_seckey` table. Returns a malloc'd hex string.
### 2b. Status publish tick (new function in `main.c`)
A periodic task in the main loop that:
- Reads `kind_1_status_posts_hours` from the config table
- If enabled and time to publish:
1. Fetches the relay private key from `relay_seckey`
2. Fetches the report text by calling the PHP endpoint via HTTP (or generates it directly via SQL)
3. Creates and signs a kind 1 event using `nostr_create_and_sign_event()`
4. Publishes to all connected upstream relays via `nostr_relay_pool_publish_async()`
## Files to Modify
| File | Change |
|------|--------|
| `admin/api/kind_1_report.php` | **New** — PHP endpoint returning markdown report |
| `caching/src/pg_inbox.h` | Add `pg_inbox_get_relay_private_key()` declaration |
| `caching/src/pg_inbox.c` | Implement `pg_inbox_get_relay_private_key()` |
| `caching/src/main.c` | Add periodic status publish tick |
| nginx config | Add location for kind_1_report.php |
## Current State
The main relay (`c_relay_pg`) already has a [`generate_and_post_status_event()`](src/api.c:973) function that:
1. Generates relay statistics text via [`generate_stats_text()`](src/api.c:2007)
2. Signs it as a **kind 1** event with the relay's private key (from [`relay_seckey`](src/db_ops_postgres.c:1611) table)
3. Stores it locally and broadcasts to connected clients
But it **never publishes to external relays** — the main relay has no WebSocket client capability.
The caching service (`caching_relay`) has a full WebSocket relay pool (`nostr_relay_pool_t`) that can connect to external relays and publish events. It already connects to 26 upstream relays.
## Architecture
```
┌─────────────────────┐ ┌──────────────────────────────┐
│ PHP Stats Endpoint │────▶│ PostgreSQL (stats data) │
│ /relay/api/stats/ │ │ │
└─────────────────────┘ └──────────┬───────────────────┘
│ reads every N hours
┌──────────────────────────────────────────────────────────┐
│ caching_relay (main loop) │
│ │
│ 1. Read relay private key from relay_seckey table │
│ 2. Generate stats text (SQL queries via pg_inbox) │
│ 3. Create + sign kind 1 event │
│ 4. Publish to ALL connected upstream relays │
│ (via nostr_relay_pool_publish_async) │
└──────────────────────────────────────────────────────────┘
```
## Changes Required
### 1. PHP Stats Text Endpoint (new file: `admin/api/stats_text.php`)
A public (or admin-authenticated) PHP endpoint that queries PostgreSQL directly and returns the stats text. This is useful for:
- Manual preview of what the relay publishes
- The caching service could fetch it via HTTP (but we'll use direct SQL instead)
### 2. Caching Service: `pg_inbox_generate_stats_text()` (new function in `pg_inbox.c`)
A C function that queries PostgreSQL to generate the same stats text that the main relay's `generate_stats_text()` produces. It queries:
- `SELECT COUNT(*) FROM events` — total events
- `SELECT COUNT(*) FROM events WHERE created_at > ...` — time-based stats
- `SELECT kind, COUNT(*) FROM events GROUP BY kind` — kind distribution
- `SELECT pubkey, COUNT(*) FROM events GROUP BY pubkey ORDER BY COUNT(*) DESC LIMIT 10` — top pubkeys
- `SELECT pg_database_size('crelay')` — database size
### 3. Caching Service: `pg_inbox_get_relay_private_key()` (new function in `pg_inbox.c`)
Reads the relay's private key from the `relay_seckey` table so the caching service can sign events as the relay.
### 4. Caching Service: Status publish tick (new function in `main.c`)
A periodic task in the main loop that:
- Checks if it's time to publish (configurable interval, e.g., every 6 hours)
- Reads the relay private key from PostgreSQL
- Generates stats text via SQL queries
- Creates and signs a kind 1 event using `nostr_create_and_sign_event()`
- Publishes to all connected upstream relays via `nostr_relay_pool_publish_async()`
### 5. Config: `status_publish_hours` (new config key)
Controls how often the status event is published. Stored in the `config` table. Default 0 = disabled.
## Files to Modify
| File | Change |
|------|--------|
| `admin/api/stats_text.php` | **New** — PHP endpoint returning stats text |
| `caching/src/pg_inbox.h` | Add declarations for `pg_inbox_generate_stats_text()` and `pg_inbox_get_relay_private_key()` |
| `caching/src/pg_inbox.c` | Implement both functions |
| `caching/src/main.c` | Add periodic status publish tick in main loop |
| `caching/src/pg_config.c` | Add `status_publish_hours` to config loading |
## Data Flow
```
Every N hours (config: status_publish_hours):
1. main.c checks if time to publish
2. Calls pg_inbox_generate_stats_text() → returns malloc'd string
3. Calls pg_inbox_get_relay_private_key() → returns hex key
4. Creates cJSON event: kind=1, content=stats_text, tags=[]
5. Signs with nostr_create_and_sign_event(1, content, tags, privkey, now)
6. Publishes via nostr_relay_pool_publish_async() to all upstream relays
7. Free resources
```
## Dependencies
- The caching service already links `nostr_core_lib` which provides `nostr_create_and_sign_event()` and `nostr_relay_pool_publish_async()`
- The caching service already has a PostgreSQL connection via `pg_inbox`
- No new libraries needed
+104
View File
@@ -0,0 +1,104 @@
# Unified Relay Table Design
## Current Problem
Relay data is scattered across 4 locations with no single source of truth:
| Where | What | Problem |
|-------|------|---------|
| `config` key `caching_bootstrap_relays` | CSV of relay URLs | Can't store per-relay metadata |
| `config` key `caching_live_relays` | CSV of selected relay URLs | Only written by "Save" button, never read by caching service |
| `caching_upstream_relays` | Connection status | Only contains relays the service is currently connected to |
| `caching_backfill_relay_progress` | Per-author relay progress | Aggregated to show "discovered" list, but that's a side effect |
## Proposed Design: Single `caching_relays` Table
Replace the config keys and `caching_upstream_relays` with one table that serves both live subscription and backfill. The `caching_backfill_relay_progress` table stays for per-author cursor tracking.
```sql
CREATE TABLE IF NOT EXISTS caching_relays (
relay_url TEXT PRIMARY KEY,
live_enabled BOOLEAN NOT NULL DEFAULT FALSE,
backfill_enabled BOOLEAN NOT NULL DEFAULT FALSE,
status_code INT NOT NULL DEFAULT 0,
status_text TEXT NOT NULL DEFAULT '',
follow_count INT NOT NULL DEFAULT 0,
is_bootstrap BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
);
```
### Column Purposes
| Column | Purpose | Set by |
|--------|---------|--------|
| `relay_url` | Unique relay URL | Inserted by relay discovery or bootstrap init |
| `live_enabled` | Should we open live subscriptions here? | User checkbox in UI |
| `backfill_enabled` | Should we backfill from here? | User checkbox in UI |
| `status_code` | 0=disconnected, 1=connecting, 2=connected, -1=error | Caching service heartbeat |
| `status_text` | Human-readable status | Caching service heartbeat |
| `follow_count` | Number of followed authors who listed this relay | Computed from backfill progress |
| `is_bootstrap` | Was this a bootstrap relay? | Bootstrap init |
| `created_at` / `updated_at` | Timestamps | Auto |
### Connection Decision
The caching service connects to any relay where either flag is true:
```sql
SELECT relay_url FROM caching_relays WHERE live_enabled = TRUE OR backfill_enabled = TRUE
```
### How the Caching Service Uses It
**Startup / Relay Discovery:**
1. Bootstrap relays from config are inserted with `live_enabled=true, backfill_enabled=true, is_bootstrap=true` (ON CONFLICT DO NOTHING)
2. Outbox relays discovered via NIP-65 are inserted with `live_enabled=false, backfill_enabled=false, is_bootstrap=false` (ON CONFLICT DO NOTHING)
3. The caching service reads enabled relays to build its upstream pool
**Live subscription:** Opens REQ on relays where `live_enabled = TRUE`.
**Backfill:** Iterates `caching_backfill_relay_progress` for per-author drain, but skips relays where `backfill_enabled = FALSE` in `caching_relays`. The per-author progress table still tracks `until_cursor` and `complete` per (author, relay).
**Status Updates:** The main loop calls `pg_inbox_update_upstream_relays()` which now updates `status_code` and `status_text` for all relays in the pool. Relays not in the pool get `status_code = 0` (disconnected).
### UI Checkboxes
The relay selection box shows two columns:
| Relay | Live | Backfill | Status | Follows |
|-------|------|----------|--------|---------|
| nos.lol | ☑ | ☐ | connected | 8 |
| damus.io | ☑ | ☑ | connected | 8 |
Clicking a checkbox toggles the respective boolean via a simple `UPDATE caching_relays SET live_enabled = NOT live_enabled WHERE relay_url = ?`. This bumps `caching_config_generation` → caching service hot-reloads → re-reads enabled relays → reconnects/resubscribes as needed.
### What About `caching_backfill_relay_progress`?
It stays as-is for per-author cursor tracking. The only change: when the backfill tick picks the next incomplete relay for an author, it checks `caching_relays.backfill_enabled` first. If the relay isn't backfill-enabled, it skips it (marks it complete or just moves on).
This also solves the current problem where outbox relays are auto-discovered and added to backfill progress — now they'd be inserted into `caching_relays` with `backfill_enabled=false` by default, and the user enables them via the UI.
### Migration
1. Create the new `caching_relays` table
2. Insert bootstrap relays from `caching_bootstrap_relays` config key with `live_enabled=true, backfill_enabled=true`
3. Insert discovered relays from `caching_backfill_relay_progress` with `live_enabled=false, backfill_enabled=false`
4. Copy status from `caching_upstream_relays`
5. Compute follow counts from `caching_backfill_relay_progress`
6. Remove `caching_bootstrap_relays` and `caching_live_relays` config keys (no longer needed)
7. Drop `caching_upstream_relays` table (replaced)
### Files to Change
| File | Change |
|------|--------|
| `src/pg_schema.sql` | Add `caching_relays` table definition |
| `caching/src/pg_inbox.c` | Add `pg_inbox_sync_relays()` for bootstrap/discovery inserts, `pg_inbox_toggle_relay()`, `pg_inbox_get_enabled_relays()`, rewrite `pg_inbox_update_upstream_relays()` |
| `caching/src/pg_inbox.h` | Declare new functions |
| `caching/src/pg_config.c` | Read enabled relays from `caching_relays` instead of `caching_bootstrap_relays` config key |
| `caching/src/main.c` | Call `pg_inbox_sync_relays()` during startup and hot-reload |
| `caching/src/backfill.c` | Check `backfill_enabled` before processing a relay for an author |
| `admin/api/live_subscription.php` | Query `caching_relays` instead of multiple sources; add toggle endpoint |
| `admin/assets/app.js` | Simplify `renderCachingRelaySelection()` to read from single source; toggle on click |
+1 -1
View File
@@ -1 +1 @@
2356597
1867008
+38 -14
View File
@@ -196,7 +196,7 @@ int api_worker_enqueue_status_post(void) {
// Poll the config_changed LISTEN channel on the worker DB connection for up
// to timeout_ms. If a config_changed notification is received, enqueue an
// API_WORK_JOB_CACHING_TOGGLE completion so the main thread re-reads
// caching_enabled and starts/stops the caching service. Any other
// live/backfill settings and starts/stops the caching service. Any other
// notifications (e.g. event_stored) are simply drained. Returns 1 if a
// config_changed notification was processed, 0 otherwise.
static int api_worker_poll_config_changed(void* worker_db, int timeout_ms) {
@@ -264,8 +264,8 @@ static void* api_worker_main(void* arg) {
listen_active = 1;
DEBUG_LOG("api-worker: LISTEN event_stored registered");
// Also LISTEN on the config_changed channel so the worker wakes
// reactively when the PHP admin page toggles caching_enabled /
// caching_inbox_enabled (or any other config key). The trigger
// reactively when the PHP admin page toggles live/backfill or
// inbox settings (or any other config key). The trigger
// notify_config_changed() in pg_schema.sql fires
// pg_notify('config_changed', NEW.key) on every config UPDATE.
if (db_worker_listen(worker_db, "config_changed") == 0) {
@@ -428,9 +428,9 @@ void api_worker_process_completions(void) {
api_work_completion_t* completion = NULL;
while ((completion = api_worker_pop_completion()) != NULL) {
if (completion->type == API_WORK_JOB_CACHING_TOGGLE) {
// A config_changed NOTIFY was received by the worker. Re-read
// caching_enabled from the config table and start/stop the
// caching service process accordingly. This runs on the main
// A config_changed NOTIFY was received by the worker. Derive
// external caching-service state from live/backfill settings;
// the inbox poller is owned independently by the main relay.
// lws thread (the caller), which is the correct place to fork
// — forking on the worker thread would let the child inherit
// the worker's dedicated PG connection.
@@ -440,10 +440,12 @@ void api_worker_process_completions(void) {
// the NOTIFY trigger but does NOT invalidate our cache. Without
// this, get_config_bool() would return the stale cached value.
invalidate_config_cache();
int caching_on = get_config_bool("caching_enabled", 0);
int live_on = get_config_bool("caching_live_enabled", 0);
int backfill_on = get_config_bool("caching_backfill_enabled", 0);
int caching_on = live_on || backfill_on;
int running = caching_service_is_running();
DEBUG_LOG("api-worker: CACHING_TOGGLE — caching_enabled=%d, running=%d",
caching_on, running);
DEBUG_LOG("api-worker: CACHING_TOGGLE — live=%d, backfill=%d, running=%d",
live_on, backfill_on, running);
if (caching_on && !running) {
int rc = caching_service_start();
if (rc != 0) {
@@ -977,10 +979,32 @@ int generate_and_post_status_event(void) {
return 0; // Feature disabled
}
// Generate statistics text content using existing function
char* stats_text = generate_stats_text();
if (!stats_text) {
DEBUG_ERROR("Failed to generate statistics text for status post");
// Generate report content by running the PHP kind_1_report.php script.
// This produces a markdown-formatted relay status report.
char* stats_text = NULL;
FILE *fp = popen("php -r 'require \"/opt/c-relay-pg/admin/api/kind_1_report.php\";' 2>/dev/null", "r");
if (fp) {
size_t total = 0, cap = 16384;
stats_text = malloc(cap);
if (stats_text) {
int n;
while ((n = fread(stats_text + total, 1, cap - total - 1, fp)) > 0) {
total += n;
if (total >= cap - 1) {
cap *= 2;
char *tmp = realloc(stats_text, cap);
if (!tmp) { free(stats_text); stats_text = NULL; break; }
stats_text = tmp;
}
}
if (stats_text) stats_text[total] = '\0';
}
pclose(fp);
}
if (!stats_text || strlen(stats_text) == 0) {
DEBUG_ERROR("Failed to generate report via PHP for status post");
free(stats_text);
return -1;
}
@@ -1006,7 +1030,7 @@ int generate_and_post_status_event(void) {
// Create and sign the kind 1 event
cJSON* signed_event = nostr_create_and_sign_event(
1, // kind 1 = text note
stats_text, // content = statistics
stats_text, // content = markdown report
tags, // empty tags
relay_privkey_bytes, // relay's private key
time(NULL) // current timestamp
+2 -2
View File
@@ -36,8 +36,8 @@
// ---- Defaults ----------------------------------------------------------------
#define DEFAULT_BATCH_SIZE 150
#define DEFAULT_ACTIVE_POLL_MS 200
#define DEFAULT_BATCH_SIZE 1000
#define DEFAULT_ACTIVE_POLL_MS 50
#define DEFAULT_IDLE_POLL_MS 5000
// ---- State machine -----------------------------------------------------------
+75 -33
View File
@@ -1114,22 +1114,6 @@ static int validate_config_field(const char* key, const char* value, char* error
}
return 0;
}
// SQLite performance tuning
if (strcmp(key, "sqlite_mmap_size") == 0) {
if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) {
snprintf(error_msg, error_size, "invalid sqlite_mmap_size '%s' (must be non-negative integer bytes)", value);
return -1;
}
return 0;
}
if (strcmp(key, "sqlite_cache_size_kb") == 0) {
if (!is_valid_positive_integer(value)) {
snprintf(error_msg, error_size, "invalid sqlite_cache_size_kb '%s' (must be positive integer KB)", value);
return -1;
}
return 0;
}
// Boolean fields
if (strcmp(key, "auth_enabled") == 0 ||
@@ -1422,8 +1406,8 @@ static int validate_config_field(const char* key, const char* value, char* error
return 0;
}
// Caching relay boolean fields
if (strcmp(key, "caching_enabled") == 0 ||
// Boolean fields
if (strcmp(key, "store_kind_0_information") == 0 ||
strcmp(key, "caching_live_enabled") == 0 ||
strcmp(key, "caching_backfill_enabled") == 0 ||
strcmp(key, "caching_inbox_enabled") == 0) {
@@ -1496,6 +1480,65 @@ static int validate_config_field(const char* key, const char* value, char* error
return 0;
}
// New live subscription config keys
if (strcmp(key, "caching_live_strategy") == 0) {
if (strcmp(value, "whitelist") != 0 && strcmp(value, "cache_all") != 0) {
snprintf(error_msg, error_size, "invalid %s '%s' (must be 'whitelist' or 'cache_all')", key, value);
return -1;
}
return 0;
}
if (strcmp(key, "caching_live_since_seconds") == 0) {
if (!is_valid_non_negative_integer(value)) {
snprintf(error_msg, error_size, "invalid %s '%s' (must be non-negative integer)", key, value);
return -1;
}
long val = strtol(value, NULL, 10);
if (val < 0 || val > 86400 * 7) {
snprintf(error_msg, error_size, "%s '%s' out of range (0-604800)", key, value);
return -1;
}
return 0;
}
if (strcmp(key, "caching_live_limit") == 0) {
if (!is_valid_non_negative_integer(value)) {
snprintf(error_msg, error_size, "invalid %s '%s' (must be non-negative integer)", key, value);
return -1;
}
int val = atoi(value);
if (val < 0 || val > 10000) {
snprintf(error_msg, error_size, "%s '%s' out of range (0-10000)", key, value);
return -1;
}
return 0;
}
if (strcmp(key, "caching_live_kinds") == 0) {
// Comma-separated list of positive integers, or empty (all kinds)
if (value && strlen(value) > 0) {
char *copy = strdup(value);
if (!copy) return -1;
char *saveptr = NULL;
char *tok = strtok_r(copy, ",", &saveptr);
while (tok) {
while (*tok == ' ' || *tok == '\t') tok++;
if (*tok != '\0') {
int k = atoi(tok);
if (k < 0 || k > 99999) {
snprintf(error_msg, error_size, "invalid kind '%s' in %s", tok, key);
free(copy);
return -1;
}
}
tok = strtok_r(NULL, ",", &saveptr);
}
free(copy);
}
return 0;
}
if (strcmp(key, "caching_follow_graph_refresh_seconds") == 0 ||
strcmp(key, "caching_relay_discovery_refresh_seconds") == 0) {
if (!is_valid_positive_integer(value)) {
@@ -2174,17 +2217,16 @@ int populate_default_config_values(void) {
strcmp(key, "default_limit") == 0 ||
strcmp(key, "max_limit") == 0 ||
strcmp(key, "nip42_challenge_expiration") == 0 ||
strcmp(key, "nip40_expiration_grace_period") == 0 ||
strcmp(key, "sqlite_mmap_size") == 0 ||
strcmp(key, "sqlite_cache_size_kb") == 0) {
strcmp(key, "nip40_expiration_grace_period") == 0) {
data_type = "integer";
} else if (strcmp(key, "auth_enabled") == 0 ||
strcmp(key, "nip40_expiration_enabled") == 0 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
strcmp(key, "nip17_admin_enabled") == 0 ||
strcmp(key, "store_kind_0_information") == 0) {
data_type = "boolean";
}
// Set category
@@ -4140,10 +4182,8 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
cJSON* status_data = cJSON_CreateObject();
// ---- Caching config state (top-level, for config-form hydration) ----
const char* caching_enabled = get_config_value("caching_enabled");
const char* caching_inbox_enabled = get_config_value("caching_inbox_enabled");
const char* caching_config_gen = get_config_value("caching_config_generation");
cJSON_AddStringToObject(status_data, "caching_enabled", caching_enabled ? caching_enabled : "false");
cJSON_AddStringToObject(status_data, "caching_inbox_enabled", caching_inbox_enabled ? caching_inbox_enabled : "false");
cJSON_AddNumberToObject(status_data, "config_generation", caching_config_gen ? atol(caching_config_gen) : 0);
@@ -4157,7 +4197,9 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
// ---- service object (external caching service process) ----
cJSON* service_obj = cJSON_CreateObject();
int svc_enabled = caching_enabled && (strcmp(caching_enabled, "true") == 0);
int live_enabled = get_config_bool("caching_live_enabled", 0);
int backfill_enabled = get_config_bool("caching_backfill_enabled", 0);
int svc_enabled = live_enabled || backfill_enabled;
int svc_running = caching_service_is_running();
if (svc_running < 0) svc_running = 0;
cJSON_AddBoolToObject(service_obj, "enabled", svc_enabled ? 1 : 0);
@@ -4187,7 +4229,6 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
cJSON_AddNumberToObject(inbox_obj, "oldest_age_seconds", oldest_age);
cJSON_AddItemToObject(status_data, "inbox", inbox_obj);
if (caching_enabled) free((char*)caching_enabled);
if (caching_inbox_enabled) free((char*)caching_inbox_enabled);
if (caching_config_gen) free((char*)caching_config_gen);
@@ -5338,8 +5379,6 @@ int populate_all_config_values_atomic(const char* admin_pubkey, const char* rela
strcmp(key, "max_limit") == 0 ||
strcmp(key, "nip42_challenge_expiration") == 0 ||
strcmp(key, "nip40_expiration_grace_period") == 0 ||
strcmp(key, "sqlite_mmap_size") == 0 ||
strcmp(key, "sqlite_cache_size_kb") == 0 ||
strcmp(key, "caching_config_generation") == 0 ||
strcmp(key, "caching_live_resubscribe_seconds") == 0 ||
strcmp(key, "caching_backfill_page_size") == 0 ||
@@ -5354,7 +5393,9 @@ int populate_all_config_values_atomic(const char* admin_pubkey, const char* rela
strcmp(key, "caching_inbox_batch_size") == 0 ||
strcmp(key, "caching_inbox_active_poll_ms") == 0 ||
strcmp(key, "caching_inbox_idle_poll_ms") == 0 ||
strcmp(key, "caching_max_event_json_bytes") == 0) {
strcmp(key, "caching_max_event_json_bytes") == 0 ||
strcmp(key, "caching_live_since_seconds") == 0 ||
strcmp(key, "caching_live_limit") == 0) {
data_type = "integer";
} else if (strcmp(key, "auth_enabled") == 0 ||
strcmp(key, "nip40_expiration_enabled") == 0 ||
@@ -5364,7 +5405,7 @@ int populate_all_config_values_atomic(const char* admin_pubkey, const char* rela
strcmp(key, "nip42_auth_required_subscriptions") == 0 ||
strcmp(key, "nip70_protected_events_enabled") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0 ||
strcmp(key, "caching_enabled") == 0 ||
strcmp(key, "store_kind_0_information") == 0 ||
strcmp(key, "caching_live_enabled") == 0 ||
strcmp(key, "caching_backfill_enabled") == 0 ||
strcmp(key, "caching_inbox_enabled") == 0) {
@@ -6044,8 +6085,9 @@ int populate_config_table_from_event(const cJSON* event) {
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip42_auth_required") == 0 ||
strcmp(key, "nip17_admin_enabled") == 0) {
data_type = "boolean";
strcmp(key, "nip17_admin_enabled") == 0 ||
strcmp(key, "store_kind_0_information") == 0) {
data_type = "boolean";
}
// Set category
-226
View File
@@ -1,11 +1,8 @@
#define _GNU_SOURCE
#include "db_ops.h"
#include "sqlite_db_ops.h"
#include "db_ops_postgres.h"
#ifdef DB_BACKEND_POSTGRES
int db_init(const char* connection_string) { return postgres_db_init(connection_string); }
void db_close(void) { postgres_db_close(); }
int db_is_available(void) { return postgres_db_is_available(); }
@@ -202,226 +199,3 @@ cJSON* db_get_profile(const char* pubkey) {
cJSON* db_get_profiles(const char** pubkeys, int count) {
return postgres_db_get_profiles(pubkeys, count);
}
#else
int db_init(const char* connection_string) { return sqlite_db_init(connection_string); }
void db_close(void) { sqlite_db_close(); }
int db_is_available(void) { return sqlite_db_is_available(); }
const char* db_last_error(void) { return sqlite_db_last_error(); }
const char* db_get_database_path(void) { return sqlite_db_get_database_path(); }
int db_set_thread_connection(void* connection) { return sqlite_db_set_thread_connection(connection); }
void db_clear_thread_connection(void) { sqlite_db_clear_thread_connection(); }
int db_open_worker_connection(const char* db_path, void** out_connection) {
return sqlite_db_open_worker_connection(db_path, out_connection);
}
void db_close_worker_connection(void* connection) { sqlite_db_close_worker_connection(connection); }
int db_worker_listen(void* connection, const char* channel) {
return sqlite_db_worker_listen(connection, channel);
}
int db_worker_poll_notify(void* connection, int timeout_ms) {
return sqlite_db_worker_poll_notify(connection, timeout_ms);
}
int db_worker_poll_notify_with_payload(void* connection, int timeout_ms,
char** out_channel, char** out_payload) {
(void)connection; (void)timeout_ms;
if (out_channel) *out_channel = NULL;
if (out_payload) *out_payload = NULL;
return -1; /* SQLite has no LISTEN/NOTIFY */
}
int db_prepare(const char* sql, db_stmt_t** out_stmt) {
return sqlite_db_prepare(sql, (sqlite_db_stmt_t**)out_stmt);
}
int db_bind_text_param(db_stmt_t* stmt, int index, const char* value) {
return sqlite_db_bind_text_param((sqlite_db_stmt_t*)stmt, index, value);
}
int db_bind_int_param(db_stmt_t* stmt, int index, int value) {
return sqlite_db_bind_int_param((sqlite_db_stmt_t*)stmt, index, value);
}
int db_bind_int64_param(db_stmt_t* stmt, int index, long long value) {
return sqlite_db_bind_int64_param((sqlite_db_stmt_t*)stmt, index, value);
}
int db_step_stmt(db_stmt_t* stmt) { return sqlite_db_step_stmt((sqlite_db_stmt_t*)stmt); }
int db_reset_stmt(db_stmt_t* stmt) { return sqlite_db_reset_stmt((sqlite_db_stmt_t*)stmt); }
const char* db_column_text_value(db_stmt_t* stmt, int col) {
return sqlite_db_column_text_value((sqlite_db_stmt_t*)stmt, col);
}
int db_column_int_value(db_stmt_t* stmt, int col) {
return sqlite_db_column_int_value((sqlite_db_stmt_t*)stmt, col);
}
long long db_column_int64_value(db_stmt_t* stmt, int col) {
return sqlite_db_column_int64_value((sqlite_db_stmt_t*)stmt, col);
}
double db_column_double_value(db_stmt_t* stmt, int col) {
return sqlite_db_column_double_value((sqlite_db_stmt_t*)stmt, col);
}
void db_finalize_stmt(db_stmt_t* stmt) { sqlite_db_finalize_stmt((sqlite_db_stmt_t*)stmt); }
int db_log_subscription_created(const char* sub_id, const char* wsi_ptr,
const char* client_ip, const char* filter_json) {
return sqlite_db_log_subscription_created(sub_id, wsi_ptr, client_ip, filter_json);
}
int db_log_subscription_closed(const char* sub_id, const char* client_ip) {
return sqlite_db_log_subscription_closed(sub_id, client_ip);
}
int db_log_subscription_disconnected(const char* client_ip) {
return sqlite_db_log_subscription_disconnected(client_ip);
}
int db_update_subscription_events_sent(const char* sub_id, int events_sent) {
return sqlite_db_update_subscription_events_sent(sub_id, events_sent);
}
int db_cleanup_orphaned_subscriptions(void) { return sqlite_db_cleanup_orphaned_subscriptions(); }
int db_get_event_pubkey(const char* event_id, char* pubkey_out, size_t pubkey_out_size) {
return sqlite_db_get_event_pubkey(event_id, pubkey_out, pubkey_out_size);
}
int db_delete_event_by_id(const char* event_id, const char* requester_pubkey) {
return sqlite_db_delete_event_by_id(event_id, requester_pubkey);
}
int db_delete_events_by_address(const char* pubkey, int kind, const char* d_tag, long before_timestamp) {
return sqlite_db_delete_events_by_address(pubkey, kind, d_tag, before_timestamp);
}
int db_is_pubkey_blacklisted(const char* pubkey) { return sqlite_db_is_pubkey_blacklisted(pubkey); }
int db_is_hash_blacklisted(const char* resource_hash) { return sqlite_db_is_hash_blacklisted(resource_hash); }
int db_is_pubkey_whitelisted(const char* pubkey) { return sqlite_db_is_pubkey_whitelisted(pubkey); }
int db_count_active_whitelist_rules(void) { return sqlite_db_count_active_whitelist_rules(); }
int db_count_with_sql(const char* sql, const char** bind_params, int bind_param_count, int* out_count) {
return sqlite_db_count_with_sql(sql, bind_params, bind_param_count, out_count);
}
char* db_execute_readonly_query_json(const char* query, const char* request_id,
char* error_message, size_t error_size,
int max_rows, int timeout_ms) {
return sqlite_db_execute_readonly_query_json(query, request_id, error_message, error_size,
max_rows, timeout_ms);
}
int db_get_total_event_count_ll(long long* out_count) { return sqlite_db_get_total_event_count_ll(out_count); }
int db_get_event_count_since(time_t cutoff, long long* out_count) {
return sqlite_db_get_event_count_since(cutoff, out_count);
}
int db_get_storage_size_bytes(long long* out_size) {
return sqlite_db_get_storage_size_bytes(out_size);
}
cJSON* db_get_event_kind_distribution_rows(long long* out_total_events) {
return sqlite_db_get_event_kind_distribution_rows(out_total_events);
}
cJSON* db_get_top_pubkeys_rows(int limit) { return sqlite_db_get_top_pubkeys_rows(limit); }
cJSON* db_get_subscription_details_rows(void) { return sqlite_db_get_subscription_details_rows(); }
cJSON* db_get_all_config_rows(void) { return sqlite_db_get_all_config_rows(); }
char* db_get_config_value_dup(const char* key) { return sqlite_db_get_config_value_dup(key); }
int db_set_config_value_full(const char* key, const char* value, const char* data_type,
const char* description, const char* category, int requires_restart) {
return sqlite_db_set_config_value_full(key, value, data_type, description, category, requires_restart);
}
int db_update_config_value_only(const char* key, const char* value) {
return sqlite_db_update_config_value_only(key, value);
}
int db_upsert_config_value(const char* key, const char* value, const char* data_type) {
return sqlite_db_upsert_config_value(key, value, data_type);
}
int db_store_relay_private_key_hex(const char* relay_privkey_hex) {
return sqlite_db_store_relay_private_key_hex(relay_privkey_hex);
}
char* db_get_relay_private_key_hex_dup(void) { return sqlite_db_get_relay_private_key_hex_dup(); }
int db_store_config_event(const cJSON* event) { return sqlite_db_store_config_event(event); }
int db_insert_event_with_json(const char* id, const char* pubkey, long long created_at,
int kind, const char* event_type, const char* content,
const char* sig, const char* tags_json, const char* event_json,
int* out_step_rc, int* out_extended_errcode) {
return sqlite_db_insert_event_with_json(id, pubkey, created_at, kind, event_type, content,
sig, tags_json, event_json, out_step_rc, out_extended_errcode);
}
int db_get_event_time_bounds(long long* out_min_created_at, long long* out_max_created_at) {
return sqlite_db_get_event_time_bounds(out_min_created_at, out_max_created_at);
}
int db_event_id_exists(const char* event_id, int* out_exists) {
return sqlite_db_event_id_exists(event_id, out_exists);
}
cJSON* db_retrieve_event_by_id(const char* event_id) { return sqlite_db_retrieve_event_by_id(event_id); }
char* db_get_latest_event_pubkey_for_kind_dup(int kind) {
return sqlite_db_get_latest_event_pubkey_for_kind_dup(kind);
}
int db_get_config_row_count(int* out_count) { return sqlite_db_get_config_row_count(out_count); }
int db_store_event_tags_cjson(const char* event_id, const cJSON* tags) {
return sqlite_db_store_event_tags_cjson(event_id, tags);
}
int db_populate_event_tags_from_existing(void) { return sqlite_db_populate_event_tags_from_existing(); }
int db_add_auth_rule(const char* rule_type, const char* pattern_type, const char* pattern_value) {
return sqlite_db_add_auth_rule(rule_type, pattern_type, pattern_value);
}
int db_remove_auth_rule(const char* rule_type, const char* pattern_type, const char* pattern_value) {
return sqlite_db_remove_auth_rule(rule_type, pattern_type, pattern_value);
}
int db_delete_wot_whitelist_rules(void) { return sqlite_db_delete_wot_whitelist_rules(); }
int db_count_wot_whitelist_rules(void) { return sqlite_db_count_wot_whitelist_rules(); }
int db_table_exists(const char* table_name, int* out_exists) {
return sqlite_db_table_exists(table_name, out_exists);
}
char* db_get_schema_version_dup(void) { return sqlite_db_get_schema_version_dup(); }
int db_exec_sql(const char* sql) { return sqlite_db_exec_sql(sql); }
int db_wal_checkpoint_passive(void) { return sqlite_db_wal_checkpoint_passive(); }
int db_wal_checkpoint_truncate(void) { return sqlite_db_wal_checkpoint_truncate(); }
// Caching relay inbox helpers are PostgreSQL-only. The SQLite backend has no
// caching_event_inbox table, so the dispatch path returns errors/no-ops here
// rather than delegating to sqlite_db_ops stubs.
cJSON* db_caching_inbox_dequeue_batch(int batch_size, int* out_count) {
if (out_count) *out_count = 0;
(void)batch_size;
return NULL;
}
int db_caching_inbox_pending_counts(int* out_live_count, int* out_backfill_count) {
if (out_live_count) *out_live_count = 0;
if (out_backfill_count) *out_backfill_count = 0;
return DB_ERROR;
}
int db_caching_inbox_oldest_age(int* out_oldest_age_seconds) {
if (out_oldest_age_seconds) *out_oldest_age_seconds = 0;
return DB_ERROR;
}
int db_caching_backfill_author_counts(int* out_complete, int* out_total) {
if (out_complete) *out_complete = 0;
if (out_total) *out_total = 0;
return DB_ERROR;
}
int db_caching_inbox_insert(const char* event_id, const char* event_json,
const char* source_relay, const char* source_class,
int priority) {
(void)event_id; (void)event_json; (void)source_relay; (void)source_class; (void)priority;
return DB_ERROR;
}
cJSON* db_get_profile_metadata(const char* pubkey) {
/* SQLite backend: kind-0 metadata not supported in this context.
* The caching follows feature is PostgreSQL-only. */
(void)pubkey;
return NULL;
}
cJSON* db_get_outbox_relays(const char* pubkey) {
(void)pubkey;
return NULL;
}
cJSON* db_get_profile(const char* pubkey) {
/* SQLite backend: profiles cache table is PostgreSQL-only. */
(void)pubkey;
return NULL;
}
cJSON* db_get_profiles(const char** pubkeys, int count) {
(void)pubkeys;
(void)count;
return NULL;
}
#endif
-1428
View File
File diff suppressed because it is too large Load Diff
+12 -6
View File
@@ -130,6 +130,9 @@ static const struct {
// Enable only if you intend to use NIP-17 DMs to send admin commands to the relay.
{"nip17_admin_enabled", "false"},
// Profile Settings
{"store_kind_0_information", "true"}, // Extract kind-0 metadata into profiles table
// Thread Pool Settings
{"thread_pool_enabled", "true"},
{"thread_pool_readers", "4"},
@@ -138,15 +141,18 @@ static const struct {
// Caching Relay Settings
// External caching service configuration. All caching_ keys are dynamic (no restart required).
{"caching_enabled", "false"}, // Enable caching inbox consumer
{"caching_config_generation", "0"}, // Configuration generation counter for external service
{"caching_root_npubs", "npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks"}, // Comma-separated root npubs to follow (default: admin npub)
{"caching_bootstrap_relays", "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://laantungir.net/relay"}, // Comma-separated bootstrap relay URLs
{"caching_kinds", "0,1,3,6,10000,10002,30023"}, // Event kinds to cache for non-root follows
{"caching_admin_kinds", "*"}, // Event kinds for root npubs (* = all)
{"caching_live_enabled", "true"}, // Enable live subscriptions
{"caching_live_enabled", "false"}, // Enable live subscriptions
{"caching_live_strategy", "cache_all"}, // Live sub strategy: "whitelist" or "cache_all"
{"caching_live_kinds", ""}, // Live sub kinds (empty = use caching_kinds)
{"caching_live_since_seconds", "0"}, // Live sub lookback window (0 = no lookback, since=now)
{"caching_live_limit", "0"}, // Live sub max events per response (0 = no limit)
{"caching_live_resubscribe_seconds", "300"}, // Live subscription resubscribe interval
{"caching_backfill_enabled", "true"}, // Enable historical backfill
{"caching_backfill_enabled", "false"}, // Enable historical backfill
{"caching_backfill_windows", "86400,604800,2592000,7776000,31536000"}, // Backfill window schedule in seconds
{"caching_backfill_page_size", "500"}, // Initial backfill query page size
{"caching_backfill_tick_interval_ms", "5000"}, // Minimum delay between backfill queries
@@ -157,9 +163,9 @@ static const struct {
{"caching_max_upstream_relays", "32"}, // Maximum upstream relay count
{"caching_max_relays_per_pubkey", "5"}, // Maximum outbox relays per author
{"caching_query_timeout_ms", "15000"}, // Upstream query timeout
{"caching_inbox_enabled", "false"}, // Enable inbox poller in c-relay-pg
{"caching_inbox_batch_size", "150"}, // Inbox dequeue batch size
{"caching_inbox_active_poll_ms", "200"}, // Poll interval when inbox has events
{"caching_inbox_enabled", "true"}, // Enable inbox poller in c-relay-pg
{"caching_inbox_batch_size", "1000"}, // Inbox dequeue batch size
{"caching_inbox_active_poll_ms", "50"}, // Poll interval when inbox has events
{"caching_inbox_idle_poll_ms", "5000"}, // Poll interval when inbox is empty
{"caching_max_event_json_bytes", "65536"}, // Maximum event JSON size for inbox insert
{"caching_service_binary_path", "./caching_relay"}, // Path to caching_relay binary (relative to relay CWD which is build/)
+39 -69
View File
@@ -788,7 +788,7 @@ int init_database(const char* database_path_override) {
if (db_get_config_row_count(&row_count) == 0) {
DEBUG_LOG("Config table row count immediately after db_init(): %d", row_count);
} else {
DEBUG_LOG("Config table count unavailable immediately after sqlite3_open() (table may not exist yet)");
DEBUG_LOG("Config table count unavailable immediately after db_init() (table may not exist yet)");
}
}
// DEBUG_GUARD_END
@@ -908,36 +908,11 @@ int init_database(const char* database_path_override) {
DEBUG_WARN("Failed to enable WAL mode");
// Continue anyway - WAL mode is optional
} else {
DEBUG_LOG("SQLite WAL mode enabled");
DEBUG_LOG("WAL mode enabled");
}
// PostgreSQL performance tuning is handled via postgresql.conf.
// No SQLite-specific PRAGMAs are needed.
// Apply SQLite performance tuning PRAGMAs from config
// mmap_size: memory-map the database file to eliminate pread64 syscall overhead
// Default 256MB covers most relay databases; set to 0 to disable
long mmap_size = get_config_int("sqlite_mmap_size", 268435456);
if (mmap_size > 0) {
char mmap_pragma[64];
snprintf(mmap_pragma, sizeof(mmap_pragma), "PRAGMA mmap_size=%ld;", mmap_size);
if (db_exec_sql(mmap_pragma) != 0) {
DEBUG_WARN("Failed to set mmap_size");
} else {
DEBUG_LOG("SQLite mmap_size set to %ld bytes", mmap_size);
}
}
// cache_size_kb: page cache size in KB (negative value = KB, positive = number of 4KB pages)
// Default 64MB keeps hot event data in memory and reduces repeated disk reads
int cache_size_kb = get_config_int("sqlite_cache_size_kb", 65536);
if (cache_size_kb != 0) {
char cache_pragma[64];
// Use negative value so SQLite interprets it as KB rather than page count
snprintf(cache_pragma, sizeof(cache_pragma), "PRAGMA cache_size=-%d;", cache_size_kb > 0 ? cache_size_kb : -cache_size_kb);
if (db_exec_sql(cache_pragma) != 0) {
DEBUG_WARN("Failed to set cache_size");
} else {
DEBUG_LOG("SQLite cache_size set to %d KB", cache_size_kb);
}
}
DEBUG_TRACE("Exiting init_database() - success");
return 0;
@@ -948,14 +923,6 @@ void close_database() {
DEBUG_TRACE("Entering close_database()");
if (db_is_available()) {
#ifndef DB_BACKEND_POSTGRES
// Perform WAL checkpoint to minimize stale files on next startup (SQLite only)
DEBUG_LOG("Performing WAL checkpoint before database close");
if (db_exec_sql("PRAGMA wal_checkpoint(TRUNCATE);") != 0) {
DEBUG_WARN("WAL checkpoint warning");
}
#endif
db_close();
DEBUG_LOG("Database connection closed");
}
@@ -2300,7 +2267,7 @@ void print_usage(const char* program_name) {
printf(" --db-user USER PostgreSQL user (default: crelay)\n");
printf(" --db-password PASS PostgreSQL password\n");
printf(" --start-caching Auto-start the caching service on startup\n");
printf(" --reset-backfill Clear caching_backfill_progress before starting (use with --start-caching)\n");
printf(" --reset-backfill Clear caching_backfill_progress independently of --start-caching\n");
printf("\n");
printf("Configuration:\n");
printf(" This relay uses event-based configuration stored in the database.\n");
@@ -2994,38 +2961,36 @@ int main(int argc, char* argv[]) {
// Runs on the main lws service thread via caching_inbox_poller_tick().
caching_inbox_poller_init();
// Auto-start the caching service if requested via --start-caching.
// Optionally reset backfill progress first if --reset-backfill was passed.
if (cli_options.start_caching) {
// CLI flags are independent:
// --reset-backfill resets backfill progress tables/state.
// --start-caching enables and starts caching service.
#ifdef DB_BACKEND_POSTGRES
/* Ensure caching_enabled and caching_inbox_enabled are set to true
* in the config table so the UI reflects the actual state and the
* inbox poller is active. We call db_update_config_value_only()
* directly (bypassing the config-layer wrapper) so we must also
* invalidate the in-memory config cache otherwise the poller's
* get_config_bool("caching_inbox_enabled") will keep returning
* the stale pre-update value and never dequeue any events. */
db_update_config_value_only("caching_enabled", "true");
if (cli_options.reset_backfill) {
DEBUG_INFO("CLI: resetting caching backfill progress (--reset-backfill)");
/* Clear both the legacy window table and the per-relay drain table,
* and mark all followed authors as incomplete. */
int rb_ok = (db_exec_sql("DELETE FROM caching_backfill_progress") == 0);
if (rb_ok) rb_ok = (db_exec_sql("DELETE FROM caching_backfill_relay_progress") == 0);
if (rb_ok) rb_ok = (db_exec_sql(
"UPDATE caching_followed_pubkeys "
"SET backfill_complete = FALSE, until_cursor = 0, events_fetched = 0, "
" updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT") == 0);
if (rb_ok) {
DEBUG_INFO("CLI: caching backfill progress cleared (all tables reset)");
} else {
DEBUG_WARN("CLI: failed to clear caching backfill progress");
}
}
if (cli_options.start_caching) {
/* Explicit CLI start enables the external service functions and the
* main-relay inbox consumer. */
db_update_config_value_only("caching_live_enabled", "true");
db_update_config_value_only("caching_backfill_enabled", "true");
db_update_config_value_only("caching_inbox_enabled", "true");
invalidate_config_cache();
DEBUG_INFO("CLI: --start-caching: set caching_enabled=true, caching_inbox_enabled=true");
DEBUG_INFO("CLI: --start-caching: enabled live, backfill, and inbox");
if (cli_options.reset_backfill) {
DEBUG_INFO("CLI: resetting caching backfill progress (--reset-backfill)");
/* Clear both the legacy window table and the per-relay drain table,
* and mark all followed authors as incomplete. */
int rb_ok = (db_exec_sql("DELETE FROM caching_backfill_progress") == 0);
if (rb_ok) rb_ok = (db_exec_sql("DELETE FROM caching_backfill_relay_progress") == 0);
if (rb_ok) rb_ok = (db_exec_sql(
"UPDATE caching_followed_pubkeys "
"SET backfill_complete = FALSE, until_cursor = 0, events_fetched = 0, "
" updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT") == 0);
if (rb_ok) {
DEBUG_INFO("CLI: caching backfill progress cleared (all tables reset)");
} else {
DEBUG_WARN("CLI: failed to clear caching backfill progress");
}
}
DEBUG_INFO("CLI: auto-starting caching service (--start-caching)");
int caching_rc = caching_service_start();
if (caching_rc == 0) {
@@ -3033,10 +2998,15 @@ int main(int argc, char* argv[]) {
} else {
DEBUG_ERROR("CLI: failed to start caching service (rc=%d)", caching_rc);
}
#else
DEBUG_WARN("CLI: --start-caching requires PostgreSQL backend");
#endif
}
#else
if (cli_options.reset_backfill) {
DEBUG_WARN("CLI: --reset-backfill requires PostgreSQL backend");
}
if (cli_options.start_caching) {
DEBUG_WARN("CLI: --start-caching requires PostgreSQL backend");
}
#endif
// Phase 5 strict mode: remove main-thread fallback to global DB handle.
// Runtime DB access must go through thread-bound worker connections.
+2 -2
View File
@@ -13,8 +13,8 @@
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 2
#define CRELAY_VERSION_MINOR 1
#define CRELAY_VERSION_PATCH 28
#define CRELAY_VERSION "v2.1.28"
#define CRELAY_VERSION_PATCH 36
#define CRELAY_VERSION "v2.1.36"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay-PG"
+66 -158
View File
@@ -126,7 +126,6 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
" NEW.expires_at := expiration_value::BIGINT;\n"
" END IF;\n"
"\n"
"\n"
" SELECT tag->>1\n"
" INTO d_value\n"
" FROM jsonb_array_elements(NEW.tags) AS tag\n"
@@ -263,122 +262,6 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
" idle_first_failure BIGINT NOT NULL DEFAULT 0\n"
");\n"
"\n"
"-- Helper views for common queries\n"
"CREATE OR REPLACE VIEW recent_events AS\n"
"SELECT id, pubkey, created_at, kind, event_type, content\n"
"FROM events\n"
"WHERE event_type <> 'ephemeral'\n"
"ORDER BY created_at DESC\n"
"LIMIT 1000;\n"
"\n"
"CREATE OR REPLACE VIEW event_stats AS\n"
"SELECT\n"
" event_type,\n"
" COUNT(*) AS count,\n"
" AVG(char_length(content)) AS avg_content_length,\n"
" MIN(created_at) AS earliest,\n"
" MAX(created_at) AS latest\n"
"FROM events\n"
"GROUP BY event_type;\n"
"\n"
"-- Configuration events view (kind 33334)\n"
"CREATE OR REPLACE VIEW configuration_events AS\n"
"SELECT\n"
" id,\n"
" pubkey AS admin_pubkey,\n"
" created_at,\n"
" content,\n"
" tags,\n"
" sig\n"
"FROM events\n"
"WHERE kind = 33334\n"
"ORDER BY created_at DESC;\n"
"\n"
"-- View for subscription analytics\n"
"CREATE OR REPLACE VIEW subscription_analytics AS\n"
"SELECT\n"
" to_timestamp(created_at)::date AS date,\n"
" COUNT(*) AS subscriptions_created,\n"
" COUNT(CASE WHEN ended_at IS NOT NULL THEN 1 END) AS subscriptions_ended,\n"
" AVG(CASE WHEN duration IS NOT NULL THEN duration END) AS avg_duration_seconds,\n"
" MAX(events_sent) AS max_events_sent,\n"
" AVG(events_sent) AS avg_events_sent,\n"
" COUNT(DISTINCT client_ip) AS unique_clients\n"
"FROM subscriptions\n"
"GROUP BY to_timestamp(created_at)::date\n"
"ORDER BY date DESC;\n"
"\n"
"-- View for current active subscriptions (from log perspective)\n"
"CREATE OR REPLACE VIEW active_subscriptions_log AS\n"
"SELECT\n"
" subscription_id,\n"
" client_ip,\n"
" filter_json,\n"
" events_sent,\n"
" created_at,\n"
" (EXTRACT(EPOCH FROM NOW())::BIGINT - created_at) AS duration_seconds,\n"
" wsi_pointer\n"
"FROM subscriptions\n"
"WHERE event_type = 'created'\n"
" AND ended_at IS NULL;\n"
"\n"
"-- Event kinds distribution view\n"
"CREATE OR REPLACE VIEW event_kinds_view AS\n"
"SELECT\n"
" kind,\n"
" COUNT(*) AS count,\n"
" ROUND((COUNT(*) * 100.0 / NULLIF((SELECT COUNT(*) FROM events), 0))::numeric, 2) AS percentage\n"
"FROM events\n"
"GROUP BY kind\n"
"ORDER BY count DESC;\n"
"\n"
"-- Top pubkeys by event count view\n"
"CREATE OR REPLACE VIEW top_pubkeys_view AS\n"
"SELECT\n"
" pubkey,\n"
" COUNT(*) AS event_count,\n"
" ROUND((COUNT(*) * 100.0 / NULLIF((SELECT COUNT(*) FROM events), 0))::numeric, 2) AS percentage\n"
"FROM events\n"
"GROUP BY pubkey\n"
"ORDER BY event_count DESC;\n"
"\n"
"-- Time-based statistics view\n"
"CREATE OR REPLACE VIEW time_stats_view AS\n"
"SELECT\n"
" 'total' AS period,\n"
" COUNT(*) AS total_events,\n"
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
" MIN(created_at) AS oldest_event,\n"
" MAX(created_at) AS newest_event\n"
"FROM events\n"
"UNION ALL\n"
"SELECT\n"
" '24h' AS period,\n"
" COUNT(*) AS total_events,\n"
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
" MIN(created_at) AS oldest_event,\n"
" MAX(created_at) AS newest_event\n"
"FROM events\n"
"WHERE created_at >= (EXTRACT(EPOCH FROM NOW())::BIGINT - 86400)\n"
"UNION ALL\n"
"SELECT\n"
" '7d' AS period,\n"
" COUNT(*) AS total_events,\n"
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
" MIN(created_at) AS oldest_event,\n"
" MAX(created_at) AS newest_event\n"
"FROM events\n"
"WHERE created_at >= (EXTRACT(EPOCH FROM NOW())::BIGINT - 604800)\n"
"UNION ALL\n"
"SELECT\n"
" '30d' AS period,\n"
" COUNT(*) AS total_events,\n"
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
" MIN(created_at) AS oldest_event,\n"
" MAX(created_at) AS newest_event\n"
"FROM events\n"
"WHERE created_at >= (EXTRACT(EPOCH FROM NOW())::BIGINT - 2592000);\n"
"\n"
"INSERT INTO schema_info(key, value, updated_at)\n"
"VALUES ('version', '6', EXTRACT(EPOCH FROM NOW())::BIGINT)\n"
"ON CONFLICT (key) DO UPDATE SET\n"
@@ -386,6 +269,46 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
" updated_at = EXCLUDED.updated_at;\n"
"\n"
"-- =====================================================================\n"
"-- LISTEN/NOTIFY support for api-worker monitoring (Phase 5)\n"
"-- Fires a notification on the 'event_stored' channel whenever a new event\n"
"-- is inserted, so the api-worker thread can wake reactively instead of\n"
"-- polling on a timer. The payload is a small JSON object with kind and a\n"
"-- truncated pubkey prefix (kept tiny to minimize per-insert overhead).\n"
"-- =====================================================================\n"
"CREATE OR REPLACE FUNCTION notify_event_stored() RETURNS trigger AS $$\n"
"BEGIN\n"
" PERFORM pg_notify('event_stored', json_build_object(\n"
" 'kind', NEW.kind,\n"
" 'pubkey', substring(NEW.pubkey, 1, 8)\n"
" )::text);\n"
" RETURN NEW;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
"\n"
"DROP TRIGGER IF EXISTS trg_notify_event_stored ON events;\n"
"CREATE TRIGGER trg_notify_event_stored\n"
" AFTER INSERT ON events\n"
" FOR EACH ROW EXECUTE FUNCTION notify_event_stored();\n"
"\n"
"-- =====================================================================\n"
"-- Config change notification: fires pg_notify('config_changed', key)\n"
"-- whenever a config row is updated, so the relay can react to\n"
"-- caching_enabled / caching_inbox_enabled toggles from the PHP admin\n"
"-- without polling.\n"
"-- =====================================================================\n"
"CREATE OR REPLACE FUNCTION notify_config_changed() RETURNS trigger AS $$\n"
"BEGIN\n"
" PERFORM pg_notify('config_changed', NEW.key);\n"
" RETURN NEW;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
"\n"
"DROP TRIGGER IF EXISTS trg_notify_config_changed ON config;\n"
"CREATE TRIGGER trg_notify_config_changed\n"
" AFTER UPDATE ON config\n"
" FOR EACH ROW EXECUTE FUNCTION notify_config_changed();\n"
"\n"
"-- =====================================================================\n"
"-- Caching relay integration tables\n"
"-- These tables are written by an external caching application and\n"
"-- consumed by c-relay-pg. They are schema-only here; the database\n"
@@ -508,7 +431,6 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
"CREATE INDEX IF NOT EXISTS idx_caching_relay_progress_incomplete\n"
" ON caching_backfill_relay_progress(author_pubkey)\n"
" WHERE complete = FALSE;\n"
"\n"
"-- Replace window-based progress columns with author-based progress.\n"
"ALTER TABLE caching_service_state\n"
" DROP COLUMN IF EXISTS current_window_index,\n"
@@ -516,45 +438,23 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
" ADD COLUMN IF NOT EXISTS backfill_authors_complete INTEGER NOT NULL DEFAULT 0,\n"
" ADD COLUMN IF NOT EXISTS backfill_authors_total INTEGER NOT NULL DEFAULT 0;\n"
"\n"
"-- =====================================================================\n"
"-- LISTEN/NOTIFY support for api-worker monitoring (Phase 5)\n"
"-- Fires a notification on the 'event_stored' channel whenever a new event\n"
"-- is inserted, so the api-worker thread can wake reactively instead of\n"
"-- polling on a timer. The payload is a small JSON object with kind and a\n"
"-- truncated pubkey prefix (kept tiny to minimize per-insert overhead).\n"
"-- =====================================================================\n"
"CREATE OR REPLACE FUNCTION notify_event_stored() RETURNS trigger AS $$\n"
"BEGIN\n"
" PERFORM pg_notify('event_stored', json_build_object(\n"
" 'kind', NEW.kind,\n"
" 'pubkey', substring(NEW.pubkey, 1, 8)\n"
" )::text);\n"
" RETURN NEW;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
"-- Unified relay table: replaces caching_upstream_relays, caching_bootstrap_relays\n"
"-- config key, and caching_live_relays config key. Each relay has independent\n"
"-- live_enabled and backfill_enabled flags. The caching service connects to any\n"
"-- relay where either flag is true. Status is updated by the caching service\n"
"-- heartbeat. Follow count is computed from caching_backfill_relay_progress.\n"
"CREATE TABLE IF NOT EXISTS caching_relays (\n"
" relay_url TEXT PRIMARY KEY,\n"
" live_enabled BOOLEAN NOT NULL DEFAULT FALSE,\n"
" backfill_enabled BOOLEAN NOT NULL DEFAULT FALSE,\n"
" status_code INT NOT NULL DEFAULT 0,\n"
" status_text TEXT NOT NULL DEFAULT '',\n"
" follow_count INT NOT NULL DEFAULT 0,\n"
" is_bootstrap BOOLEAN NOT NULL DEFAULT FALSE,\n"
" created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
");\n"
"\n"
"DROP TRIGGER IF EXISTS trg_notify_event_stored ON events;\n"
"CREATE TRIGGER trg_notify_event_stored\n"
" AFTER INSERT ON events\n"
" FOR EACH ROW EXECUTE FUNCTION notify_event_stored();\n"
"\n"
"-- =====================================================================\n"
"-- Config change notification: fires pg_notify('config_changed', key)\n"
"-- whenever a config row is updated, so the relay can react to\n"
"-- caching_enabled / caching_inbox_enabled toggles from the PHP admin\n"
"-- without polling.\n"
"-- =====================================================================\n"
"CREATE OR REPLACE FUNCTION notify_config_changed() RETURNS trigger AS $$\n"
"BEGIN\n"
" PERFORM pg_notify('config_changed', NEW.key);\n"
" RETURN NEW;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
"\n"
"DROP TRIGGER IF EXISTS trg_notify_config_changed ON config;\n"
"CREATE TRIGGER trg_notify_config_changed\n"
" AFTER UPDATE ON config\n"
" FOR EACH ROW EXECUTE FUNCTION notify_config_changed();\n"
"\n"
"-- =====================================================================\n"
"-- Profile cache (kind-0 metadata projection)\n"
@@ -637,16 +537,24 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
"ON CONFLICT (key) DO NOTHING;\n"
"\n"
"-- Sync profiles row on kind-0 INSERT or UPDATE of content.\n"
"-- Gated by store_kind_0_information config setting (default: true).\n"
"-- Early-returns for non-kind-0 events (cost: one integer comparison).\n"
"-- Guards against an older kind-0 overwriting a newer one.\n"
"CREATE OR REPLACE FUNCTION sync_profile_from_event() RETURNS TRIGGER AS $$\n"
"DECLARE\n"
" j jsonb;\n"
" store_enabled text;\n"
"BEGIN\n"
" IF NEW.kind <> 0 THEN\n"
" RETURN NEW;\n"
" END IF;\n"
"\n"
" -- Check the config setting; skip if disabled.\n"
" SELECT value INTO store_enabled FROM config WHERE key = 'store_kind_0_information';\n"
" IF store_enabled IS NULL OR store_enabled <> 'true' THEN\n"
" RETURN NEW;\n"
" END IF;\n"
"\n"
" j := safe_jsonb(NEW.content);\n"
"\n"
" INSERT INTO profiles (\n"
@@ -744,6 +652,6 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
"$$;\n"
"\n"
"COMMIT;\n"
;
"";
#endif // PG_SCHEMA_H
#endif /* PG_SCHEMA_H */
+26 -1
View File
@@ -425,7 +425,6 @@ CREATE TABLE IF NOT EXISTS caching_backfill_relay_progress (
CREATE INDEX IF NOT EXISTS idx_caching_relay_progress_incomplete
ON caching_backfill_relay_progress(author_pubkey)
WHERE complete = FALSE;
-- Replace window-based progress columns with author-based progress.
ALTER TABLE caching_service_state
DROP COLUMN IF EXISTS current_window_index,
@@ -433,6 +432,24 @@ ALTER TABLE caching_service_state
ADD COLUMN IF NOT EXISTS backfill_authors_complete INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS backfill_authors_total INTEGER NOT NULL DEFAULT 0;
-- Unified relay table: replaces caching_upstream_relays, caching_bootstrap_relays
-- config key, and caching_live_relays config key. Each relay has independent
-- live_enabled and backfill_enabled flags. The caching service connects to any
-- relay where either flag is true. Status is updated by the caching service
-- heartbeat. Follow count is computed from caching_backfill_relay_progress.
CREATE TABLE IF NOT EXISTS caching_relays (
relay_url TEXT PRIMARY KEY,
live_enabled BOOLEAN NOT NULL DEFAULT FALSE,
backfill_enabled BOOLEAN NOT NULL DEFAULT FALSE,
status_code INT NOT NULL DEFAULT 0,
status_text TEXT NOT NULL DEFAULT '',
follow_count INT NOT NULL DEFAULT 0,
is_bootstrap BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
);
-- =====================================================================
-- Profile cache (kind-0 metadata projection)
-- One row per pubkey, kept in sync with the latest kind-0 event via
@@ -514,16 +531,24 @@ VALUES ('profile_name_preference', 'display_name',
ON CONFLICT (key) DO NOTHING;
-- Sync profiles row on kind-0 INSERT or UPDATE of content.
-- Gated by store_kind_0_information config setting (default: true).
-- Early-returns for non-kind-0 events (cost: one integer comparison).
-- Guards against an older kind-0 overwriting a newer one.
CREATE OR REPLACE FUNCTION sync_profile_from_event() RETURNS TRIGGER AS $$
DECLARE
j jsonb;
store_enabled text;
BEGIN
IF NEW.kind <> 0 THEN
RETURN NEW;
END IF;
-- Check the config setting; skip if disabled.
SELECT value INTO store_enabled FROM config WHERE key = 'store_kind_0_information';
IF store_enabled IS NULL OR store_enabled <> 'true' THEN
RETURN NEW;
END IF;
j := safe_jsonb(NEW.content);
INSERT INTO profiles (
-99
View File
@@ -1,99 +0,0 @@
#ifndef SQLITE_DB_OPS_H
#define SQLITE_DB_OPS_H
#include "db_ops.h"
typedef struct sqlite_db_stmt sqlite_db_stmt_t;
int sqlite_db_init(const char* connection_string);
void sqlite_db_close(void);
int sqlite_db_is_available(void);
const char* sqlite_db_last_error(void);
const char* sqlite_db_get_database_path(void);
int sqlite_db_set_thread_connection(void* connection);
void sqlite_db_clear_thread_connection(void);
int sqlite_db_open_worker_connection(const char* db_path, void** out_connection);
void sqlite_db_close_worker_connection(void* connection);
int sqlite_db_worker_listen(void* connection, const char* channel);
int sqlite_db_worker_poll_notify(void* connection, int timeout_ms);
int sqlite_db_prepare(const char* sql, sqlite_db_stmt_t** out_stmt);
int sqlite_db_bind_text_param(sqlite_db_stmt_t* stmt, int index, const char* value);
int sqlite_db_bind_int_param(sqlite_db_stmt_t* stmt, int index, int value);
int sqlite_db_bind_int64_param(sqlite_db_stmt_t* stmt, int index, long long value);
int sqlite_db_step_stmt(sqlite_db_stmt_t* stmt);
int sqlite_db_reset_stmt(sqlite_db_stmt_t* stmt);
const char* sqlite_db_column_text_value(sqlite_db_stmt_t* stmt, int col);
int sqlite_db_column_int_value(sqlite_db_stmt_t* stmt, int col);
long long sqlite_db_column_int64_value(sqlite_db_stmt_t* stmt, int col);
double sqlite_db_column_double_value(sqlite_db_stmt_t* stmt, int col);
void sqlite_db_finalize_stmt(sqlite_db_stmt_t* stmt);
int sqlite_db_log_subscription_created(const char* sub_id, const char* wsi_ptr,
const char* client_ip, const char* filter_json);
int sqlite_db_log_subscription_closed(const char* sub_id, const char* client_ip);
int sqlite_db_log_subscription_disconnected(const char* client_ip);
int sqlite_db_update_subscription_events_sent(const char* sub_id, int events_sent);
int sqlite_db_cleanup_orphaned_subscriptions(void);
int sqlite_db_get_event_pubkey(const char* event_id, char* pubkey_out, size_t pubkey_out_size);
int sqlite_db_delete_event_by_id(const char* event_id, const char* requester_pubkey);
int sqlite_db_delete_events_by_address(const char* pubkey, int kind,
const char* d_tag, long before_timestamp);
int sqlite_db_is_pubkey_blacklisted(const char* pubkey);
int sqlite_db_is_hash_blacklisted(const char* resource_hash);
int sqlite_db_is_pubkey_whitelisted(const char* pubkey);
int sqlite_db_count_active_whitelist_rules(void);
int sqlite_db_count_with_sql(const char* sql, const char** bind_params, int bind_param_count, int* out_count);
char* sqlite_db_execute_readonly_query_json(const char* query, const char* request_id,
char* error_message, size_t error_size,
int max_rows, int timeout_ms);
int sqlite_db_get_total_event_count_ll(long long* out_count);
int sqlite_db_get_event_count_since(time_t cutoff, long long* out_count);
int sqlite_db_get_storage_size_bytes(long long* out_size);
cJSON* sqlite_db_get_event_kind_distribution_rows(long long* out_total_events);
cJSON* sqlite_db_get_top_pubkeys_rows(int limit);
cJSON* sqlite_db_get_subscription_details_rows(void);
cJSON* sqlite_db_get_all_config_rows(void);
char* sqlite_db_get_config_value_dup(const char* key);
int sqlite_db_set_config_value_full(const char* key, const char* value, const char* data_type,
const char* description, const char* category, int requires_restart);
int sqlite_db_update_config_value_only(const char* key, const char* value);
int sqlite_db_upsert_config_value(const char* key, const char* value, const char* data_type);
int sqlite_db_store_relay_private_key_hex(const char* relay_privkey_hex);
char* sqlite_db_get_relay_private_key_hex_dup(void);
int sqlite_db_store_config_event(const cJSON* event);
int sqlite_db_insert_event_with_json(const char* id, const char* pubkey, long long created_at,
int kind, const char* event_type, const char* content,
const char* sig, const char* tags_json, const char* event_json,
int* out_step_rc, int* out_extended_errcode);
int sqlite_db_get_event_time_bounds(long long* out_min_created_at, long long* out_max_created_at);
int sqlite_db_event_id_exists(const char* event_id, int* out_exists);
cJSON* sqlite_db_retrieve_event_by_id(const char* event_id);
char* sqlite_db_get_latest_event_pubkey_for_kind_dup(int kind);
int sqlite_db_get_config_row_count(int* out_count);
int sqlite_db_store_event_tags_cjson(const char* event_id, const cJSON* tags);
int sqlite_db_populate_event_tags_from_existing(void);
int sqlite_db_add_auth_rule(const char* rule_type, const char* pattern_type, const char* pattern_value);
int sqlite_db_remove_auth_rule(const char* rule_type, const char* pattern_type, const char* pattern_value);
int sqlite_db_delete_wot_whitelist_rules(void);
int sqlite_db_count_wot_whitelist_rules(void);
int sqlite_db_table_exists(const char* table_name, int* out_exists);
char* sqlite_db_get_schema_version_dup(void);
int sqlite_db_exec_sql(const char* sql);
int sqlite_db_wal_checkpoint_passive(void);
int sqlite_db_wal_checkpoint_truncate(void);
#endif // SQLITE_DB_OPS_H
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=pgweb - PostgreSQL web UI
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=user
ExecStart=/usr/local/bin/pgweb --url "postgres://crelay:crelay@localhost:5432/crelay?sslmode=disable" --listen 8091
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+120 -26
View File
@@ -442,32 +442,126 @@ run_comprehensive_test() {
# Check what's actually stored in the database
print_step "Verifying database contents..."
if command -v sqlite3 &> /dev/null; then
# Find the database file (should be in build/ directory with relay pubkey as filename)
local db_file=""
if [[ -d "../build" ]]; then
db_file=$(find ../build -name "*.db" -type f | head -1)
fi
if [[ -n "$db_file" && -f "$db_file" ]]; then
print_info "Events by type in database ($db_file):"
sqlite3 "$db_file" "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null | while read line; do
echo " $line"
done
print_info "Recent events in database:"
sqlite3 "$db_file" "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null | while read line; do
echo " $line"
done
print_success "Database verification complete"
else
print_warning "Database file not found in build/ directory"
print_info "Expected database files: build/*.db (named after relay pubkey)"
fi
else
print_warning "sqlite3 not available for database verification"
fi
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Recent events in database:"
psql -d crelay -c "SELECT substr(id, 1, 16) || '...' as short_id, event_type, kind, substr(content, 1, 30) || '...' as short_content FROM events ORDER BY created_at DESC LIMIT 5;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
return 0
}
+113 -25
View File
@@ -425,31 +425,119 @@ run_count_test() {
# Check what's actually stored in the database
print_step "Verifying database contents..."
if command -v sqlite3 &> /dev/null; then
# Find the database file (should be in build/ directory with relay pubkey as filename)
local db_file=""
if [[ -d "../build" ]]; then
db_file=$(find ../build -name "*.db" -type f | head -1)
fi
if [[ -n "$db_file" && -f "$db_file" ]]; then
print_info "Events by type in database ($db_file):"
sqlite3 "$db_file" "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null | while read line; do
echo " $line"
done
print_info "Total events in database:"
sqlite3 "$db_file" "SELECT COUNT(*) FROM events;" 2>/dev/null
print_success "Database verification complete"
else
print_warning "Database file not found in build/ directory"
print_info "Expected database files: build/*.db (named after relay pubkey)"
fi
else
print_warning "sqlite3 not available for database verification"
fi
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
print_info "Events by type in database:"
psql -d crelay -c "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null || print_warning "Could not query database"
print_info "Total events in database:"
psql -d crelay -c "SELECT COUNT(*) FROM events;" 2>/dev/null || print_warning "Could not query database"
print_success "Database verification complete"
return 0
}
+2 -2
View File
@@ -41,7 +41,7 @@ fi
# Check current event count in database
DB_FILE=$(ls build/*.db 2>/dev/null | head -1)
if [ -n "$DB_FILE" ]; then
CURRENT_COUNT=$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM events WHERE kind=1;" 2>/dev/null || echo "0")
CURRENT_COUNT=$(psql -d crelay "SELECT COUNT(*) FROM events WHERE kind=1;" 2>/dev/null || echo "0")
echo "Current kind 1 events in database: $CURRENT_COUNT"
if [ "$CURRENT_COUNT" -ge "$NUM_EVENTS" ]; then
@@ -254,7 +254,7 @@ echo " - Per-event overhead: ~0.02ms (vs 50ms before)"
echo ""
if [ -n "$DB_FILE" ]; then
FINAL_COUNT=$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM events WHERE kind=1;" 2>/dev/null || echo "0")
FINAL_COUNT=$(psql -d crelay "SELECT COUNT(*) FROM events WHERE kind=1;" 2>/dev/null || echo "0")
echo "Final database stats:"
echo " Total kind 1 events: $FINAL_COUNT"
echo " Database file: $DB_FILE"
+1 -1
View File
@@ -60,4 +60,4 @@ echo "To view logs in real-time:"
echo " tail -f relay.log | grep -E '(partial|write completed|Invalid frame)'"
echo ""
echo "To check if events were stored:"
echo " sqlite3 build/*.db 'SELECT id, length(content) as content_size FROM events ORDER BY created_at DESC LIMIT 4;'"
echo " psql -d crelay 'SELECT id, length(content) as content_size FROM events ORDER BY created_at DESC LIMIT 4;'"
+162
View File
@@ -0,0 +1,162 @@
#!/bin/bash
# Bulk seed the relay with ~1000 test events for the Cleanup page.
# Generates events across multiple kinds, authors, and age ranges.
#
# Usage: ./tests/seed_cleanup_test_events.sh [relay_url]
# Default relay: ws://127.0.0.1:8888
RELAY="${1:-ws://127.0.0.1:8888}"
ADMIN_SK="1111111111111111111111111111111111111111111111111111111111111111"
# Pre-generate 10 random author keys for "non-followed" variety
RANDOM_SKS=()
echo "Generating random author keys..."
for i in $(seq 1 10); do
RANDOM_SKS+=("$(nak key generate 2>/dev/null | head -1)")
done
NOW=$(date +%s)
POSTED=0
FAILED=0
# ── Post a single event ─────────────────────────────────────────────────
post_event() {
local kind="$1"
local content="$2"
local created_at="$3"
local author_sk="$4"
nak event -c "$content" --kind "$kind" --sec "$author_sk" --created-at "$created_at" "$RELAY" >/dev/null 2>&1
if [ $? -eq 0 ]; then
POSTED=$((POSTED + 1))
else
FAILED=$((FAILED + 1))
fi
}
# ── Post N events in a loop ─────────────────────────────────────────────
# Usage: bulk_post <kind> <content_prefix> <count> <age_range_days> [author_sk]
# age_range_days: events will be spread from 0 to N days old
bulk_post() {
local kind="$1"
local prefix="$2"
local count="$3"
local age_range="$4"
local author_sk="${5:-$ADMIN_SK}"
for i in $(seq 1 "$count"); do
local age_seconds=$(( (i * age_range * 86400) / count ))
local ts=$(( NOW - age_seconds ))
post_event "$kind" "${prefix} #${i}" "$ts" "$author_sk"
done
}
# ── Post N events with random author cycling ────────────────────────────
bulk_post_random() {
local kind="$1"
local prefix="$2"
local count="$3"
local age_range="$4"
for i in $(seq 1 "$count"); do
local author_sk="${RANDOM_SKS[$(( (i - 1) % ${#RANDOM_SKS[@]} ))]}"
local age_seconds=$(( (i * age_range * 86400) / count ))
local ts=$(( NOW - age_seconds ))
post_event "$kind" "${prefix} #${i}" "$ts" "$author_sk"
done
}
echo "=== Seeding ~1000 test events to $RELAY ==="
echo ""
# ═══════════════════════════════════════════════════════════════════════
# ADMIN (FOLLOWED) EVENTS
# ═══════════════════════════════════════════════════════════════════════
echo "--- Admin (followed) events ---"
# Kind 1 (text notes) — 100 events spread over 365 days
bulk_post 1 "Admin text note" 100 365
# Kind 7 (reactions) — 50 events spread over 180 days
bulk_post 7 "+" 50 180
# Kind 9734 (zap requests) — 30 events spread over 90 days
bulk_post 9734 "Zap request" 30 90
# Kind 30023 (long-form articles) — 20 events spread over 365 days
bulk_post 30023 "Long form article content for testing the cleanup page with some substantial text to make the events larger and more realistic" 20 365
# Kind 3 (contact lists) — 10 events spread over 365 days
bulk_post 3 '{"contacts":[]}' 10 365
# Kind 0 (profiles) — 5 events spread over 365 days
for i in $(seq 1 5); do
ts=$(( NOW - (i * 365 * 86400 / 5) ))
post_event 0 "{\"name\":\"admin_$i\",\"display_name\":\"Admin User $i\",\"about\":\"Test profile $i\"}" "$ts"
done
echo " → Admin done: $POSTED posted, $FAILED failed"
echo ""
# ═══════════════════════════════════════════════════════════════════════
# RANDOM (NON-FOLLOWED) EVENTS — bulk of the data
# ═══════════════════════════════════════════════════════════════════════
echo "--- Random (non-followed) events ---"
# Kind 1 (text notes) — 400 events spread over 365 days, 10 different authors
bulk_post_random 1 "Random text note" 400 365
# Kind 7 (reactions) — 150 events spread over 180 days
bulk_post_random 7 "+" 150 180
# Kind 9734 (zap requests) — 80 events spread over 90 days
bulk_post_random 9734 "Zap request" 80 90
# Kind 30023 (long-form articles) — 50 events spread over 365 days
bulk_post_random 30023 "Long form article from random user with some substantial content for testing the cleanup page event deletion functionality" 50 365
# Kind 3 (contact lists) — 30 events spread over 365 days
bulk_post_random 3 '{"contacts":[]}' 30 365
# Kind 0 (profiles) — 20 events spread over 365 days
for i in $(seq 1 20); do
author_sk="${RANDOM_SKS[$(( (i - 1) % ${#RANDOM_SKS[@]} ))]}"
ts=$(( NOW - (i * 365 * 86400 / 20) ))
post_event 0 "{\"name\":\"random_$i\",\"display_name\":\"Random User $i\",\"about\":\"Test profile $i\"}" "$ts" "$author_sk"
done
# Kind 6 (reposts) — 30 events spread over 60 days
bulk_post_random 6 "Repost content" 30 60
# Kind 9735 (zap receipt) — 20 events spread over 30 days
bulk_post_random 9735 "Zap receipt" 20 30
echo " → Random done: $POSTED posted, $FAILED failed"
echo ""
# ═══════════════════════════════════════════════════════════════════════
# SUMMARY
# ═══════════════════════════════════════════════════════════════════════
echo "=== Done! ==="
echo " Total posted: $POSTED"
echo " Total failed: $FAILED"
echo ""
echo "Now open http://127.0.0.1:8088/index.php and click 'Cleanup' in the nav."
echo ""
echo "Suggested tests:"
echo " 1. Preview: non-follows, kinds=1, max_age=90"
echo " → should match random kind-1 events older than 90 days"
echo " 2. Preview: non-follows, kinds=1,7, max_age=30"
echo " → should match random kind-1/7 events older than 30 days"
echo " 3. Preview: follows, kinds=1, max_age=90"
echo " → should match admin kind-1 events older than 90 days"
echo " 4. Preview: all, kinds=0"
echo " → should match all kind-0 (profiles)"
echo " 5. Preview: non-follows, kinds=30023, max_age=30"
echo " → should match old random long-form articles"
echo " 6. Save a query, then execute it"
echo " 7. Check the kind breakdown to see size distribution"
+3 -3
View File
@@ -89,7 +89,7 @@ get_subscription_count() {
return
fi
sqlite3 "$db_file" "SELECT COUNT(*) FROM subscriptions WHERE event_type='created' AND ended_at IS NULL;" 2>/dev/null || echo "0"
psql -d crelay "SELECT COUNT(*) FROM subscriptions WHERE event_type='created' AND ended_at IS NULL;" 2>/dev/null || echo "0"
}
# Test 1: Basic Connectivity
@@ -260,14 +260,14 @@ echo "[INFO] Checking database integrity..."
db_file=$(find . -name "*.db" -type f 2>/dev/null | head -1)
if [ -n "$db_file" ]; then
# Check if database is accessible
if sqlite3 "$db_file" "PRAGMA integrity_check;" 2>/dev/null | grep -q "ok"; then
if psql -d crelay "PRAGMA integrity_check;" 2>/dev/null | grep -q "ok"; then
print_result "PASS" "Database integrity check passed"
else
print_result "FAIL" "Database integrity check failed"
fi
# Check subscription table structure
if sqlite3 "$db_file" "SELECT COUNT(*) FROM subscriptions;" &>/dev/null; then
if psql -d crelay "SELECT COUNT(*) FROM subscriptions;" &>/dev/null; then
print_result "PASS" "Subscription table is accessible"
else
print_result "FAIL" "Subscription table is not accessible"