Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca7a4b6722 | ||
|
|
aa07fe0edd | ||
|
|
67874636d2 | ||
|
|
c316c39860 | ||
|
|
a4197dab4b | ||
|
|
8453d82ee4 | ||
|
|
8c6770c7e2 | ||
|
|
7cd33aa6ea |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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 += ` | <strong>Estimated size:</strong> ${d.total_size_human}`;
|
||||
}
|
||||
if (d.avg_size_per_event) {
|
||||
html += ` | <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 += ` | <strong>Freed:</strong> ${d.freed_human}`;
|
||||
if (d.duration_ms) html += ` | <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
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+11
-11
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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}]
|
||||
Vendored
+1
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 ·
|
||||
<a href="wss://<?= e($_SERVER['HTTP_HOST'] ?? '') ?>">wss://<?= e($_SERVER['HTTP_HOST'] ?? '') ?></a>
|
||||
· 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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;'"
|
||||
Executable
+162
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user