mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-05-18 15:26:00 +02:00
28255261fb
NODE_CLEANUP_DAYS source of truth in Pydantic Settings is 30, not 7. Fixed in README, .env.example, docker-compose.yml, and docker-source-guide.md. Also added missing OIDC_POST_LOGOUT_REDIRECT_URI, WEB_AUTO_REFRESH_SECONDS, NETWORK_DOMAIN to docker-compose.yml web service. Added WEB_LOCALE and WEB_DATETIME_LOCALE to AGENTS.md env vars list.
376 lines
15 KiB
YAML
376 lines
15 KiB
YAML
services:
|
|
# ==========================================================================
|
|
# MQTT Broker - MeshCore MQTT Broker (optional, use --profile mqtt)
|
|
# WebSocket-only broker with MeshCore public key authentication
|
|
# Most users will connect to an external MQTT broker instead
|
|
# See: https://github.com/michaelhart/meshcore-mqtt-broker
|
|
# ==========================================================================
|
|
mqtt:
|
|
image: ghcr.io/ipnet-mesh/meshcore-mqtt-broker:latest
|
|
container_name: ${COMPOSE_PROJECT_NAME:-hub}-mqtt
|
|
profiles:
|
|
- all
|
|
- mqtt
|
|
restart: unless-stopped
|
|
volumes:
|
|
- mqtt_data:/data
|
|
environment:
|
|
# Broker listener
|
|
- MQTT_WS_PORT=${MQTT_PORT:-1883}
|
|
- MQTT_HOST=0.0.0.0
|
|
# JWT audience validation
|
|
- AUTH_EXPECTED_AUDIENCE=${MQTT_TOKEN_AUDIENCE:-mqtt.localhost}
|
|
# Subscriber accounts (hub connects as role 1 = admin for /internal topics)
|
|
- SUBSCRIBER_MAX_CONNECTIONS_DEFAULT=5
|
|
- SUBSCRIBER_1=${MQTT_USERNAME:-admin}:${MQTT_PASSWORD:-admin}:1
|
|
# Abuse detection
|
|
- ABUSE_ENFORCEMENT_ENABLED=false
|
|
- ABUSE_DUPLICATE_WINDOW_SIZE=100
|
|
- ABUSE_DUPLICATE_WINDOW_MS=300000
|
|
- ABUSE_DUPLICATE_THRESHOLD=10
|
|
- ABUSE_MAX_DUPLICATES_PER_PACKET=5
|
|
- ABUSE_DUPLICATE_RATE_THRESHOLD=0.3
|
|
- ABUSE_DUPLICATE_RATE_WINDOW_MS=300000
|
|
- ABUSE_BUCKET_CAPACITY=20
|
|
- ABUSE_BUCKET_REFILL_RATE=3
|
|
- ABUSE_MAX_PACKET_SIZE=255
|
|
- ABUSE_MAX_TOPICS_PER_DAY=3
|
|
- ABUSE_ANOMALY_THRESHOLD=10
|
|
- ABUSE_MAX_IATA_CHANGES_24H=3
|
|
- ABUSE_TOPIC_HISTORY_SIZE=50
|
|
- ABUSE_TOPIC_HISTORY_WINDOW_MS=86400000
|
|
- ABUSE_PERSISTENCE_PATH=/data/abuse-detection.db
|
|
- ABUSE_PERSISTENCE_INTERVAL_MS=300000
|
|
healthcheck:
|
|
test:
|
|
[
|
|
"CMD",
|
|
"node",
|
|
"-e",
|
|
"const net=require('net');const s=net.createConnection(process.env.MQTT_WS_PORT||1883,'127.0.0.1',()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)",
|
|
]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 10s
|
|
|
|
# ==========================================================================
|
|
# Observer - MeshCore packet capture to MQTT (serial only)
|
|
# Uses ghcr.io/agessaman/meshcore-packet-capture (separate project)
|
|
# Publishes to local MQTT (broker 3) for the hub collector to ingest.
|
|
# Optionally publish to Let's Mesh (brokers 1 & 2) for cloud map integration.
|
|
# ==========================================================================
|
|
observer:
|
|
image: ghcr.io/agessaman/meshcore-packet-capture:${PACKETCAPTURE_IMAGE_VERSION:-latest}
|
|
container_name: ${COMPOSE_PROJECT_NAME:-hub}-observer
|
|
profiles:
|
|
- all
|
|
- observer
|
|
depends_on:
|
|
mqtt:
|
|
condition: service_healthy
|
|
restart: unless-stopped
|
|
devices:
|
|
- "${SERIAL_PORT:-/dev/ttyUSB0}:${SERIAL_PORT:-/dev/ttyUSB0}"
|
|
user: root
|
|
environment:
|
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
|
# Connection (serial only)
|
|
- PACKETCAPTURE_CONNECTION_TYPE=serial
|
|
- PACKETCAPTURE_SERIAL_PORTS=${SERIAL_PORT:-/dev/ttyUSB0}
|
|
- PACKETCAPTURE_TIMEOUT=${PACKETCAPTURE_TIMEOUT:-30}
|
|
- PACKETCAPTURE_MAX_CONNECTION_RETRIES=${PACKETCAPTURE_MAX_CONNECTION_RETRIES:-5}
|
|
- PACKETCAPTURE_CONNECTION_RETRY_DELAY=${PACKETCAPTURE_CONNECTION_RETRY_DELAY:-5}
|
|
- PACKETCAPTURE_HEALTH_CHECK_INTERVAL=${PACKETCAPTURE_HEALTH_CHECK_INTERVAL:-30}
|
|
# Device
|
|
- PACKETCAPTURE_IATA=${PACKETCAPTURE_IATA:-LOC}
|
|
- PACKETCAPTURE_ORIGIN=${PACKETCAPTURE_ORIGIN:-}
|
|
- PACKETCAPTURE_ADVERT_INTERVAL_HOURS=${PACKETCAPTURE_ADVERT_INTERVAL_HOURS:-11}
|
|
- PACKETCAPTURE_RF_DATA_TIMEOUT=${PACKETCAPTURE_RF_DATA_TIMEOUT:-15.0}
|
|
# MQTT Broker 1 - Let's Mesh US (opt-in)
|
|
- PACKETCAPTURE_MQTT1_ENABLED=${PACKETCAPTURE_MQTT1_ENABLED:-false}
|
|
- PACKETCAPTURE_MQTT1_SERVER=${PACKETCAPTURE_MQTT1_SERVER:-mqtt-us-v1.letsmesh.net}
|
|
- PACKETCAPTURE_MQTT1_PORT=${PACKETCAPTURE_MQTT1_PORT:-443}
|
|
- PACKETCAPTURE_MQTT1_TRANSPORT=websockets
|
|
- PACKETCAPTURE_MQTT1_USE_TLS=${PACKETCAPTURE_MQTT1_USE_TLS:-true}
|
|
- PACKETCAPTURE_MQTT1_USE_AUTH_TOKEN=${PACKETCAPTURE_MQTT1_USE_AUTH_TOKEN:-true}
|
|
- PACKETCAPTURE_MQTT1_TOKEN_AUDIENCE=${PACKETCAPTURE_MQTT1_TOKEN_AUDIENCE:-mqtt-us-v1.letsmesh.net}
|
|
- PACKETCAPTURE_MQTT1_KEEPALIVE=${PACKETCAPTURE_MQTT1_KEEPALIVE:-120}
|
|
# MQTT Broker 2 - Let's Mesh EU (opt-in)
|
|
- PACKETCAPTURE_MQTT2_ENABLED=${PACKETCAPTURE_MQTT2_ENABLED:-false}
|
|
- PACKETCAPTURE_MQTT2_SERVER=${PACKETCAPTURE_MQTT2_SERVER:-mqtt-eu-v1.letsmesh.net}
|
|
- PACKETCAPTURE_MQTT2_PORT=${PACKETCAPTURE_MQTT2_PORT:-443}
|
|
- PACKETCAPTURE_MQTT2_TRANSPORT=websockets
|
|
- PACKETCAPTURE_MQTT2_USE_TLS=${PACKETCAPTURE_MQTT2_USE_TLS:-true}
|
|
- PACKETCAPTURE_MQTT2_USE_AUTH_TOKEN=${PACKETCAPTURE_MQTT2_USE_AUTH_TOKEN:-true}
|
|
- PACKETCAPTURE_MQTT2_TOKEN_AUDIENCE=${PACKETCAPTURE_MQTT2_TOKEN_AUDIENCE:-mqtt-eu-v1.letsmesh.net}
|
|
- PACKETCAPTURE_MQTT2_KEEPALIVE=${PACKETCAPTURE_MQTT2_KEEPALIVE:-120}
|
|
# MQTT Broker 3 - Local MQTT (wired to hub's MQTT_* vars)
|
|
- PACKETCAPTURE_MQTT3_ENABLED=${PACKETCAPTURE_MQTT3_ENABLED:-true}
|
|
- PACKETCAPTURE_MQTT3_SERVER=${MQTT_HOST}
|
|
- PACKETCAPTURE_MQTT3_PORT=${MQTT_PORT}
|
|
- PACKETCAPTURE_MQTT3_USERNAME=${MQTT_USERNAME:-}
|
|
- PACKETCAPTURE_MQTT3_PASSWORD=${MQTT_PASSWORD:-}
|
|
- PACKETCAPTURE_MQTT3_TRANSPORT=websockets
|
|
- PACKETCAPTURE_MQTT3_USE_TLS=${MQTT_TLS:-false}
|
|
- PACKETCAPTURE_MQTT3_USE_AUTH_TOKEN=true
|
|
- PACKETCAPTURE_MQTT3_TOKEN_AUDIENCE=${MQTT_TOKEN_AUDIENCE:-mqtt.localhost}
|
|
- PACKETCAPTURE_MQTT3_KEEPALIVE=${PACKETCAPTURE_MQTT3_KEEPALIVE:-60}
|
|
# Topics match broker's <prefix>/<IATA>/<pubkey>/<feed> format
|
|
- PACKETCAPTURE_MQTT3_TOPIC_STATUS=meshcore/{IATA}/{PUBLIC_KEY}/status
|
|
- PACKETCAPTURE_MQTT3_TOPIC_PACKETS=meshcore/{IATA}/{PUBLIC_KEY}/packets
|
|
# MQTT reconnection
|
|
- PACKETCAPTURE_MAX_MQTT_RETRIES=${PACKETCAPTURE_MAX_MQTT_RETRIES:-5}
|
|
- PACKETCAPTURE_MQTT_RETRY_DELAY=${PACKETCAPTURE_MQTT_RETRY_DELAY:-5}
|
|
- PACKETCAPTURE_EXIT_ON_RECONNECT_FAIL=${PACKETCAPTURE_EXIT_ON_RECONNECT_FAIL:-true}
|
|
volumes:
|
|
- observer_data:/app/data
|
|
|
|
# ==========================================================================
|
|
# Collector - MQTT subscriber and database storage
|
|
# ==========================================================================
|
|
collector:
|
|
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
container_name: ${COMPOSE_PROJECT_NAME:-hub}-collector
|
|
profiles:
|
|
- all
|
|
- core
|
|
depends_on:
|
|
migrate:
|
|
condition: service_completed_successfully
|
|
restart: unless-stopped
|
|
volumes:
|
|
- data:/data
|
|
- ${SEED_HOME:-./seed}:/seed
|
|
environment:
|
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
|
- MQTT_HOST=${MQTT_HOST:-mqtt}
|
|
- MQTT_PORT=${MQTT_PORT:-1883}
|
|
- MQTT_USERNAME=${MQTT_USERNAME:-}
|
|
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
|
|
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
|
|
- MQTT_TLS=${MQTT_TLS:-false}
|
|
- MQTT_TRANSPORT=${MQTT_TRANSPORT:-websockets}
|
|
- MQTT_WS_PATH=${MQTT_WS_PATH:-/}
|
|
- COLLECTOR_CHANNEL_KEYS=${COLLECTOR_CHANNEL_KEYS:-}
|
|
- COLLECTOR_INCLUDE_TEST_CHANNEL=${COLLECTOR_INCLUDE_TEST_CHANNEL:-false}
|
|
- DATA_HOME=/data
|
|
- SEED_HOME=/seed
|
|
# Webhook configuration
|
|
- WEBHOOK_ADVERTISEMENT_URL=${WEBHOOK_ADVERTISEMENT_URL:-}
|
|
- WEBHOOK_ADVERTISEMENT_SECRET=${WEBHOOK_ADVERTISEMENT_SECRET:-}
|
|
- WEBHOOK_MESSAGE_URL=${WEBHOOK_MESSAGE_URL:-}
|
|
- WEBHOOK_MESSAGE_SECRET=${WEBHOOK_MESSAGE_SECRET:-}
|
|
- WEBHOOK_CHANNEL_MESSAGE_URL=${WEBHOOK_CHANNEL_MESSAGE_URL:-}
|
|
- WEBHOOK_CHANNEL_MESSAGE_SECRET=${WEBHOOK_CHANNEL_MESSAGE_SECRET:-}
|
|
- WEBHOOK_DIRECT_MESSAGE_URL=${WEBHOOK_DIRECT_MESSAGE_URL:-}
|
|
- WEBHOOK_DIRECT_MESSAGE_SECRET=${WEBHOOK_DIRECT_MESSAGE_SECRET:-}
|
|
- WEBHOOK_TIMEOUT=${WEBHOOK_TIMEOUT:-10.0}
|
|
- WEBHOOK_MAX_RETRIES=${WEBHOOK_MAX_RETRIES:-3}
|
|
- WEBHOOK_RETRY_BACKOFF=${WEBHOOK_RETRY_BACKOFF:-2.0}
|
|
# Data retention and cleanup configuration
|
|
- DATA_RETENTION_ENABLED=${DATA_RETENTION_ENABLED:-true}
|
|
- DATA_RETENTION_DAYS=${DATA_RETENTION_DAYS:-30}
|
|
- DATA_RETENTION_INTERVAL_HOURS=${DATA_RETENTION_INTERVAL_HOURS:-24}
|
|
- NODE_CLEANUP_ENABLED=${NODE_CLEANUP_ENABLED:-true}
|
|
- NODE_CLEANUP_DAYS=${NODE_CLEANUP_DAYS:-30}
|
|
command: ["collector"]
|
|
healthcheck:
|
|
test: ["CMD", "meshcore-hub", "health", "collector"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 10s
|
|
|
|
# ==========================================================================
|
|
# API Server - REST API for querying data
|
|
# ==========================================================================
|
|
api:
|
|
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
container_name: ${COMPOSE_PROJECT_NAME:-hub}-api
|
|
profiles:
|
|
- all
|
|
- core
|
|
restart: unless-stopped
|
|
depends_on:
|
|
migrate:
|
|
condition: service_completed_successfully
|
|
collector:
|
|
condition: service_started
|
|
volumes:
|
|
- data:/data
|
|
environment:
|
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
|
- MQTT_HOST=${MQTT_HOST:-mqtt}
|
|
- MQTT_PORT=${MQTT_PORT:-1883}
|
|
- MQTT_USERNAME=${MQTT_USERNAME:-}
|
|
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
|
|
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
|
|
- MQTT_TLS=${MQTT_TLS:-false}
|
|
- MQTT_TRANSPORT=${MQTT_TRANSPORT:-websockets}
|
|
- MQTT_WS_PATH=${MQTT_WS_PATH:-/}
|
|
- DATA_HOME=/data
|
|
- API_HOST=0.0.0.0
|
|
- API_PORT=8000
|
|
- API_READ_KEY=${API_READ_KEY:-}
|
|
- API_ADMIN_KEY=${API_ADMIN_KEY:-}
|
|
- METRICS_ENABLED=${METRICS_ENABLED:-true}
|
|
- METRICS_CACHE_TTL=${METRICS_CACHE_TTL:-60}
|
|
command: ["api"]
|
|
healthcheck:
|
|
test:
|
|
[
|
|
"CMD",
|
|
"python",
|
|
"-c",
|
|
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')",
|
|
]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 10s
|
|
|
|
# ==========================================================================
|
|
# Web Dashboard - Web interface for network visualization
|
|
# ==========================================================================
|
|
web:
|
|
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
container_name: ${COMPOSE_PROJECT_NAME:-hub}-web
|
|
profiles:
|
|
- all
|
|
- core
|
|
restart: unless-stopped
|
|
depends_on:
|
|
api:
|
|
condition: service_healthy
|
|
volumes:
|
|
- ${CONTENT_HOME:-./content}:/content:ro
|
|
environment:
|
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
|
- API_BASE_URL=http://api:8000
|
|
# Use ADMIN key to allow write operations from admin interface
|
|
# Falls back to READ key if ADMIN key is not set
|
|
- API_KEY=${API_ADMIN_KEY:-${API_READ_KEY:-}}
|
|
- WEB_HOST=0.0.0.0
|
|
- WEB_PORT=8080
|
|
- WEB_THEME=${WEB_THEME:-dark}
|
|
- WEB_LOCALE=${WEB_LOCALE:-en}
|
|
- WEB_DATETIME_LOCALE=${WEB_DATETIME_LOCALE:-en-US}
|
|
- WEB_DEBUG=${WEB_DEBUG:-false}
|
|
# OIDC authentication (see .env.example for details)
|
|
- OIDC_ENABLED=${OIDC_ENABLED:-false}
|
|
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
|
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-}
|
|
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
|
|
- OIDC_SCOPES=${OIDC_SCOPES:-openid email profile}
|
|
- OIDC_ROLES_CLAIM=${OIDC_ROLES_CLAIM:-roles}
|
|
- OIDC_ROLE_ADMIN=${OIDC_ROLE_ADMIN:-admin}
|
|
- OIDC_ROLE_OPERATOR=${OIDC_ROLE_OPERATOR:-operator}
|
|
- OIDC_ROLE_MEMBER=${OIDC_ROLE_MEMBER:-member}
|
|
- OIDC_SESSION_SECRET=${OIDC_SESSION_SECRET:-}
|
|
- OIDC_SESSION_MAX_AGE=${OIDC_SESSION_MAX_AGE:-86400}
|
|
- OIDC_COOKIE_SECURE=${OIDC_COOKIE_SECURE:-false}
|
|
- OIDC_POST_LOGOUT_REDIRECT_URI=${OIDC_POST_LOGOUT_REDIRECT_URI:-}
|
|
- WEB_AUTO_REFRESH_SECONDS=${WEB_AUTO_REFRESH_SECONDS:-30}
|
|
- NETWORK_DOMAIN=${NETWORK_DOMAIN:-}
|
|
- NETWORK_NAME=${NETWORK_NAME:-MeshCore Network}
|
|
- NETWORK_CITY=${NETWORK_CITY:-}
|
|
- NETWORK_COUNTRY=${NETWORK_COUNTRY:-}
|
|
- NETWORK_RADIO_CONFIG=${NETWORK_RADIO_CONFIG:-}
|
|
- NETWORK_CONTACT_EMAIL=${NETWORK_CONTACT_EMAIL:-}
|
|
- NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-}
|
|
- NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-}
|
|
- NETWORK_CONTACT_YOUTUBE=${NETWORK_CONTACT_YOUTUBE:-}
|
|
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
|
|
- CONTENT_HOME=/content
|
|
- TZ=${TZ:-UTC}
|
|
- COLLECTOR_CHANNEL_KEYS=${COLLECTOR_CHANNEL_KEYS:-}
|
|
- COLLECTOR_INCLUDE_TEST_CHANNEL=${COLLECTOR_INCLUDE_TEST_CHANNEL:-false}
|
|
# Feature flags (set to false to disable specific pages)
|
|
- FEATURE_DASHBOARD=${FEATURE_DASHBOARD:-true}
|
|
- FEATURE_NODES=${FEATURE_NODES:-true}
|
|
- FEATURE_ADVERTISEMENTS=${FEATURE_ADVERTISEMENTS:-true}
|
|
- FEATURE_MESSAGES=${FEATURE_MESSAGES:-true}
|
|
- FEATURE_MAP=${FEATURE_MAP:-true}
|
|
- FEATURE_MEMBERS=${FEATURE_MEMBERS:-true}
|
|
- FEATURE_PAGES=${FEATURE_PAGES:-true}
|
|
command: ["web"]
|
|
healthcheck:
|
|
test:
|
|
[
|
|
"CMD",
|
|
"python",
|
|
"-c",
|
|
"import urllib.request; urllib.request.urlopen('http://localhost:8080/health')",
|
|
]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 10s
|
|
|
|
# ==========================================================================
|
|
# Database Migrations - Run Alembic migrations
|
|
# ==========================================================================
|
|
migrate:
|
|
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
container_name: ${COMPOSE_PROJECT_NAME:-hub}-migrate
|
|
profiles:
|
|
- all
|
|
- core
|
|
- migrate
|
|
restart: "no"
|
|
volumes:
|
|
- data:/data
|
|
environment:
|
|
- DATA_HOME=/data
|
|
command: ["db", "upgrade"]
|
|
|
|
# ==========================================================================
|
|
# Seed Data - Import node_tags.yaml from SEED_HOME
|
|
# NOTE: This is NOT run automatically. Use --profile seed to run explicitly.
|
|
# Since tags are now managed via the admin UI, automatic seeding would
|
|
# overwrite user changes.
|
|
# ==========================================================================
|
|
seed:
|
|
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
container_name: ${COMPOSE_PROJECT_NAME:-hub}-seed
|
|
profiles:
|
|
- seed
|
|
restart: "no"
|
|
volumes:
|
|
- data:/data
|
|
- ${SEED_HOME:-./seed}:/seed:ro
|
|
environment:
|
|
- DATA_HOME=/data
|
|
- SEED_HOME=/seed
|
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
|
# Imports node_tags.yaml if it exists
|
|
command: ["collector", "seed"]
|
|
|
|
# ==========================================================================
|
|
# Volumes
|
|
# ==========================================================================
|
|
volumes:
|
|
data:
|
|
name: ${COMPOSE_PROJECT_NAME:-hub}_data
|
|
mqtt_data:
|
|
name: ${COMPOSE_PROJECT_NAME:-hub}_mqtt_data
|
|
observer_data:
|
|
name: ${COMPOSE_PROJECT_NAME:-hub}_observer_data
|