diff --git a/.github/e2e/admin-config.yaml b/.github/e2e/admin-config.yaml index 6ee2224..8c7acc3 100644 --- a/.github/e2e/admin-config.yaml +++ b/.github/e2e/admin-config.yaml @@ -1,6 +1,6 @@ -db: "mysql://root:root@localhost:3376/lnvps" +db: "mysql://root:root@localhost:3377/lnvps" redis: - url: "redis://localhost:6398" + url: "redis://localhost:6399" ttl: 30 encryption: key-file: "/tmp/e2e-encryption.key" diff --git a/.github/e2e/api-config.yaml b/.github/e2e/api-config.yaml index 2e05aac..40d6a13 100644 --- a/.github/e2e/api-config.yaml +++ b/.github/e2e/api-config.yaml @@ -1,4 +1,4 @@ -db: "mysql://root:root@localhost:3376/lnvps" +db: "mysql://root:root@localhost:3377/lnvps" lightning: lnd: url: "https://localhost:10009" @@ -8,7 +8,7 @@ delete-after: 3 public-url: "http://localhost:8000" read-only: true redis: - url: "redis://localhost:6398" + url: "redis://localhost:6399" ttl: 30 nostr: relays: diff --git a/.github/e2e/wait-for-lnd.sh b/.github/e2e/wait-for-lnd.sh index a2733e3..8138ae0 100755 --- a/.github/e2e/wait-for-lnd.sh +++ b/.github/e2e/wait-for-lnd.sh @@ -1,39 +1,107 @@ #!/usr/bin/env bash set -euo pipefail -# Wait for LND to be fully ready and copy credentials to a known path. +# Wait for both LND nodes to be fully ready, fund them, open a channel from +# lnd-payer → lnd, and copy the lnd-payer credentials to a known host path. +# # Usage: ./wait-for-lnd.sh [timeout_seconds] TIMEOUT=${1:-120} LND_CONTAINER=$(docker compose -f docker-compose.e2e.yaml ps -q lnd) +PAYER_CONTAINER=$(docker compose -f docker-compose.e2e.yaml ps -q lnd-payer) +BITCOIND_CONTAINER=$(docker compose -f docker-compose.e2e.yaml ps -q bitcoind) -echo "Waiting for LND to be ready (timeout: ${TIMEOUT}s)..." +BTC_CLI() { + docker exec "$BITCOIND_CONTAINER" bitcoin-cli -regtest \ + -rpcuser=polaruser -rpcpassword=polarpass "$@" +} +LND_CLI() { + docker exec "$LND_CONTAINER" lncli --network=regtest "$@" +} +PAYER_CLI() { + docker exec "$PAYER_CONTAINER" lncli --network=regtest "$@" +} -for i in $(seq 1 "$TIMEOUT"); do - if docker exec "$LND_CONTAINER" lncli --network=regtest getinfo >/dev/null 2>&1; then - echo "LND is ready after ${i}s" +wait_for_node() { + local name="$1" + local cli_fn="$2" + echo "Waiting for ${name} to be ready (timeout: ${TIMEOUT}s)..." + for i in $(seq 1 "$TIMEOUT"); do + if $cli_fn getinfo >/dev/null 2>&1; then + echo "${name} is ready after ${i}s" + return 0 + fi + sleep 1 + done + echo "ERROR: ${name} did not become ready within ${TIMEOUT}s" + return 1 +} - # Copy TLS cert and macaroon to host - mkdir -p /tmp/e2e-lnd/data/chain/bitcoin/regtest - docker cp "$LND_CONTAINER":/root/.lnd/tls.cert /tmp/e2e-lnd/tls.cert - docker cp "$LND_CONTAINER":/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon \ - /tmp/e2e-lnd/data/chain/bitcoin/regtest/admin.macaroon +# Wait for both nodes +wait_for_node "lnd" LND_CLI +wait_for_node "lnd-payer" PAYER_CLI - echo "LND credentials copied to /tmp/e2e-lnd/" +# Copy lnd credentials to host (used by the API server) +mkdir -p /tmp/e2e-lnd/data/chain/bitcoin/regtest +docker cp "$LND_CONTAINER":/root/.lnd/tls.cert \ + /tmp/e2e-lnd/tls.cert +docker cp "$LND_CONTAINER":/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon \ + /tmp/e2e-lnd/data/chain/bitcoin/regtest/admin.macaroon +echo "lnd credentials copied to /tmp/e2e-lnd/" - # Generate a wallet address and mine initial blocks so LND has funds - ADDR=$(docker exec "$LND_CONTAINER" lncli --network=regtest newaddress p2wkh | jq -r .address) - BITCOIND_CONTAINER=$(docker compose -f docker-compose.e2e.yaml ps -q bitcoind) - docker exec "$BITCOIND_CONTAINER" bitcoin-cli -regtest \ - -rpcuser=polaruser -rpcpassword=polarpass \ - generatetoaddress 101 "$ADDR" >/dev/null +# Copy lnd-payer credentials to host (used by E2E tests to pay invoices) +mkdir -p /tmp/e2e-lnd-payer/data/chain/bitcoin/regtest +docker cp "$PAYER_CONTAINER":/root/.lnd/tls.cert \ + /tmp/e2e-lnd-payer/tls.cert +docker cp "$PAYER_CONTAINER":/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon \ + /tmp/e2e-lnd-payer/data/chain/bitcoin/regtest/admin.macaroon +echo "lnd-payer credentials copied to /tmp/e2e-lnd-payer/" - echo "Mined 101 blocks to LND address ${ADDR}" - exit 0 +# Fund both nodes' on-chain wallets (101 blocks each to activate segwit) +LND_ADDR=$(LND_CLI newaddress p2wkh | jq -r .address) +PAYER_ADDR=$(PAYER_CLI newaddress p2wkh | jq -r .address) +BTC_CLI generatetoaddress 101 "$LND_ADDR" >/dev/null +BTC_CLI generatetoaddress 101 "$PAYER_ADDR" >/dev/null +echo "Funded lnd ($LND_ADDR) and lnd-payer ($PAYER_ADDR) with 101 blocks each" + +# Connect lnd-payer to lnd as a peer. +# lnd listens on port 9735 inside the compose network (service hostname "lnd"). +# Retry for up to 30 s because the wallet can still be initialising after +# getinfo returns successfully. +LND_PUBKEY=$(LND_CLI getinfo | jq -r .identity_pubkey) +echo "Connecting lnd-payer to lnd (pubkey: ${LND_PUBKEY})..." +for i in $(seq 1 30); do + if PAYER_CLI connect "${LND_PUBKEY}@lnd:9735" 2>/dev/null; then + echo "lnd-payer connected to lnd after ${i}s" + break + fi + if [[ "$i" -eq 30 ]]; then + echo "ERROR: could not connect lnd-payer to lnd within 30s" + exit 1 fi sleep 1 done -echo "ERROR: LND did not become ready within ${TIMEOUT}s" -docker compose -f docker-compose.e2e.yaml logs lnd | tail -30 -exit 1 +# Open a 10M sat channel from lnd-payer → lnd +PAYER_CLI openchannel --node_key "$LND_PUBKEY" --local_amt 10000000 +echo "Channel open request submitted (10M sats)" + +# Mine 6 blocks so the channel is confirmed and active +BTC_CLI generatetoaddress 6 "$LND_ADDR" >/dev/null +echo "Mined 6 confirmation blocks" + +# Wait until the channel is active on the payer side +echo "Waiting for channel to become active..." +for i in $(seq 1 60); do + ACTIVE=$(PAYER_CLI listchannels | jq '[.channels[] | select(.active == true)] | length') + if [[ "$ACTIVE" -ge 1 ]]; then + echo "Channel is active after ${i}s" + break + fi + if [[ "$i" -eq 60 ]]; then + echo "ERROR: channel did not become active within 60s" + PAYER_CLI listchannels >&2 + exit 1 + fi + sleep 1 +done diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4aa5351..4c535c6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,20 @@ on: branches: - master pull_request: + workflow_dispatch: + inputs: + docker: + description: 'Docker image to build' + required: false + default: 'all' + type: choice + options: + - all + - lnvps-api + - lnvps-api-admin + - lnvps-operator + - lnvps-nostr + - lnvps-host-info env: REGISTRY: registry.v0l.io @@ -14,6 +28,10 @@ jobs: # Build host-info as a multi-arch image first build-host-info: runs-on: ubuntu-latest + if: > + github.event_name == 'push' || + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && (github.event.inputs.docker == 'lnvps-host-info' || github.event.inputs.docker == 'all')) steps: - name: Checkout code uses: actions/checkout@v4 @@ -48,6 +66,12 @@ jobs: build: runs-on: ubuntu-latest needs: build-host-info + if: > + always() && ( + github.event_name == 'push' || + github.event_name == 'pull_request' || + github.event_name == 'workflow_dispatch' + ) strategy: fail-fast: false matrix: @@ -81,6 +105,10 @@ jobs: password: ${{ secrets.REGISTRY_TOKEN }} - name: Build and push ${{ matrix.name }} + if: > + github.event_name == 'push' || + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && (github.event.inputs.docker == matrix.name || github.event.inputs.docker == 'all')) uses: docker/build-push-action@v5 with: context: . diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7db931e..2fa16f2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -11,7 +11,7 @@ env: jobs: e2e: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 steps: - name: Checkout code @@ -34,63 +34,17 @@ jobs: - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler jq - - name: Start infrastructure (DB, Redis, bitcoind, LND) - run: docker compose -f docker-compose.e2e.yaml up -d - - - name: Wait for LND and copy credentials - run: .github/e2e/wait-for-lnd.sh 120 - - - name: Build API servers - run: | - cargo build -p lnvps_api -p lnvps_api_admin - - - name: Start user API - run: | - cargo run -p lnvps_api -- --config .github/e2e/api-config.yaml & - echo $! > /tmp/api.pid - # Wait for user API to be ready - for i in $(seq 1 60); do - if curl -sf http://localhost:8000/ >/dev/null 2>&1; then - echo "User API ready after ${i}s" - break - fi - if [ "$i" -eq 60 ]; then - echo "User API failed to start" - exit 1 - fi - sleep 1 - done - - - name: Start admin API - run: | - cargo run -p lnvps_api_admin --bin lnvps_api_admin -- --config .github/e2e/admin-config.yaml & - echo $! > /tmp/admin-api.pid - for i in $(seq 1 60); do - if curl -sf http://localhost:8001/ >/dev/null 2>&1; then - echo "Admin API ready after ${i}s" - break - fi - if [ "$i" -eq 60 ]; then - echo "Admin API failed to start" - exit 1 - fi - sleep 1 - done - - name: Run E2E tests - run: cargo test -p lnvps_e2e -- --test-threads=1 + env: + LNVPS_E2E_RUN_ID: ${{ github.run_id }}_${{ github.run_attempt }} + run: ./scripts/run-e2e.sh - name: Dump server logs on failure if: failure() run: | + echo "=== User API log ===" + cat /tmp/lnvps-e2e-api.log 2>/dev/null || true + echo "=== Admin API log ===" + cat /tmp/lnvps-e2e-admin-api.log 2>/dev/null || true echo "=== Docker compose logs ===" - docker compose -f docker-compose.e2e.yaml logs --tail=50 - echo "=== User API process ===" - cat /tmp/api.pid 2>/dev/null || true - - - name: Cleanup - if: always() - run: | - kill "$(cat /tmp/api.pid 2>/dev/null)" 2>/dev/null || true - kill "$(cat /tmp/admin-api.pid 2>/dev/null)" 2>/dev/null || true - docker compose -f docker-compose.e2e.yaml down -v + docker compose -f docker-compose.e2e.yaml logs --tail=50 2>/dev/null || true diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index f41fc0c..be3cefb 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -6,7 +6,7 @@ Admin API request/response format reference for LLM consumption. **DiskType**: `"hdd"`, `"ssd"` **DiskInterface**: `"sata"`, `"scsi"`, `"pcie"` -**VmRunningStates**: `"running"`, `"stopped"`, `"starting"`, `"deleting"` +**VmRunningStates**: `"unknown"`, `"running"`, `"stopped"`, `"creating"` **AdminVmHistoryActionType**: `"created"`, `"started"`, `"stopped"`, `"restarted"`, `"deleted"`, `"expired"`, `"renewed"`, `"reinstalled"`, `"state_changed"`, `"payment_received"`, `"configuration_changed"` **AdminPaymentMethod**: `"lightning"`, `"revolut"`, `"paypal"`, `"stripe"` @@ -19,7 +19,7 @@ Admin API request/response format reference for LLM consumption. **RouterKind**: `"mikrotik"`, `"ovh_additional_ip"` **AdminUserRole**: `"super_admin"`, `"admin"`, `"read_only"` **AdminUserStatus**: `"active"`, `"suspended"`, `"deleted"` -**SubscriptionPaymentType**: `"purchase"`, `"renewal"` +**SubscriptionPaymentType**: `"purchase"`, `"renewal"`, `"upgrade"` **SubscriptionType**: `"ip_range"`, `"asn_sponsoring"`, `"dns_hosting"` **InternetRegistry**: `"arin"`, `"ripe"`, `"apnic"`, `"lacnic"`, `"afrinic"` **CpuMfg**: `"unknown"`, `"intel"`, `"amd"`, `"apple"`, `"nvidia"`, `"arm"` @@ -177,6 +177,34 @@ Required Permission: `virtual_machines::view` Returns detailed VM information with complete host and region data. The VM must have valid host and region associations. +The response includes a `subscription` field (type: `AdminSubscriptionInfo`) when the VM is linked to a subscription. This object contains: +- `id` — subscription ID +- `is_active` — whether the subscription is currently active +- `interval_amount` / `interval_type` — billing interval +- `currency` — billing currency +- `payment_count` — total number of payments made +- `line_items` — array of `AdminSubscriptionLineItemInfo` objects + +Example (abbreviated): +```json +{ + "data": { + "id": 42, + "subscription": { + "id": 7, + "is_active": true, + "interval_amount": 1, + "interval_type": "month", + "currency": "USD", + "payment_count": 3, + "line_items": [{ "id": 12, "amount": 999, "setup_amount": 0 }] + } + } +} +``` + +`subscription` is `null`/omitted if no subscription is linked to the VM. + #### Create VM for User ``` @@ -2826,7 +2854,11 @@ The RBAC system uses the following permission format: `resource::action` "billing_tax_id": "string | null", "vm_count": number, "last_login": "string (ISO 8601) | null", - "is_admin": boolean + "is_admin": boolean, + "email_verified": boolean, + // Whether the user's email address has been verified + "has_nwc": boolean + // Whether the user has a Nostr Wallet Connect connection string configured } ``` @@ -2837,7 +2869,8 @@ The RBAC system uses the following permission format: `resource::action` "id": number, // VM ID "created": "string (ISO 8601)", - "expires": "string (ISO 8601)", + "expires": "string (ISO 8601) | null", + // null for VMs not yet paid "mac_address": "string", "image_id": number, // OS image ID for linking @@ -2873,7 +2906,7 @@ The RBAC system uses the following permission format: `resource::action` "timestamp": number, // Unix timestamp of when state was collected "state": "running", - // VmRunningStates enum: "running", "stopped", "starting", "deleting" + // VmRunningStates enum: "unknown", "running", "stopped", "creating" "cpu_usage": number, // Current CPU usage percentage (0.0-100.0) "mem_usage": number, @@ -2917,7 +2950,33 @@ The RBAC system uses the following permission format: `resource::action` "region_id": number, "region_name": "string", "deleted": boolean, - "ref_code": "string | null" + "disabled": boolean, + // Whether the VM has been administratively disabled + "ref_code": "string | null", + "subscription": { + // Full AdminSubscriptionInfo — present when the VM has a linked subscription + "id": number, + "name": "string", + "is_active": boolean, + "auto_renewal_enabled": boolean, + "interval_amount": number, + "interval_type": "day" | "month" | "year", + "currency": "string", + "payment_count": number, + "line_items": [ + { + "id": number, + "name": "string", + "description": "string | null", + "amount": number, + // recurring cost in cents/millisats + "setup_amount": number + // one-time setup fee in cents/millisats + } + ] + } + | null + // null/omitted when no subscription is linked } ``` @@ -2956,8 +3015,7 @@ The RBAC system uses the following permission format: `resource::action` }, "assigned_by": "number | null", "assigned_at": "string (ISO 8601)", - "expires_at": "string (ISO 8601) | null", - "is_active": boolean + "expires_at": "string (ISO 8601) | null" } ``` @@ -2988,6 +3046,8 @@ The RBAC system uses the following permission format: `resource::action` "load_memory": number, "load_disk": number, "vlan_id": "number | null", + "mtu": "number | null", + // MTU setting for network configuration (null if not set) "disks": [ { "id": number, @@ -3030,7 +3090,8 @@ The RBAC system uses the following permission format: `resource::action` "id": number, "name": "string", "enabled": boolean, - "company_id": "number | null", + "company_id": number, + // Company that owns this region "host_count": number, "total_vms": number, // Count of active (non-deleted) VMs only @@ -3132,6 +3193,8 @@ The RBAC system uses the following permission format: `resource::action` "created": "string (ISO 8601)", "expires": "string (ISO 8601) | null", "is_active": boolean, + "is_setup": boolean, + // Whether the subscription has been fully set up (resources allocated) "currency": "string", // "USD", "EUR", "BTC", "GBP", "CAD", "CHF", "AUD", "JPY" "interval_amount": number, @@ -3199,6 +3262,8 @@ The RBAC system uses the following permission format: `resource::action` // Total amount in cents/millisats "currency": "string", // "USD", "EUR", "BTC", etc. + "company_base_currency": "string", + // Base currency of the company that owns the subscription (e.g., "EUR") "payment_method": "lightning" | "revolut" @@ -3208,17 +3273,25 @@ The RBAC system uses the following permission format: `resource::action` "stripe", "payment_type": "purchase" | - "renewal", + "renewal" + | + "upgrade", // SubscriptionPaymentType enum "external_id": "string | null", // External payment processor ID "is_paid": boolean, "paid_at": "string (ISO 8601) | null", // When payment was completed (null if unpaid) - "rate": number + "rate": number, + // Exchange rate to company_base_currency + "time_value": number | null, - // Exchange rate if applicable + // Seconds added to expiry when this payment is completed (omitted if not applicable) + "metadata": object + | + null, + // Service-specific JSON metadata (omitted if none) "tax": number, // Tax amount in cents/millisats "processing_fee": number @@ -3239,8 +3312,12 @@ The RBAC system uses the following permission format: `resource::action` "release_date": "string (ISO 8601)", "url": "string", "default_username": "string | null", - "active_vm_count": number + "active_vm_count": number, // Number of active (non-deleted) VMs using this image + "sha2": "string | null", + // SHA-256 checksum of the image file (omitted if not set) + "sha2_url": "string | null" + // URL to a file containing the SHA-256 checksum (omitted if not set) } ``` @@ -3484,8 +3561,26 @@ The RBAC system uses the following permission format: `resource::action` } ``` +### AdminRouterInfo + +Embedded summary returned when a router is referenced inside another object (e.g. inside `AdminAccessPolicyDetail`). + +```json +{ + "id": number, + "name": "string", + "enabled": boolean, + "kind": "mikrotik", + // RouterKind enum: "mikrotik" or "ovh_additional_ip" + "url": "string" + // Router API URL +} +``` + ### AdminRouterDetail +Full detail returned by `GET /api/admin/v1/routers/{id}`. + ```json { "id": number, @@ -3909,6 +4004,8 @@ Response: Paginated list of `AdminIpRangeSubscriptionInfo` ```json { "id": number, + "company_id": number, + // Company that owns this IP space block "cidr": "string", // e.g., "192.168.0.0/22" "min_prefix_size": number, @@ -4359,6 +4456,82 @@ Instead, the config contains boolean indicators showing whether these values are } ``` +### SanitizedProviderConfig + +The `config` field of `AdminPaymentMethodConfigInfo` is a tagged union — the `"type"` field identifies the variant. All secret/token values are replaced with boolean `has_*` indicators. + +**LND (`"type": "lnd"`)** + +```json +{ + "type": "lnd", + "url": "string", + // LND gRPC endpoint URL + "cert_path": "string", + // Path to the TLS certificate file on the server + "macaroon_path": "string" + // Path to the macaroon file on the server +} +``` + +**Revolut (`"type": "revolut"`)** + +```json +{ + "type": "revolut", + "url": "string", + // Revolut API base URL + "api_version": "string", + // API version string (e.g. "2024-09-01") + "public_key": "string", + // Revolut public key used for webhook verification + "has_token": boolean, + // Whether the API token is configured + "has_webhook_secret": boolean + // Whether the webhook secret is configured +} +``` + +**Stripe (`"type": "stripe"`)** + +```json +{ + "type": "stripe", + "publishable_key": "string", + // Stripe publishable key (safe to expose) + "has_secret_key": boolean, + // Whether the secret key is configured + "has_webhook_secret": boolean + // Whether the webhook signing secret is configured +} +``` + +**PayPal (`"type": "paypal"`)** + +```json +{ + "type": "paypal", + "client_id": "string", + // PayPal OAuth client ID (safe to expose) + "mode": "string", + // "sandbox" or "live" + "has_client_secret": boolean + // Whether the client secret is configured +} +``` + +**Bitvora (`"type": "bitvora"`)** + +```json +{ + "type": "bitvora", + "has_token": boolean, + // Whether the API token is configured + "has_webhook_secret": boolean + // Whether the webhook secret is configured +} +``` + ### CreatePaymentMethodConfigRequest ```json diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index c5a4e70..3a225e2 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -4,6 +4,114 @@ All notable changes to the LNVPS APIs are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Added + +- **2026-03-10** - `"creating"` VM state for cleaner first-provision UX (closes #119) + - `GET /api/v1/vm`, `GET /api/v1/vm/{id}` — `status.state` now transitions to `"creating"` immediately after the first payment is confirmed and before the VM is provisioned on the host. The state is replaced by a real host state (`"running"`, `"stopped"`, etc.) once provisioning completes. + - `GET /api/admin/v1/vms`, `GET /api/admin/v1/vms/{id}` — Same `"creating"` state visible in the admin API. + - This gives frontends a meaningful status to display instead of a stale `"stopped"` state during initial provisioning. + +- **2026-03-10** - WebSocket console endpoint for VM serial terminal access (User API) + - `ANY /api/v1/vm/{id}/console` (WebSocket upgrade) — Bidirectional relay between the client and the VM's serial console via the host provisioner. Authentication is passed via query parameter `?auth=`. + +- **2026-03-10** - Stripe payment handler fully implemented + - `POST /api/v1/vm` / `POST /api/v1/vm/custom-template` — Stripe is now a fully functional payment method (`method=stripe`) + - `GET /api/v1/vm/{id}/renew?method=stripe` — VM renewals can now be paid via Stripe + - `GET /api/v1/subscriptions/{id}/renew?method=stripe` — Subscription renewals support Stripe + - `POST /api/v1/vm/{id}/upgrade?method=stripe` — VM upgrades support Stripe + +- **2026-03-10** - `LNURL` added as a payment method variant + - `GET /api/v1/payment/methods` — Response may now include `{ "name": "lnurl", ... }` when Lightning is enabled + +- **2026-03-10** - `Upgrade` added as a `SubscriptionPayment.payment_type` variant + - `GET /api/v1/subscriptions/{id}/payments` — Payments created for VM upgrades now carry `payment_type: "Upgrade"` + - Previously only `Purchase` and `Renewal` were possible + +- **2026-03-10** - `processing_fee` field added to `SubscriptionPayment` user API response + - `GET /api/v1/subscriptions/{id}/payments` — Each payment now includes `processing_fee: { currency, amount }` + +### Changed + +- **2026-03-10** - `VmRunningStates` enum simplified — `"starting"` and `"deleting"` removed + - `GET /api/v1/vm`, `GET /api/v1/vm/{id}` — `status.state` now has four possible values: `"unknown"` (default before first poll), `"running"`, `"stopped"`, `"creating"`. The former `"starting"` and `"deleting"` variants are no longer emitted. + - `GET /api/admin/v1/vms`, `GET /api/admin/v1/vms/{id}` — Same change applies to `running_state.state`. + - `"unknown"` is now the default value when no state has been cached yet, replacing the previous implicit `"stopped"` default. + +- **2026-03-10** - `VmStatus.expires` is now nullable + - `GET /api/v1/vm`, `GET /api/v1/vm/{id}` — The `expires` field is now `string | null` (was always a string). It will be `null` for newly created VMs that have not yet been paid. + +- **2026-03-10** - `GET /api/v1/vm/{id}/payments` now uses database-level pagination + - The endpoint now accepts `?limit=N&offset=N` query parameters and returns a paginated response (`data`, `total`, `limit`, `offset`). Previously the list was unbounded. + +### Fixed + +- **2026-03-10** - VM subscription lookup query used incorrect type filter + - Internal fix: the query that finds a VM's linked subscription was incorrectly using `IN (3, 4)` instead of `= 3`, which could return incorrect results. + +- **2026-03-10** - `ApiVmPayment::from_subscription_payment` now propagates JSON parse errors + - Previously, a malformed `metadata` JSON field in a `subscription_payment` row would be silently ignored, potentially returning incorrect upgrade parameter data. Errors are now surfaced to the API caller. + +- **2026-03-10** - Expiry notification always sent when NWC auto-renewal is inactive + - Workers now always send the expiry notification email/NIP-17 DM even when NWC is configured but `auto_renewal_enabled` is false for the subscription. + +### Removed + +- **2026-03-10** - Clarification: `POST /api/admin/v1/vms/{id}/renew` does **not** exist + - The 2026-03-03 changelog entry incorrectly stated that multi-interval renewal was added to an admin renew endpoint. No such endpoint exists in the admin API. Multi-interval renewal is only available via the user-facing `GET /api/v1/vm/{id}/renew?intervals=N`. + +### Fixed + +- **2026-03-03** - VM upgrade no longer leaves subscription renewal cost stale + - `POST /api/v1/vm/{id}/upgrade` — After payment confirmation, `SubscriptionLineItem.amount` is now updated to the new base-currency cost of the upgraded template for both standard→custom and custom→custom upgrade paths + - `GET /api/v1/subscriptions/{id}` and admin equivalents — `line_items[].price` now reflects the post-upgrade renewal cost immediately after an upgrade completes + +- **2026-03-03** - Migration tool no longer marks subscriptions active for deleted VMs + - `migrate_vm_subscriptions` — Subscriptions created for deleted VMs are now inserted with `is_active = false` + +### Changed + +- **2026-03-03** - Admin subscription list now returns results in descending order + - `GET /api/admin/v1/subscriptions` — Results ordered by `id DESC` (newest first); applies to both the all-subscriptions list and the `?user_id=N` filtered list + +- **2026-03-03** - Admin VM info response now includes subscription details + - `GET /api/admin/v1/vms/{id}` — Response now includes a `subscription` object with the full `AdminSubscriptionInfo` (id, status, interval, currency, line items, payment count); omitted if no subscription is linked + +- **2026-03-03** - Admin subscription payment response now includes `company_base_currency` + - `GET /api/admin/v1/subscriptions/{id}/payments` — Each payment now includes `company_base_currency` + - `GET /api/admin/v1/subscription_payments/{id}` — Response now includes `company_base_currency` + - `POST /api/admin/v1/subscription_payments/{id}/complete` — Response now includes `company_base_currency` + +- **2026-03-03** - VM payments now use the unified `subscription_payment` table + - All VM renewal, purchase, and upgrade payments are now stored in `subscription_payment` instead of `vm_payment` + - `GET /api/v1/vm/{id}/payments` — Response format unchanged; now backed by `subscription_payment`; supports pagination via `?limit=N&offset=N` query params + - `GET /api/v1/vm/{id}/payments/{payment_id}` — Now looks up by `subscription_payment.id` + - `GET /api/v1/vm/{id}/payments/{payment_id}/invoice` — Now backed by `subscription_payment` + - `POST /api/v1/vm/{id}/renew` — Returns payment from `subscription_payment` + - `POST /api/v1/vm/{id}/upgrade` — Returns payment from `subscription_payment`; upgrade parameters stored in `metadata` JSON field + - `GET /api/admin/v1/vms/{id}/payments` — Now backed by `subscription_payment`; uses real DB-level pagination + - `GET /api/admin/v1/vms/{id}/payments/{payment_id}` — Now looks up by `subscription_payment.id` + - `POST /api/admin/v1/vms/{id}/payments/{payment_id}/complete` — Now completes a `subscription_payment` + - `GET /api/admin/v1/reports/time-series` — Revenue data now sourced from `subscription_payment` + - `GET /api/admin/v1/reports/referral-usage/time-series` — Referral data now sourced from `subscription_payment` + - **Requires data migration**: Run `migrate_vm_subscriptions` binary to backfill existing VMs with subscriptions before upgrading + - **Schema migrations**: `20260302151134_vm_subscription_link.sql` and `20260302154256_vm_subscription_not_null.sql` + +- **2026-03-03** - Every VM is now linked to a `subscription` and `subscription_line_item` + - `vm` table has a new `subscription_line_item_id` column (NOT NULL) linking it to the subscriptions system + - New VMs provisioned via `POST /api/v1/vm` or `POST /api/v1/vm/custom` automatically get a subscription created + - The subscription interval is copied from the cost plan (standard VMs) or defaults to 1 month (custom VMs) + +- **2026-03-03** - `IntervalType` enum renamed from `VmCostPlanIntervalType` + - Affects admin responses that include cost plan or subscription interval information + +### Added + +- **2026-03-03** - Multi-interval VM renewal support + - `POST /api/v1/vm/{id}/renew` — Accepts optional `intervals` query parameter to pre-pay multiple billing periods at once + - `POST /api/admin/v1/vms/{id}/renew` — Same `intervals` support in admin renewal endpoint + ## [v0.2.0] - 2026-02-22 ### Changed diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index ff099a4..34f750c 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -14,7 +14,7 @@ This document provides comprehensive API specifications for generating TypeScrip **DiskType**: `"hdd"`, `"ssd"` **DiskInterface**: `"sata"`, `"scsi"`, `"pcie"` -**VmState**: `"running"`, `"stopped"`, `"pending"`, `"error"`, `"unknown"` +**VmState**: `"unknown"`, `"running"`, `"stopped"`, `"creating"` **CostPlanIntervalType**: `"day"`, `"month"`, `"year"` **OsDistribution**: `"ubuntu"`, `"debian"`, `"centos"`, `"fedora"`, `"freebsd"`, `"opensuse"`, `"archlinux"`, `"redhatenterprise"` @@ -65,17 +65,34 @@ interface AccountInfo { interface VmStatus { id: number; created: string; // ISO 8601 datetime - expires: string; // ISO 8601 datetime + expires?: string; // ISO 8601 datetime — null/omitted for VMs not yet paid mac_address: string; image: VmOsImage; template: VmTemplate; ssh_key: UserSshKey; ip_assignments: VmIpAssignment[]; - status: VmState; + status: VmRunningState; // Full running state with metrics; check status.state for the current lifecycle state auto_renewal_enabled: boolean; // Whether automatic renewal via NWC is enabled for this VM } -type VmState = 'running' | 'stopped' | 'pending' | 'error' | 'unknown'; +interface VmRunningState { + timestamp: number; // Unix timestamp when state was collected + state: VmRunningStateKind; + cpu_usage: number; // CPU usage percentage (0.0–100.0) + mem_usage: number; // Memory usage percentage (0.0–100.0) + uptime: number; // Uptime in seconds + net_in: number; // Network bytes received + net_out: number; // Network bytes transmitted + disk_write: number; // Disk bytes written + disk_read: number; // Disk bytes read +} + +// state field values: +// "unknown" — State not yet known (default before first poll) +// "running" — VM is running normally +// "stopped" — VM is shut down +// "creating" — First payment received; VM is being provisioned on the host for the first time +type VmRunningStateKind = 'unknown' | 'running' | 'stopped' | 'creating'; ``` ### VM Template @@ -225,7 +242,7 @@ interface PaymentType { } interface PaymentMethod { - name: 'lightning' | 'revolut' | 'paypal' | 'stripe' | 'nwc'; + name: 'lightning' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl'; metadata: Record; currencies: ('BTC' | 'EUR' | 'USD')[]; processing_fee_rate?: number; // Percentage rate (e.g., 1.0 for 1%) @@ -264,11 +281,12 @@ interface SubscriptionPayment { created: string; // ISO 8601 datetime expires: string; // ISO 8601 datetime amount: Price; // Total payment amount - payment_method: 'lightning' | 'revolut' | 'paypal' | 'stripe'; - payment_type: 'Purchase' | 'Renewal'; + payment_method: 'lightning' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl'; + payment_type: 'Purchase' | 'Renewal' | 'Upgrade'; is_paid: boolean; paid_at?: string; // ISO 8601 datetime when payment was completed (only present when is_paid is true) tax: Price; // Tax amount + processing_fee: Price; // Processing fee in the payment currency } ``` @@ -483,6 +501,12 @@ console.log('Auto-renewal enabled:', vmStatus.data.auto_renewal_enabled); - **Auth**: Required - **Response**: `null` +#### VM Serial Console (WebSocket) +- **WebSocket** `/api/v1/vm/{id}/console` +- **Auth**: Query parameter `?auth=` (same base64-encoded NIP-98 event as the `Authorization` header) +- **Protocol**: WebSocket upgrade — bidirectional relay between the client and the VM's serial console +- **Description**: Opens a WebSocket connection to the VM's serial terminal. Raw bytes in either direction are forwarded to/from the VM's serial port on the host. The connection is closed when either side disconnects or an error occurs. + ### Templates and Images #### List VM Templates @@ -529,9 +553,15 @@ console.log('Auto-renewal enabled:', vmStatus.data.auto_renewal_enabled); - **Response**: `VmPayment` #### Get Payment History -- **GET** `/api/v1/vm/{id}/payments` +- **GET** `/api/v1/vm/{id}/payments?limit={limit}&offset={offset}` - **Auth**: Required -- **Response**: `VmPayment[]` +- **Query Params**: + - `limit`: Optional (default: 50, max: 100) + - `offset`: Optional (default: 0) +- **Response**: Paginated list of VM payments +```typescript +// Returns: PaginatedResponse +``` #### Get Payment Invoice (PDF) - **GET** `/api/v1/payment/{payment_id}/invoice?auth={base64_auth}` @@ -621,7 +651,7 @@ const result: ApiResponse = await response.json(); - **GET** `/api/v1/subscriptions/{id}/renew?method={payment_method}` - **Auth**: Required - **Query Params**: - - `method`: Optional payment method ('lightning' | 'revolut' | 'paypal' | 'stripe'). Defaults to 'lightning' + - `method`: Optional payment method (`'lightning'` | `'revolut'` | `'paypal'` | `'stripe'`). Defaults to `'lightning'` - **Response**: `SubscriptionPayment` - **Description**: Generates a payment invoice to renew/extend the subscription. For the first payment, the amount includes setup fees plus the monthly recurring cost. For subsequent renewals, only the monthly recurring cost is charged. After payment is confirmed, resources (IP ranges, etc.) are allocated and the subscription is activated. @@ -963,10 +993,7 @@ interface IpSpacePricing { other_setup_fee: Price[]; // Setup fees converted to alternative currencies } -interface Price { - currency: 'usd' | 'eur' | 'btc' | 'gbp' | 'cad' | 'chf' | 'aud' | 'jpy'; - amount: number; // In decimal format (e.g., 10.00 for $10, 0.00011 for BTC) -} +// Note: uses the same Price type as the rest of the API (smallest currency units, uppercase currency codes) interface IpRangeSubscription { id: number; diff --git a/Cargo.lock b/Cargo.lock index 776b946..a9c0d71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2614,6 +2614,7 @@ dependencies = [ "chrono", "hex", "nostr 0.44.2", + "redis", "reqwest", "serde", "serde_json", diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index 0cac2d5..2923d41 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -1,6 +1,7 @@ volumes: e2e-db: e2e-lnd: + e2e-lnd-payer: e2e-bitcoind: e2e-nostr-relay: @@ -11,7 +12,7 @@ services: MARIADB_ROOT_PASSWORD: root MARIADB_DATABASE: lnvps ports: - - "3376:3306" + - "3377:3306" volumes: - "e2e-db:/var/lib/mysql" healthcheck: @@ -23,7 +24,7 @@ services: redis: image: redis:latest ports: - - "6398:6379" + - "6399:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 3s @@ -91,6 +92,41 @@ services: retries: 30 start_period: 10s + lnd-payer: + image: lightninglabs/lnd:v0.18.5-beta + depends_on: + bitcoind: + condition: service_healthy + environment: + LND_ENVIRONMENT: regtest + command: + - lnd + - --noseedbackup + - --norest + - --debuglevel=info + - --bitcoin.active + - --bitcoin.regtest + - --bitcoin.node=bitcoind + - --bitcoind.rpchost=bitcoind:18443 + - --bitcoind.rpcuser=polaruser + - --bitcoind.rpcpass=polarpass + - --bitcoind.zmqpubrawblock=tcp://bitcoind:28332 + - --bitcoind.zmqpubrawtx=tcp://bitcoind:28333 + - --rpclisten=0.0.0.0:10009 + - --listen=0.0.0.0:9735 + - --tlsextradomain=lnd-payer + - --tlsextraip=0.0.0.0 + ports: + - "10010:10009" + volumes: + - "e2e-lnd-payer:/root/.lnd" + healthcheck: + test: ["CMD", "lncli", "--network=regtest", "getinfo"] + interval: 5s + timeout: 10s + retries: 30 + start_period: 10s + nostr-relay: image: dockurr/strfry ports: diff --git a/docs/agents-common b/docs/agents-common index a047e25..d12ce93 160000 --- a/docs/agents-common +++ b/docs/agents-common @@ -1 +1 @@ -Subproject commit a047e257fb138ed4e6c38c68f507cf8a79649f0b +Subproject commit d12ce932d349e424e6678f4b87f465eee5b04c70 diff --git a/docs/agents/api-guidelines.md b/docs/agents/api-guidelines.md index 6d3a658..4da6084 100644 --- a/docs/agents/api-guidelines.md +++ b/docs/agents/api-guidelines.md @@ -5,6 +5,7 @@ - **Always return amounts in API responses as cents / milli-sats** - **Never add JavaScript code examples to API documentation** - **Never expose secrets in admin API responses** — tokens, API keys, webhook secrets, and other sensitive values must never be returned in GET/list responses. Use sanitized structs with boolean indicators (e.g., `has_token: true`) instead of actual values. +- **All `list_*` APIs must use database-level pagination** — never fetch all rows and paginate in Rust (skip/take). Use `LIMIT ? OFFSET ?` in the SQL query, and return a separate `COUNT(*)` or equivalent for the `total` field in the paginated response. Results must be ordered deterministically (typically `ORDER BY id DESC` or `ORDER BY created DESC`) so pagination is stable across requests. ## Documentation Requirements diff --git a/docs/agents/build-and-test.md b/docs/agents/build-and-test.md index 59f47a5..7c55b5f 100644 --- a/docs/agents/build-and-test.md +++ b/docs/agents/build-and-test.md @@ -17,8 +17,17 @@ cargo check **IMPORTANT:** Always use `--test-threads=1` to avoid flaky tests. Tests use shared static state (`LazyLock`) in mocks and must run sequentially. +**IMPORTANT:** Before running any tests, always ensure Docker is running: ```bash -# Run all tests +docker compose up -d +``` +The `lnvps_e2e` crate connects to MariaDB (port 3376) and the API servers. Without Docker the e2e tests all fail with connection errors. + +```bash +# Run all unit tests (no API servers required) +cargo test --workspace --exclude lnvps_e2e -- --test-threads=1 + +# Run ALL tests including e2e (requires API servers on ports 8000 and 8001) cargo test -- --test-threads=1 # Run a single test by name (substring match) diff --git a/docs/agents/e2e-tests.md b/docs/agents/e2e-tests.md index 31d3ab2..7b3edf7 100644 --- a/docs/agents/e2e-tests.md +++ b/docs/agents/e2e-tests.md @@ -6,43 +6,66 @@ The `lnvps_e2e` crate contains end-to-end integration tests that run against liv **These tests are NOT run during Docker image builds.** They run in a dedicated CI workflow (`e2e.yml`) on pull requests, and can also be run locally. -## Prerequisites +## Running -Before running E2E tests, ensure: +### Using the script (recommended) -1. **MySQL/MariaDB** is running on port 3376 (via `docker compose up -d`) -2. **User API** (`lnvps_api`) is running on port 8000 -3. **Admin API** (`lnvps_api_admin`) is running on port 8001 -4. Database migrations have been applied (automatic on server startup) +`scripts/run-e2e.sh` handles everything: starts docker infrastructure, waits for LND, creates the per-run database, patches the API configs, builds and starts both API servers, runs the tests, and tears everything down on exit. -Do NOT set `LNVPS_DEV_SETUP=1` — the lifecycle test creates and cleans up all its own infrastructure. The `dev_setup.sql` script inserts data that can conflict. +```bash +# Full run (start docker, build, run all tests, stop docker) +./scripts/run-e2e.sh -## Running +# Skip rebuild if binaries are already up to date +./scripts/run-e2e.sh --no-build -```bash -# Run all E2E tests (always use --test-threads=1) -cargo test -p lnvps_e2e -- --test-threads=1 +# Run only the lifecycle test +./scripts/run-e2e.sh --filter lifecycle + +# Leave API servers and docker running after the run (for debugging) +./scripts/run-e2e.sh --no-cleanup +``` + +### Script options -# Run with output visible -cargo test -p lnvps_e2e -- --test-threads=1 --nocapture +| Flag | Description | +|---|---| +| `--no-build` | Skip `cargo build` step | +| `--no-cleanup` | Leave API servers and DB running after the run | +| `--filter FILTER` | Pass a test-name filter to `cargo test` (e.g. `lifecycle`) | +| `--run-id ID` | Override the run ID (default: current timestamp) | -# Run a specific test module -cargo test -p lnvps_e2e lifecycle -- --test-threads=1 --nocapture -cargo test -p lnvps_e2e rbac -- --test-threads=1 -cargo test -p lnvps_e2e admin_api -- --test-threads=1 -cargo test -p lnvps_e2e user_api -- --test-threads=1 +### Unit tests only (no API servers needed) -# Run against a remote server (override defaults) -LNVPS_API_URL=https://api-uat.lnvps.net cargo test -p lnvps_e2e user_api -- --test-threads=1 +```bash +# Docker still required for the DB connection in unit tests +docker compose up -d +cargo test --workspace --exclude lnvps_e2e -- --test-threads=1 ``` +The `run-e2e.sh` script sets `LNVPS_NO_DEV_SETUP=1` when starting the API servers so that `dev_setup.sql` is not executed. The lifecycle test creates and cleans up all its own infrastructure; the dev setup data would conflict with it. + +## Per-run Database Isolation + +Each test process creates its own temporary database named `lnvps_e2e_{run_id}` and drops it at the end of the lifecycle test. This prevents test runs from polluting the main `lnvps` database. + +- In CI the run ID is `${{ github.run_id }}_${{ github.run_attempt }}` (set as `LNVPS_E2E_RUN_ID`). +- Locally, if `LNVPS_E2E_RUN_ID` is not set, the current Unix timestamp in milliseconds is used. +- The database is created automatically the first time any test calls `db::connect()`. +- The lifecycle test drops the database at the end of its cleanup section. + +The API servers must be configured to connect to the same per-run database. In CI this is done by the workflow step that patches the API config files before starting the servers. + ## Environment Variables | Variable | Default | Description | |---|---|---| | `LNVPS_API_URL` | `http://localhost:8000` | User API base URL | | `LNVPS_ADMIN_API_URL` | `http://localhost:8001` | Admin API base URL | -| `LNVPS_DB_URL` | `mysql://root:root@localhost:3376/lnvps` | Direct DB connection for bootstrap/cleanup | +| `LNVPS_DB_BASE_URL` | *(derived from `LNVPS_DB_URL`)* | DB server URL without database name, e.g. `mysql://root:root@localhost:3376`. Used to create/drop the per-run database. | +| `LNVPS_DB_URL` | `mysql://root:root@localhost:3376/lnvps` | Full DB URL — only used to derive `LNVPS_DB_BASE_URL` when the latter is not set. | +| `LNVPS_E2E_RUN_ID` | *(current timestamp ms)* | Unique ID for this test run; determines the per-run DB name `lnvps_e2e_{run_id}`. | +| `LNVPS_NO_DEV_SETUP` | *(unset)* | Set to any value to suppress `dev_setup.sql` on startup (debug builds only). Always set by `run-e2e.sh`. | | `NOSTR_SECRET_KEY` | *(random)* | Hex Nostr secret key for user identity | | `ADMIN_NOSTR_SECRET_KEY` | *(random)* | Hex Nostr secret key for admin identity | @@ -165,21 +188,24 @@ pub async fn hard_delete_my_resource(pool: &MySqlPool, id: u64) -> anyhow::Resul ## CI Workflow -The `.github/workflows/e2e.yml` workflow runs E2E tests on every pull request. It: +The `.github/workflows/e2e.yml` workflow runs E2E tests on every pull request. It installs dependencies, then delegates entirely to `scripts/run-e2e.sh` with `LNVPS_E2E_RUN_ID` set to `${{ github.run_id }}_${{ github.run_attempt }}`. The script: 1. Starts infrastructure via `docker-compose.e2e.yaml` (MariaDB, Redis, bitcoind regtest, LND) 2. Waits for LND to be ready and copies TLS cert + macaroon to the host 3. Mines 101 blocks so LND has spendable funds -4. Builds and starts both API servers using configs from `.github/e2e/` -5. Runs `cargo test -p lnvps_e2e -- --test-threads=1` -6. Tears down all containers on completion +4. Creates the per-run database `lnvps_e2e_{run_id}` +5. Writes temporary API configs pointing at the per-run database +6. Builds and starts both API servers +7. Runs `cargo test -p lnvps_e2e -- --test-threads=1` +8. Tears down API servers and docker containers on exit ### CI files | File | Purpose | |---|---| -| `.github/workflows/e2e.yml` | GitHub Actions workflow | +| `.github/workflows/e2e.yml` | GitHub Actions workflow (thin wrapper around the script) | +| `scripts/run-e2e.sh` | Full runner script used by CI and local development | | `docker-compose.e2e.yaml` | Compose file with DB, Redis, bitcoind, LND | -| `.github/e2e/api-config.yaml` | User API config pointing to CI LND | -| `.github/e2e/admin-config.yaml` | Admin API config | +| `.github/e2e/api-config.yaml` | User API config template (DB URL replaced at runtime) | +| `.github/e2e/admin-config.yaml` | Admin API config template (DB URL replaced at runtime) | | `.github/e2e/wait-for-lnd.sh` | Script to wait for LND readiness and mine initial blocks | diff --git a/docs/agents/migrations.md b/docs/agents/migrations.md index b3b4026..dfd818b 100644 --- a/docs/agents/migrations.md +++ b/docs/agents/migrations.md @@ -27,3 +27,39 @@ Fix by using a completely unique timestamp: - Use `NOT NULL DEFAULT ` for new columns to avoid breaking existing rows - Test migrations against a database with production-like data - Never modify a migration that has already been applied to any environment + +## Notable Migrations + +### vm_payment → subscription_payment (2026-03-02) + +Two schema migrations and a data migration binary were added as part of migrating VM payments +from the legacy `vm_payment` table to the unified `subscription_payment` table. + +**Schema migrations** (applied automatically by sqlx at startup): + +- `20260302151134_vm_subscription_link.sql` — Adds `subscription_line_item_id` to `vm`; adds + `interval_amount`/`interval_type` back to `subscription`; adds `time_value`/`metadata` to + `subscription_payment`. All new columns have safe defaults so existing rows are unaffected. +- `20260302154256_vm_subscription_not_null.sql` — Makes `vm.subscription_line_item_id` NOT NULL + after the data migration has been run. + +**Data migration** (must be run manually before the NOT NULL migration): + +```bash +cargo run --bin migrate_vm_subscriptions -- --database-url +# Dry-run first: +cargo run --bin migrate_vm_subscriptions -- --database-url --dry-run +``` + +The binary iterates all VMs that do not yet have a `subscription_line_item_id` set, creates a +`subscription` + `subscription_line_item` (type `VmRenewal`) for each, and links the VM. It is +idempotent — VMs that already have a subscription are skipped. + +**Finalization** (after production verification — do not run until confirmed): + +Once the data migration has been verified in production and all new VMs are going through the +subscription path, `vm_payment` can be dropped: + +```sql +DROP TABLE vm_payment; +``` diff --git a/lnvps_api/dev_setup.sql b/lnvps_api/dev_setup.sql index dbfb00f..d232a99 100644 --- a/lnvps_api/dev_setup.sql +++ b/lnvps_api/dev_setup.sql @@ -1,132 +1,107 @@ --- Default company -insert -ignore into company(id,name,email,base_currency) -values(1,"Dev Company","dev@example.com","EUR"); - -insert -ignore into vm_host_region(id,name,enabled,company_id) values(1,"uat",1,1); -insert -ignore into vm_host(id,kind,region_id,name,ip,cpu,memory,enabled,api_token) -values(1, 0, 1, "lab", "https://10.100.1.5:8006", 4, 4096*1024, 1, "root@pam!tester=c82f8a57-f876-4ca4-8610-c086d8d9d51c"); -insert -ignore into vm_host_disk(id,host_id,name,size,kind,interface,enabled) -values(1,1,"local-zfs",1000*1000*1000*1000, 0, 0, 1); -insert -ignore into vm_os_image(id,distribution,flavour,version,enabled,url,release_date) -values(1, 0,"Server","24.04",1,"https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img","2024-04-25"); -insert -ignore into vm_os_image(id,distribution,flavour,version,enabled,url,release_date) -values(2, 0,"Server","22.04",1,"https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img","2022-04-21"); -insert -ignore into vm_os_image(id,distribution,flavour,version,enabled,url,release_date) -values(3, 0,"Server","20.04",1,"https://cloud-images.ubuntu.com/focal/current/focal-server-cloudimg-amd64.img","2020-04-23"); -insert -ignore into vm_os_image(id,distribution,flavour,version,enabled,url,release_date) -values(4, 1,"Server","12",1,"https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.raw","2023-06-10"); -insert -ignore into vm_os_image(id,distribution,flavour,version,enabled,url,release_date) -values(5, 1,"Server","11",1,"https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.raw","2021-08-14"); -insert -ignore into ip_range(id,cidr,enabled,region_id,gateway) -values(1,"10.100.1.128/25",1,1,"10.100.1.1/24"); -insert -ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) -values(1,"tiny_monthly",2,"EUR",1,1); -insert -ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) -values(2,"small_monthly",4,"EUR",1,1); -insert -ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) -values(3,"medium_monthly",8,"EUR",1,1); -insert -ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) -values(4,"large_monthly",17,"EUR",1,1); -insert -ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) -values(5,"xlarge_monthly",30,"EUR",1,1); -insert -ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) -values(6,"xxlarge_monthly",45,"EUR",1,1); -insert -ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) -values(1,"Tiny",1,1,1024*1024*1024*1,1024*1024*1024*40,1,2,1,1); -insert -ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) -values(2,"Small",1,2,1024*1024*1024*2,1024*1024*1024*80,1,2,2,1); -insert -ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) -values(3,"Medium",1,4,1024*1024*1024*4,1024*1024*1024*160,1,2,3,1); -insert -ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) -values(4,"Large",1,8,1024*1024*1024*8,1024*1024*1024*400,1,2,4,1); -insert -ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) -values(5,"X-Large",1,12,1024*1024*1024*16,1024*1024*1024*800,1,2,5,1); -insert -ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) -values(6,"XX-Large",1,20,1024*1024*1024*24,1024*1024*1024*1000,1,2,6,1); - --- Available IP Space for sale -insert -ignore into available_ip_space(id,company_id,cidr,min_prefix_size,max_prefix_size,registry,external_id,is_available,is_reserved,metadata) +-- Default company (aligns with the row inserted by the require_company_id migration) +insert ignore into company(id,name,email,base_currency) +values(1,"Default Company","admin@example.com","EUR"); + +-- Region +insert ignore into vm_host_region(id,name,enabled,company_id) +values(1,"Mock",1,1); + +-- Dummy (mock) host — kind=65535 (VmHostKind::Dummy), all credential fields ignored at runtime +insert ignore into vm_host(id,kind,region_id,name,ip,cpu,memory,enabled,api_token) +values(1,65535,1,"mock-host","https://localhost",4,4*1024*1024*1024,1,""); + +-- SSD/PCIe disk on the mock host (kind=1 SSD, interface=2 PCIe) +insert ignore into vm_host_disk(id,host_id,name,size,kind,interface,enabled) +values(1,1,"mock-disk",10*1000*1000*1000*1000,1,2,1); + +-- OS images +insert ignore into vm_os_image(id,distribution,flavour,version,enabled,url,release_date) +values(1,0,"Server","24.04",1,"https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img","2024-04-25"); +insert ignore into vm_os_image(id,distribution,flavour,version,enabled,url,release_date) +values(2,0,"Server","22.04",1,"https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img","2022-04-21"); +insert ignore into vm_os_image(id,distribution,flavour,version,enabled,url,release_date) +values(3,1,"Server","12",1,"https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.raw","2023-06-10"); +insert ignore into vm_os_image(id,distribution,flavour,version,enabled,url,release_date) +values(4,1,"Server","11",1,"https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.raw","2021-08-14"); + +-- IPv4 loopback range (allocation_mode=0 Random, use_full_range=0) +insert ignore into ip_range(id,cidr,enabled,region_id,gateway,allocation_mode,use_full_range) +values(1,"127.0.0.0/8",1,1,"127.0.0.1/8",0,0); + +-- IPv6 link-local range (allocation_mode=0 Random, use_full_range=0) +insert ignore into ip_range(id,cidr,enabled,region_id,gateway,allocation_mode,use_full_range) +values(2,"fe80::/64",1,1,"fe80::1",0,0); + +-- Cost plans (amounts in cents: €2.00, €4.00, €8.00, €17.00, €30.00, €45.00) +insert ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) +values(1,"tiny_monthly",200,"EUR",1,1); +insert ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) +values(2,"small_monthly",400,"EUR",1,1); +insert ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) +values(3,"medium_monthly",800,"EUR",1,1); +insert ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) +values(4,"large_monthly",1700,"EUR",1,1); +insert ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) +values(5,"xlarge_monthly",3000,"EUR",1,1); +insert ignore into vm_cost_plan(id,name,amount,currency,interval_amount,interval_type) +values(6,"xxlarge_monthly",4500,"EUR",1,1); + +-- VM templates (disk_type=1 SSD, disk_interface=2 PCIe) +insert ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) +values(1,"Tiny", 1, 1,1*1024*1024*1024, 40*1024*1024*1024,1,2,1,1); +insert ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) +values(2,"Small", 1, 2,2*1024*1024*1024, 80*1024*1024*1024,1,2,2,1); +insert ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) +values(3,"Medium",1, 4,4*1024*1024*1024,160*1024*1024*1024,1,2,3,1); +insert ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) +values(4,"Large", 1, 8,8*1024*1024*1024,400*1024*1024*1024,1,2,4,1); +insert ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) +values(5,"X-Large", 1,12,16*1024*1024*1024,800*1024*1024*1024,1,2,5,1); +insert ignore into vm_template(id,name,enabled,cpu,memory,disk_size,disk_type,disk_interface,cost_plan_id,region_id) +values(6,"XX-Large",1,20,24*1024*1024*1024,1000*1024*1024*1024,1,2,6,1); + +-- Available IP space for sale (documentation ranges, safe for dev) +insert ignore into available_ip_space(id,company_id,cidr,min_prefix_size,max_prefix_size,registry,external_id,is_available,is_reserved,metadata) values(1,1,"192.0.2.0/24",32,24,0,"ARIN-2024-001",1,0,'{"upstream":"ExampleISP","asn":65000}'); - -insert -ignore into available_ip_space(id,company_id,cidr,min_prefix_size,max_prefix_size,registry,external_id,is_available,is_reserved,metadata) +insert ignore into available_ip_space(id,company_id,cidr,min_prefix_size,max_prefix_size,registry,external_id,is_available,is_reserved,metadata) values(2,1,"198.51.100.0/22",26,22,0,"ARIN-2024-002",1,0,'{"upstream":"ExampleISP","asn":65000}'); - -insert -ignore into available_ip_space(id,company_id,cidr,min_prefix_size,max_prefix_size,registry,external_id,is_available,is_reserved,metadata) +insert ignore into available_ip_space(id,company_id,cidr,min_prefix_size,max_prefix_size,registry,external_id,is_available,is_reserved,metadata) values(3,1,"2001:db8::/29",48,32,1,"RIPE-2024-001",1,0,'{"upstream":"ExampleISP","asn":65000}'); --- IP Space Pricing --- Pricing for 192.0.2.0/24 -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(1,1,32,500,"USD",1000); -- /32 single IP: $5/mo, $10 setup - -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(2,1,24,15000,"USD",5000); -- /24 (256 IPs): $150/mo, $50 setup - --- Pricing for 198.51.100.0/22 -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(3,2,26,4000,"USD",2000); -- /26 (64 IPs): $40/mo, $20 setup - -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(4,2,25,7500,"USD",3000); -- /25 (128 IPs): $75/mo, $30 setup - -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(5,2,24,14000,"USD",5000); -- /24 (256 IPs): $140/mo, $50 setup - -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(6,2,23,26000,"USD",8000); -- /23 (512 IPs): $260/mo, $80 setup - -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(7,2,22,50000,"USD",15000); -- /22 (1024 IPs): $500/mo, $150 setup - --- Pricing for IPv6 2001:db8::/29 -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(8,3,48,2000,"USD",5000); -- /48 (for end sites): $20/mo, $50 setup - -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(9,3,44,5000,"USD",10000); -- /44: $50/mo, $100 setup - -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(10,3,40,12000,"USD",20000); -- /40: $120/mo, $200 setup - -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(11,3,36,25000,"USD",35000); -- /36: $250/mo, $350 setup - -insert -ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) -values(12,3,32,50000,"USD",50000); -- /32 (large ISP): $500/mo, $500 setup \ No newline at end of file +-- Custom pricing (per-resource billing, amounts in cents per unit per month) +-- cpu_cost: €0.14/core, memory_cost: €0.01/GB, ip4_cost: €0.05/IPv4, ip6_cost: €0.02/IPv6 +insert ignore into vm_custom_pricing(id,name,enabled,region_id,currency,cpu_cost,memory_cost,ip4_cost,ip6_cost,cpu_mfg,cpu_arch,cpu_features,min_cpu,max_cpu,min_memory,max_memory,disk_iops_read,disk_iops_write,disk_mbps_read,disk_mbps_write,network_mbps,cpu_limit) +values(1,"mock-flex",1,1,"EUR",14,1,5,2,0,0,"",1,32,1073741824,68719476736,NULL,NULL,NULL,NULL,NULL,NULL); + +-- Custom pricing disk (kind=1 SSD, interface=2 PCIe, cost=€0.01/GB/mo = 1 cent, limits 5GB–2TB) +insert ignore into vm_custom_pricing_disk(id,pricing_id,kind,interface,cost,min_disk_size,max_disk_size) +values(1,1,1,2,1,5368709120,2199023255552); + +-- IP space pricing (amounts in cents) +-- 192.0.2.0/24 +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(1,1,32,500,"EUR",1000); -- /32 single IP: €5/mo, €10 setup +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(2,1,24,15000,"EUR",5000); -- /24 (256 IPs): €150/mo, €50 setup +-- 198.51.100.0/22 +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(3,2,26,4000,"EUR",2000); -- /26 (64 IPs): €40/mo, €20 setup +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(4,2,25,7500,"EUR",3000); -- /25 (128 IPs): €75/mo, €30 setup +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(5,2,24,14000,"EUR",5000); -- /24 (256 IPs): €140/mo, €50 setup +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(6,2,23,26000,"EUR",8000); -- /23 (512 IPs): €260/mo, €80 setup +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(7,2,22,50000,"EUR",15000); -- /22 (1024 IPs): €500/mo, €150 setup +-- 2001:db8::/29 (IPv6) +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(8,3,48,2000,"EUR",5000); -- /48: €20/mo, €50 setup +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(9,3,44,5000,"EUR",10000); -- /44: €50/mo, €100 setup +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(10,3,40,12000,"EUR",20000); -- /40: €120/mo, €200 setup +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(11,3,36,25000,"EUR",35000); -- /36: €250/mo, €350 setup +insert ignore into ip_space_pricing(id,available_ip_space_id,prefix_size,price_per_month,currency,setup_fee) +values(12,3,32,50000,"EUR",50000); -- /32 (large ISP): €500/mo, €500 setup diff --git a/lnvps_api/src/api/ip_space.rs b/lnvps_api/src/api/ip_space.rs index ac3597e..feaecdf 100644 --- a/lnvps_api/src/api/ip_space.rs +++ b/lnvps_api/src/api/ip_space.rs @@ -23,23 +23,16 @@ async fn v1_list_ip_space( let limit = q.limit.unwrap_or(50).min(100); let offset = q.offset.unwrap_or(0); - // Get all available IP spaces - let all_spaces = this.db.list_available_ip_space().await?; - - // Filter to only show available ones (not reserved) - let available_spaces: Vec<_> = all_spaces - .into_iter() - .filter(|space| space.is_available && !space.is_reserved) - .collect(); - - let total = available_spaces.len() as u64; - - // Paginate - let paginated_spaces: Vec<_> = available_spaces - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect(); + let (paginated_spaces, total) = this + .db + .list_available_ip_space_paginated( + Some(true), // is_available = true + Some(false), // is_reserved = false + None, + limit, + offset, + ) + .await?; // Convert to API format with pricing let mut ip_spaces = Vec::new(); diff --git a/lnvps_api/src/api/legal.rs b/lnvps_api/src/api/legal.rs index c4d00f2..42ad614 100644 --- a/lnvps_api/src/api/legal.rs +++ b/lnvps_api/src/api/legal.rs @@ -227,23 +227,22 @@ async fn v1_generate_lir_agreement_from_subscription( .iter() .map(|li| { let resource_type = match li.subscription_type { - lnvps_db::SubscriptionType::IpRange => { - // Try to extract IP range info from configuration - li.configuration - .as_ref() - .and_then(|cfg| cfg.get("cidr").and_then(|c| c.as_str())) - .map(|cidr| { - if cidr.contains(':') { - "IPv6 PI" - } else { - "IPv4 PI" - } - }) - .unwrap_or("IP Range") - .to_string() - } + lnvps_db::SubscriptionType::IpRange => li + .configuration + .as_ref() + .and_then(|cfg| cfg.get("cidr").and_then(|c| c.as_str())) + .map(|cidr| { + if cidr.contains(':') { + "IPv6 PI" + } else { + "IPv4 PI" + } + }) + .unwrap_or("IP Range") + .to_string(), lnvps_db::SubscriptionType::AsnSponsoring => "AS Number".to_string(), lnvps_db::SubscriptionType::DnsHosting => "DNS Hosting".to_string(), + lnvps_db::SubscriptionType::Vps => "VPS".to_string(), }; let quantity = li diff --git a/lnvps_api/src/api/mod.rs b/lnvps_api/src/api/mod.rs index f493ad8..8d2a87a 100644 --- a/lnvps_api/src/api/mod.rs +++ b/lnvps_api/src/api/mod.rs @@ -10,6 +10,23 @@ mod routes; mod subscriptions; mod webhook; +use crate::settings::Settings; +use crate::subscription::SubscriptionHandler; +pub use contact::router as contacts_router; +pub use docs::router as docs_router; +pub use ip_space::router as ip_space_router; +pub use legal::router as legal_router; +use lnvps_api_common::{ExchangeRateService, VmHistoryLogger, VmStateCache, WorkCommander}; +use lnvps_db::LNVpsDb; +#[cfg(feature = "nostr-domain")] +pub use nostr_domain::router as nostr_domain_router; +pub use referral::router as referral_router; +pub use routes::routes as main_router; +use serde::Deserialize; +use std::sync::Arc; +pub use subscriptions::router as subscriptions_router; +pub use webhook::router as webhook_router; + #[derive(Deserialize)] pub(crate) struct PaymentMethodQuery { pub method: Option, @@ -32,26 +49,9 @@ pub(crate) struct AuthQuery { pub struct RouterState { pub db: Arc, pub state: VmStateCache, - pub provisioner: Arc, - pub history: Arc, + pub sub_handler: SubscriptionHandler, + pub history: VmHistoryLogger, pub settings: Settings, pub rates: Arc, pub work_sender: Arc, } - -use crate::provisioner::LNVpsProvisioner; -use crate::settings::Settings; -pub use contact::router as contacts_router; -pub use docs::router as docs_router; -pub use ip_space::router as ip_space_router; -pub use legal::router as legal_router; -use lnvps_api_common::{ExchangeRateService, VmHistoryLogger, VmStateCache, WorkCommander}; -use lnvps_db::LNVpsDb; -#[cfg(feature = "nostr-domain")] -pub use nostr_domain::router as nostr_domain_router; -pub use referral::router as referral_router; -pub use routes::routes as main_router; -use serde::Deserialize; -use std::sync::Arc; -pub use subscriptions::router as subscriptions_router; -pub use webhook::router as webhook_router; diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index a957b95..e8f77e3 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -252,6 +252,78 @@ impl ApiInvoiceItem { payment.time_value, ) } + + /// Creates a formatted invoice item from a SubscriptionPayment + pub fn from_subscription_payment( + payment: &lnvps_db::SubscriptionPayment, + ) -> Result { + Self::from_payment_data( + payment.amount, + payment.tax, + payment.processing_fee, + &payment.currency, + payment.time_value.unwrap_or(0), + ) + } +} + +impl ApiVmPayment { + /// Convert a `SubscriptionPayment` to an `ApiVmPayment`. + /// The `vm_id` must be provided because `SubscriptionPayment` only knows the subscription. + pub fn from_subscription_payment( + value: lnvps_db::SubscriptionPayment, + vm_id: u64, + ) -> anyhow::Result { + let upgrade_params = value + .metadata + .as_ref() + .map(|m| serde_json::to_string(m).unwrap_or_default()); + let is_upgrade = value.payment_type == lnvps_db::SubscriptionPaymentType::Upgrade; + let data = match &value.payment_method { + PaymentMethod::Lightning => ApiPaymentData::Lightning(value.external_data.into()), + PaymentMethod::Revolut => { + #[derive(Deserialize)] + struct RevolutData { + pub token: String, + } + let data: RevolutData = + serde_json::from_str(value.external_data.as_str()).map_err(|e| { + anyhow::anyhow!("Failed to parse Revolut payment data: {}", e) + })?; + ApiPaymentData::Revolut { token: data.token } + } + PaymentMethod::Paypal => todo!(), + PaymentMethod::Stripe => { + #[derive(Deserialize)] + struct StripeData { + pub session_id: String, + } + let data: StripeData = + serde_json::from_str(value.external_data.as_str()).map_err(|e| { + anyhow::anyhow!("Failed to parse Stripe payment data: {}", e) + })?; + ApiPaymentData::Stripe { + session_id: data.session_id, + } + } + }; + Ok(Self { + id: hex::encode(&value.id), + vm_id, + created: value.created, + expires: value.expires, + amount: value.amount, + tax: value.tax, + processing_fee: value.processing_fee, + currency: value.currency, + is_paid: value.is_paid, + paid_at: value.paid_at, + time: value.time_value.unwrap_or(0), + is_upgrade, + upgrade_params, + data, + }) + } } impl From for ApiVmPayment { @@ -621,6 +693,7 @@ pub struct ApiSubscriptionPayment { pub enum ApiSubscriptionPaymentType { Purchase, Renewal, + Upgrade, } impl From for ApiSubscriptionPaymentType { @@ -628,6 +701,7 @@ impl From for ApiSubscriptionPaymentType { match payment_type { lnvps_db::SubscriptionPaymentType::Purchase => ApiSubscriptionPaymentType::Purchase, lnvps_db::SubscriptionPaymentType::Renewal => ApiSubscriptionPaymentType::Renewal, + lnvps_db::SubscriptionPaymentType::Upgrade => ApiSubscriptionPaymentType::Upgrade, } } } diff --git a/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs index 76d422d..9bf935a 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -350,6 +350,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires live network access to zap.stream"] async fn test_validate_lightning_address_accepts_valid() { let result = validate_lightning_address("kieran@zap.stream").await; assert!(result.is_ok()); diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 6c6aa4b..27d9ad7 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -7,8 +7,8 @@ use axum::{Json, Router}; use chrono::{DateTime, Datelike, Utc}; use futures::future::join_all; use isocountry::CountryCode; -use lnurl::Tag; use lnurl::pay::{LnURLPayInvoice, PayResponse}; +use lnurl::{LnUrlResponse, Tag}; use log::{error, info}; use nostr_sdk::{ToBech32, Url}; use payments_rs::currency::CurrencyAmount; @@ -364,15 +364,23 @@ async fn v1_patch_vm( let mut ips = this.db.list_vm_ip_assignments(vm.id).await?; for ip in ips.iter_mut() { ip.dns_reverse = Some(ptr.to_string()); - this.provisioner.network.update_reverse_ip_dns(ip).await?; + this.sub_handler + .vm_provisioner() + .network + .update_reverse_ip_dns(ip) + .await?; this.db.update_vm_ip_assignment(ip).await?; } } - // Handle auto-renewal setting change + // Handle auto-renewal setting change — stored on the subscription, not the VM if let Some(auto_renewal) = data.auto_renewal_enabled { - vm.auto_renewal_enabled = auto_renewal; - vm_config = true; + let mut sub = this + .db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await?; + sub.auto_renewal_enabled = auto_renewal; + this.db.update_subscription(&sub).await?; } if vm_config { @@ -526,7 +534,8 @@ async fn v1_create_custom_vm_order( let template = req.spec.clone().into(); let rsp = this - .provisioner + .sub_handler + .vm_provisioner() .provision_custom(uid, template, req.image_id, req.ssh_key_id, req.ref_code) .await?; @@ -609,7 +618,8 @@ async fn v1_create_vm_order( } let rsp = this - .provisioner + .sub_handler + .vm_provisioner() .provision( uid, req.template_id, @@ -635,19 +645,26 @@ async fn v1_renew_vm( Path(id): Path, Query(q): Query, ) -> ApiResult { - let (uid, _) = get_user_vm(&auth, &this, id).await?; + let (uid, vm) = get_user_vm(&auth, &this, id).await?; let user = this.db.get_user(uid).await?; let intervals = q.intervals.unwrap_or(1); + let vm_line = this + .db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; // handle "nwc" payments automatically - let rsp = if q.method.as_deref() == Some("nwc") && user.nwc_connection_string.is_some() { - this.provisioner - .auto_renew_via_nwc(id, user.nwc_connection_string.unwrap().as_str()) + let payment = if q.method.as_deref() == Some("nwc") && user.nwc_connection_string.is_some() { + this.sub_handler + .auto_renew_via_nwc( + vm_line.subscription_id, + user.nwc_connection_string.unwrap().as_str(), + ) .await? } else { - this.provisioner - .renew_intervals( - id, + this.sub_handler + .renew_subscription( + vm_line.subscription_id, q.method .and_then(|m| PaymentMethod::from_str(&m).ok()) .unwrap_or(PaymentMethod::Lightning), @@ -656,7 +673,7 @@ async fn v1_renew_vm( .await? }; - ApiData::ok(rsp.into()) + ApiData::ok(ApiVmPayment::from_subscription_payment(payment, id)?) } /// Extend a VM by LNURL payment @@ -664,24 +681,46 @@ async fn v1_renew_vm_lnurlp( State(this): State, Path(id): Path, Query(q): Query, -) -> Result, &'static str> { - let vm = this.db.get_vm(id).await.map_err(|_e| "VM not found")?; +) -> Result, Json> { + let vm = this.db.get_vm(id).await.map_err(|_| { + Json(lnurl::Response::Error { + reason: "VM not found".to_string(), + }) + })?; if vm.deleted { - return Err("VM not found"); + return Err(lnurl::Response::Error { + reason: "VM not found".to_string(), + } + .into()); } if q.amount < 1000 { - return Err("Amount must be greater than 1000"); + return Err(lnurl::Response::Error { + reason: "Amount must be greater than 1000".to_string(), + } + .into()); } - + let vm_line = this + .db + .get_subscription_line_item(vm.subscription_line_item_id) + .await + .map_err(|_| { + Json(lnurl::Response::Error { + reason: "VM not found".to_string(), + }) + })?; let rsp = this - .provisioner + .sub_handler .renew_amount( - id, + vm_line.subscription_id, CurrencyAmount::millisats(q.amount), PaymentMethod::Lightning, ) .await - .map_err(|_| "Error generating invoice")?; + .map_err(|_| { + Json(lnurl::Response::Error { + reason: "Error generating invoice".to_string(), + }) + })?; // external_data is pr for lightning payment method Ok(Json(LnURLPayInvoice::new(rsp.external_data.into()))) @@ -705,7 +744,7 @@ async fn v1_lnurlp( .map_err(|_| "Could not get callback url")? .to_string(), max_sendable: 1_000_000_000, - min_sendable: 1_000, // TODO: calc min by using 1s extend time + min_sendable: 100_000, // TODO: calc min by using 1s extend time tag: Tag::PayRequest, metadata: serde_json::to_string(&meta).map_err(|_e| "Failed to serialize metadata")?, comment_allowed: None, @@ -1029,13 +1068,16 @@ async fn v1_get_payment( return ApiData::err("Invalid payment id"); }; - let payment = this.db.get_vm_payment(&id).await?; - let vm = this.db.get_vm(payment.vm_id).await?; + let payment = this.db.get_subscription_payment(&id).await?; + let vm = this + .db + .get_vm_by_subscription(payment.subscription_id) + .await?; if vm.user_id != uid { return ApiData::err("VM does not belong to you"); } - ApiData::ok(payment.into()) + ApiData::ok(ApiVmPayment::from_subscription_payment(payment, vm.id)?) } /// Print payment invoice @@ -1065,17 +1107,18 @@ async fn v1_get_payment_invoice( let payment = this .db - .get_vm_payment(&id) + .get_subscription_payment(&id) .await .map_err(|_| "Payment not found")?; let vm = this .db - .get_vm(payment.vm_id) + .get_vm_by_subscription(payment.subscription_id) .await .map_err(|_| "VM not found")?; if vm.user_id != uid { return Err("VM does not belong to you"); } + let vm_id_for_payment = vm.id; if !payment.is_paid { return Err("Payment is not paid, can't generate invoice"); @@ -1127,11 +1170,11 @@ async fn v1_get_payment_invoice( .map_err(|_| "Invalid template")?; // Parse upgrade details if this is an upgrade payment - let upgrade_details = if payment.payment_type == lnvps_db::PaymentType::Upgrade { + let upgrade_details = if payment.payment_type == lnvps_db::SubscriptionPaymentType::Upgrade { payment - .upgrade_params + .metadata .as_ref() - .and_then(|s| serde_json::from_str::(s).ok()) + .and_then(|m| serde_json::from_value::(m.clone()).ok()) .map(|c| UpgradeDetails { cpu_upgrade: c.new_cpu, memory_upgrade: c.new_memory.map(|m| m / crate::GB), @@ -1142,7 +1185,7 @@ async fn v1_get_payment_invoice( }; let now = Utc::now(); - let invoice_item = ApiInvoiceItem::from_vm_payment(&payment) + let invoice_item = ApiInvoiceItem::from_subscription_payment(&payment) .map_err(|_| "Failed to create formatted invoice item")?; let mut html = Cursor::new(Vec::new()); @@ -1161,7 +1204,8 @@ async fn v1_get_payment_invoice( payment.amount + payment.tax + payment.processing_fee, ) .to_string(), - payment: payment.into(), + payment: ApiVmPayment::from_subscription_payment(payment, vm_id_for_payment) + .map_err(|_| "Failed to parse payment data")?, invoice_item, npub: nostr_sdk::PublicKey::from_slice(&user.pubkey) .map_err(|_| "Invalid pubkey")? @@ -1181,6 +1225,7 @@ async fn v1_payment_history( auth: Nip98Auth, State(this): State, Path(id): Path, + Query(q): Query, ) -> ApiResult> { let pubkey = auth.event.pubkey.to_bytes(); let uid = this.db.upsert_user(&pubkey).await?; @@ -1189,8 +1234,19 @@ async fn v1_payment_history( return ApiData::err("VM does not belong to you"); } - let payments = this.db.list_vm_payment(id).await?; - ApiData::ok(payments.into_iter().map(|i| i.into()).collect()) + let payments = { + let limit = q.limit.unwrap_or(50); + let offset = q.offset.unwrap_or(0); + this.db + .list_vm_subscription_payments_paginated(id, limit, offset) + .await? + }; + ApiData::ok( + payments + .into_iter() + .map(|p| ApiVmPayment::from_subscription_payment(p, id)) + .collect::>>()?, + ) } /// List action history of a VM @@ -1244,8 +1300,9 @@ async fn v1_vm_upgrade_quote( // Calculate the upgrade cost and new renewal cost match this - .provisioner - .calculate_upgrade_cost( + .sub_handler + .pricing_engine() + .calculate_vm_upgrade_cost( id, &cfg, q.method @@ -1287,8 +1344,8 @@ async fn v1_vm_upgrade( // Create upgrade payment let payment = this - .provisioner - .create_upgrade_payment( + .sub_handler + .create_vm_upgrade_payment( id, &cfg, q.method @@ -1298,7 +1355,7 @@ async fn v1_vm_upgrade( .await?; // Note: The actual upgrade happens after payment is confirmed - ApiData::ok(payment.into()) + ApiData::ok(ApiVmPayment::from_subscription_payment(payment, id)?) } async fn get_user_vm(auth: &Nip98Auth, this: &RouterState, id: u64) -> Result<(u64, Vm), ApiError> { diff --git a/lnvps_api/src/api/subscriptions.rs b/lnvps_api/src/api/subscriptions.rs index 9d9fc77..9d866ff 100644 --- a/lnvps_api/src/api/subscriptions.rs +++ b/lnvps_api/src/api/subscriptions.rs @@ -8,7 +8,7 @@ use chrono::Utc; use lnvps_api_common::{ ApiData, ApiPaginatedData, ApiPaginatedResult, ApiResult, Nip98Auth, PageQuery, }; -use lnvps_db::{PaymentMethod, Subscription, SubscriptionLineItem, SubscriptionType}; +use lnvps_db::{IntervalType, PaymentMethod, Subscription, SubscriptionLineItem, SubscriptionType}; use std::str::FromStr; pub fn router() -> Router { @@ -44,15 +44,13 @@ async fn v1_list_subscriptions( let limit = q.limit.unwrap_or(50).min(100); let offset = q.offset.unwrap_or(0); - let all_subscriptions = this.db.list_subscriptions_by_user(uid).await?; - let total = all_subscriptions.len() as u64; + let (page, total) = this + .db + .list_subscriptions_paginated(Some(uid), limit, offset) + .await?; let mut subscriptions = Vec::new(); - for subscription in all_subscriptions - .into_iter() - .skip(offset as usize) - .take(limit as usize) - { + for subscription in page { subscriptions .push(ApiSubscription::from_subscription(this.db.as_ref(), subscription).await?); } @@ -98,15 +96,13 @@ pub async fn v1_list_subscription_payments( let limit = q.limit.unwrap_or(50).min(100); let offset = q.offset.unwrap_or(0); - let all_payments = this.db.list_subscription_payments(id).await?; - let total = all_payments.len() as u64; + let (page, total) = this + .db + .list_subscription_payments_paginated(id, limit, offset) + .await?; - let payments: Vec = all_payments - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .map(ApiSubscriptionPayment::from) - .collect(); + let payments: Vec = + page.into_iter().map(ApiSubscriptionPayment::from).collect(); ApiPaginatedData::ok(payments, total, limit, offset) } @@ -212,7 +208,7 @@ async fn v1_create_subscription( let company_id = derived_company_id .ok_or_else(|| anyhow::anyhow!("Could not determine company from line items"))?; - // Create the subscription (always monthly interval) + // Create the subscription (always monthly interval for IP/ASN/DNS subscriptions) let subscription = Subscription { id: 0, // Will be set by database user_id: uid, @@ -222,7 +218,10 @@ async fn v1_create_subscription( created: Utc::now(), expires: None, // Will be set after first payment is_active: false, // Inactive until first payment + is_setup: false, // Set to true once purchase payment is confirmed currency, + interval_amount: 1, + interval_type: IntervalType::Month, setup_fee: total_setup_fee, auto_renewal_enabled: auto_renewal, external_id: None, @@ -235,7 +234,7 @@ async fn v1_create_subscription( |(name, description, amount, setup_amount, subscription_type, configuration)| { SubscriptionLineItem { id: 0, - subscription_id: 0, // Will be set below + subscription_id: 0, // Will be set by insert subscription_type, name, description, @@ -248,7 +247,7 @@ async fn v1_create_subscription( .collect(); // Insert subscription and line items in a single transaction - let subscription_id = this + let (subscription_id, _line_item_ids) = this .db .insert_subscription_with_line_items(&subscription, line_items) .await?; @@ -291,8 +290,8 @@ async fn v1_renew_subscription( // Generate payment via provisioner let payment = this - .provisioner - .renew_subscription(id, method) + .sub_handler + .renew_subscription(id, method, 1) .await .map_err(|e| anyhow::anyhow!("Failed to generate payment: {}", e))?; diff --git a/lnvps_api/src/api/webhook.rs b/lnvps_api/src/api/webhook.rs index 644c69b..4e4350f 100644 --- a/lnvps_api/src/api/webhook.rs +++ b/lnvps_api/src/api/webhook.rs @@ -18,6 +18,11 @@ pub fn router() -> Router { router = router.route("/api/v1/webhook/revolut", any(send_webhook)); } + #[cfg(feature = "stripe")] + { + router = router.route("/api/v1/webhook/stripe", any(send_webhook)); + } + router } diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index 26151ee..5892217 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -6,7 +6,7 @@ use lnvps_api::dvm::start_dvms; use lnvps_api::payments::listen_all_payments; use lnvps_api::settings::Settings; use lnvps_api::worker::Worker; -use lnvps_api_common::VmHistoryLogger; +use lnvps_api_common::{ChannelWorkCommander, RedisWorkCommander, VmHistoryLogger, WorkCommander}; use lnvps_api_common::{VmStateCache, WorkJob, make_exchange_service}; use std::fmt::{Display, Formatter}; @@ -16,12 +16,13 @@ use nostr_sdk::{Client, Keys}; use axum::Router; use lnvps_api::api::*; +use lnvps_api::subscription::SubscriptionHandler; use payments_rs::lightning::setup_crypto_provider; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use tokio::net::TcpListener; +use tokio::net::{TcpListener, TcpSocket}; use tower_http::cors::CorsLayer; #[derive(Parser)] @@ -85,7 +86,7 @@ async fn main() -> Result<(), Error> { let db = LNVpsDbMysql::new(&settings.db).await?; db.migrate().await?; #[cfg(debug_assertions)] - if std::env::var("LNVPS_DEV_SETUP").is_ok() { + if std::env::var("LNVPS_NO_DEV_SETUP").is_err() { let setup_script = include_str!("../../dev_setup.sql"); db.execute(setup_script).await?; info!("Executed dev_setup.sql"); @@ -110,26 +111,41 @@ async fn main() -> Result<(), Error> { } else { VmStateCache::new() }; - let vm_history = Arc::new(VmHistoryLogger::new(db.clone())); - let provisioner = settings.get_provisioner(db.clone(), node.clone(), exchange.clone()); - provisioner.init().await?; + let vm_history = VmHistoryLogger::new(db.clone()); - // run data migrations - run_data_migrations(db.clone(), provisioner.clone(), &settings).await?; + let work_commander: Arc = if let Some(redis_config) = &settings.redis { + Arc::new(RedisWorkCommander::new(&redis_config.url, "workers", "api-worker").await?) + } else { + Arc::new(ChannelWorkCommander::new()) + }; + + let sub_handler = SubscriptionHandler::new( + settings.clone(), + db.clone(), + node.clone(), + exchange.clone(), + work_commander.clone(), + status.clone(), + )?; + sub_handler.vm_provisioner().init().await?; let worker = Worker::new( db.clone(), - provisioner.clone(), + work_commander.clone(), + sub_handler.clone(), &settings, status.clone(), nostr_client.clone(), ) .await?; - let mode = args.mode.unwrap_or(vec![ExecMode::Worker, ExecMode::Api]); if mode.contains(&ExecMode::Worker) { + // Data migrations touch hosts, ARP tables, DNS, etc. — worker concerns only. + run_data_migrations(db.clone(), sub_handler.vm_provisioner(), &settings).await?; + tasks.push(worker.spawn_job_interval(WorkJob::CheckVms, Duration::from_secs(30))); + tasks.push(worker.spawn_job_interval(WorkJob::CheckSubscriptions, Duration::from_secs(30))); tasks.push(worker.spawn_handler_loop()); // check all nostr domains every 10 minutes for CNAME entries (enable/disable as needed) @@ -139,11 +155,14 @@ async fn main() -> Result<(), Error> { worker.spawn_job_interval(WorkJob::CheckNostrDomains, Duration::from_secs(600)), ); } + + // check vms now to get current state + worker.send(WorkJob::CheckVms).await?; } // setup payment handlers tasks.extend( - listen_all_payments(&settings, node.clone(), db.clone(), worker.commander()).await?, + listen_all_payments(&settings, node.clone(), db.clone(), sub_handler.clone()).await?, ); // refresh rates every 1min @@ -165,7 +184,7 @@ async fn main() -> Result<(), Error> { #[cfg(feature = "nostr-dvm")] { let nostr_client = nostr_client.unwrap(); - tasks.push(start_dvms(nostr_client.clone(), provisioner.clone())); + tasks.push(start_dvms(nostr_client.clone(), sub_handler.clone())); } // request for host info to be patched @@ -176,7 +195,7 @@ async fn main() -> Result<(), Error> { Some(i) => i.parse()?, None => SocketAddr::new(IpAddr::from([0, 0, 0, 0]), 8000), }; - let listener = TcpListener::bind(ip).await?; + let listener = bind_address(ip).await?; info!("Listening on {}", ip); let mut router = Router::new() .merge(docs_router()) @@ -223,7 +242,7 @@ async fn main() -> Result<(), Error> { .with_state(RouterState { db, state: status, - provisioner, + sub_handler, history: vm_history, settings, rates: exchange, @@ -242,3 +261,10 @@ async fn main() -> Result<(), Error> { } Ok(()) } + +async fn bind_address(address: SocketAddr) -> std::io::Result { + let socket = TcpSocket::new_v4()?; + socket.set_reuseaddr(true)?; + socket.bind(address)?; + socket.listen(1024) +} diff --git a/lnvps_api/src/data_migration/dns.rs b/lnvps_api/src/data_migration/dns.rs index 2769445..cb12f7d 100644 --- a/lnvps_api/src/data_migration/dns.rs +++ b/lnvps_api/src/data_migration/dns.rs @@ -15,7 +15,7 @@ pub struct DnsDataMigration { impl DnsDataMigration { pub fn new(db: Arc, settings: &Settings) -> Option { - let dns = settings.get_dns().ok().flatten()?; + let dns = settings.get_dns()?; Some(Self { db, dns, diff --git a/lnvps_api/src/data_migration/ip6_init.rs b/lnvps_api/src/data_migration/ip6_init.rs index c1c5455..b9d88e6 100644 --- a/lnvps_api/src/data_migration/ip6_init.rs +++ b/lnvps_api/src/data_migration/ip6_init.rs @@ -1,5 +1,5 @@ use crate::data_migration::DataMigration; -use crate::provisioner::{LNVpsProvisioner, NetworkProvisioner}; +use crate::provisioner::{NetworkProvisioner, VmProvisioner}; use chrono::Utc; use ipnetwork::IpNetwork; use lnvps_db::LNVpsDb; @@ -11,11 +11,11 @@ use std::sync::Arc; pub struct Ip6InitDataMigration { db: Arc, - provisioner: Arc, + provisioner: VmProvisioner, } impl Ip6InitDataMigration { - pub fn new(db: Arc, provisioner: Arc) -> Ip6InitDataMigration { + pub fn new(db: Arc, provisioner: VmProvisioner) -> Ip6InitDataMigration { Self { db, provisioner } } } @@ -28,7 +28,21 @@ impl DataMigration for Ip6InitDataMigration { let net = NetworkProvisioner::new(db.clone()); let vms = db.list_vms().await?; for vm in vms { - if vm.expires < Utc::now() { + // Skip expired VMs — check subscription expiry + let sub_active = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await + .ok() + .and_then(|li| Some(li.subscription_id)); + let sub_active = if let Some(sub_id) = sub_active { + db.get_subscription(sub_id) + .await + .map(|s| s.expires.map(|e| e > Utc::now()).unwrap_or(false)) + .unwrap_or(false) + } else { + false + }; + if !sub_active { continue; } // skip VM with no assigned mac @@ -47,7 +61,7 @@ impl DataMigration for Ip6InitDataMigration { if let Some(mut v6) = ips_pick.ip6 { info!("Assigning ip {} to vm {}", v6.ip, vm.id); let mut assignment = - LNVpsProvisioner::v6_to_allocation(&mut v6, vm.id, &vm.mac_address)?; + VmProvisioner::v6_to_allocation(&mut v6, vm.id, &vm.mac_address)?; provisioner .network .save_ip_assignment(&mut assignment) diff --git a/lnvps_api/src/data_migration/mod.rs b/lnvps_api/src/data_migration/mod.rs index 7f6d24e..0bb5fbe 100644 --- a/lnvps_api/src/data_migration/mod.rs +++ b/lnvps_api/src/data_migration/mod.rs @@ -4,7 +4,7 @@ use crate::data_migration::encryption_migration::EncryptionDataMigration; use crate::data_migration::ip6_init::Ip6InitDataMigration; use crate::data_migration::payment_method_config::PaymentMethodConfigMigration; use crate::data_migration::ssh_key_migration::SshKeyMigration; -use crate::provisioner::LNVpsProvisioner; +use crate::provisioner::VmProvisioner; use crate::settings::Settings; use anyhow::Result; use lnvps_db::LNVpsDb; @@ -27,7 +27,7 @@ pub trait DataMigration: Send + Sync { pub async fn run_data_migrations( db: Arc, - lnvps: Arc, + lnvps: VmProvisioner, settings: &Settings, ) -> Result<()> { let mut migrations: Vec> = vec![]; diff --git a/lnvps_api/src/dvm/lnvps.rs b/lnvps_api/src/dvm/lnvps.rs index 807900d..eaf33a1 100644 --- a/lnvps_api/src/dvm/lnvps.rs +++ b/lnvps_api/src/dvm/lnvps.rs @@ -1,6 +1,8 @@ use crate::dvm::{DVMHandler, DVMJobRequest, build_status_for_job}; -use crate::provisioner::LNVpsProvisioner; +use crate::provisioner::VmProvisioner; +use crate::subscription::SubscriptionHandler; use anyhow::Context; +use lnvps_api_common::VmStateCache; use lnvps_db::{ DiskInterface, DiskType, OsDistribution, PaymentMethod, UserSshKey, VmCustomTemplate, }; @@ -10,17 +12,16 @@ use ssh_key::PublicKey; use std::future::Future; use std::pin::Pin; use std::str::FromStr; -use std::sync::Arc; pub struct LnvpsDvm { client: Client, - provisioner: Arc, + sub_handler: SubscriptionHandler, } impl LnvpsDvm { - pub fn new(provisioner: Arc, client: Client) -> LnvpsDvm { + pub fn new(sub_handler: SubscriptionHandler, client: Client) -> LnvpsDvm { Self { - provisioner, + sub_handler, client, } } @@ -31,7 +32,7 @@ impl DVMHandler for LnvpsDvm { &mut self, request: DVMJobRequest, ) -> Pin> + Send>> { - let provisioner = self.provisioner.clone(); + let sub_handler = self.sub_handler.clone(); let client = self.client.clone(); Box::pin(async move { let default_disk = "ssd".to_string(); @@ -62,7 +63,7 @@ impl DVMHandler for LnvpsDvm { .context("missing os_version parameter")?; let region = request.params.get("region"); - let db = provisioner.get_db(); + let db = sub_handler.db(); let host_region = if let Some(r) = region { db.get_host_region_by_name(r).await? } else { @@ -130,10 +131,16 @@ impl DVMHandler for LnvpsDvm { .find(|i| i.distribution == image && i.version == *os_version) .context("no os image found")?; - let vm = provisioner + let vm = sub_handler + .vm_provisioner() .provision_custom(uid, template, image.id, ssh_key_id, None) .await?; - let invoice = provisioner.renew(vm.id, PaymentMethod::Lightning).await?; + let line_item = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let invoice = sub_handler + .renew_subscription(line_item.subscription_id, PaymentMethod::Lightning, 1) + .await?; let mut payment = build_status_for_job( &request, @@ -159,9 +166,12 @@ mod tests { use crate::dvm::parse_job_request; use crate::mocks::MockNode; use crate::settings::mock_settings; - use lnvps_api_common::{ExchangeRateService, MockDb, MockExchangeRate, Ticker}; + use lnvps_api_common::{ + ChannelWorkCommander, ExchangeRateService, MockDb, MockExchangeRate, Ticker, + }; use lnvps_db::{VmCustomPricing, VmCustomPricingDisk}; use nostr_sdk::{EventBuilder, Keys, Kind}; + use std::sync::Arc; #[tokio::test] #[ignore] @@ -213,13 +223,14 @@ mod tests { } let settings = mock_settings(); - let provisioner = Arc::new(LNVpsProvisioner::new( + let provisioner = SubscriptionHandler::new( settings, db.clone(), node.clone(), exch.clone(), - None, - )); + Arc::new(ChannelWorkCommander::new()), + VmStateCache::new(), + )?; let keys = Keys::generate(); let empty_client = Client::new(keys.clone()); empty_client.add_relay("wss://nos.lol").await?; diff --git a/lnvps_api/src/dvm/mod.rs b/lnvps_api/src/dvm/mod.rs index 8b34c16..c1889bc 100644 --- a/lnvps_api/src/dvm/mod.rs +++ b/lnvps_api/src/dvm/mod.rs @@ -1,7 +1,8 @@ mod lnvps; use crate::dvm::lnvps::LnvpsDvm; -use crate::provisioner::LNVpsProvisioner; +use crate::provisioner::VmProvisioner; +use crate::subscription::SubscriptionHandler; use anyhow::Result; use log::{error, info, warn}; use nostr_sdk::prelude::DataVendingMachineStatus; @@ -244,9 +245,9 @@ fn parse_job_request(event: &Event) -> Result { }) } -pub fn start_dvms(client: Client, provisioner: Arc) -> JoinHandle<()> { +pub fn start_dvms(client: Client, sub_handler: SubscriptionHandler) -> JoinHandle<()> { tokio::spawn(async move { - let dvm = LnvpsDvm::new(provisioner, client.clone()); + let dvm = LnvpsDvm::new(sub_handler, client.clone()); if let Err(e) = listen_for_jobs(client, Kind::from_u16(5999), Box::new(dvm)).await { error!("Error listening jobs: {}", e); } diff --git a/lnvps_api/src/host/dummy_host.rs b/lnvps_api/src/host/dummy_host.rs new file mode 100644 index 0000000..47011d3 --- /dev/null +++ b/lnvps_api/src/host/dummy_host.rs @@ -0,0 +1,371 @@ +use crate::host::{ + FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostDiskInfo, + VmHostInfo, +}; +use async_trait::async_trait; +use chrono::Utc; +use lnvps_api_common::retry::OpResult; +use lnvps_api_common::{GB, PB, TB, VmRunningState, VmRunningStates, op_fatal}; +use lnvps_db::{Vm, VmOsImage}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; +use tokio::sync::Mutex; + +/// Per-VM state tracked by the mock host. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct MockVm { + state: VmRunningStates, + /// Monotonically increasing uptime counter (seconds). Reset to 0 on stop. + uptime_secs: u64, + /// Simulated cumulative network-in bytes. + net_in: u64, + /// Simulated cumulative network-out bytes. + net_out: u64, + /// Simulated cumulative disk-read bytes. + disk_read: u64, + /// Simulated cumulative disk-write bytes. + disk_write: u64, + /// Unix timestamp of the last `tick` call (used to advance counters). + last_tick: u64, +} + +impl MockVm { + /// Advance the simulated counters based on elapsed wall-clock time. + /// Only accumulates when the VM is Running. + fn tick(&mut self) { + let now = now_secs(); + let elapsed = now.saturating_sub(self.last_tick); + self.last_tick = now; + + if self.state == VmRunningStates::Running { + self.uptime_secs += elapsed; + // Randomise per-second rates within realistic ranges: + // net_in: 0 – 2 Mbps (0 – 250 KB/s) + // net_out: 0 – 1 Mbps (0 – 125 KB/s) + // disk_read: 0 – 50 MB/s + // disk_write:0 – 25 MB/s + self.net_in += elapsed * (rand::random::() % 250_000); + self.net_out += elapsed * (rand::random::() % 125_000); + self.disk_read += elapsed * (rand::random::() % 50_000_000); + self.disk_write += elapsed * (rand::random::() % 25_000_000); + } + } + + fn to_running_state(&self) -> VmRunningState { + // Vary CPU and memory usage with a simple pseudo-random pattern + // based on uptime so the values change over time but stay realistic. + let cpu_usage = if self.state == VmRunningStates::Running { + // oscillates between ~5 % and ~35 % + 0.05 + 0.30 * ((self.uptime_secs % 60) as f32 / 60.0) + } else { + 0.0 + }; + let mem_usage = if self.state == VmRunningStates::Running { + // slowly rises from 20 % to 60 % then wraps + 0.20 + 0.40 * ((self.uptime_secs % 300) as f32 / 300.0) + } else { + 0.0 + }; + + VmRunningState { + timestamp: now_secs(), + state: self.state.clone(), + cpu_usage, + mem_usage, + uptime: self.uptime_secs, + net_in: self.net_in, + net_out: self.net_out, + disk_write: self.disk_write, + disk_read: self.disk_read, + } + } +} + +fn now_secs() -> u64 { + Utc::now().timestamp() as u64 +} + +// --------------------------------------------------------------------------- + +/// A mock `VmHostClient` that simulates VM lifecycle without contacting any +/// real hypervisor. +/// +/// Two construction modes: +/// - [`DummyVmHost::new()`] — fresh independent in-memory map; used by tests. +/// - [`DummyVmHost::new_persistent()`] — process-wide shared map backed by a +/// JSON file in `/tmp`; used by the real API service so state survives +/// restarts. +#[derive(Debug, Clone)] +pub struct DummyVmHost { + vms: Arc>>, + /// When `true`, mutations are flushed to [`STATE_FILE`]. + persist: bool, +} + +impl Default for DummyVmHost { + fn default() -> Self { + Self::new() + } +} + +/// Path used to persist dummy-host VM state across restarts. +const STATE_FILE: &str = "/tmp/lnvps_dummy_vms.json"; + +impl DummyVmHost { + /// Create a fresh, isolated in-memory host. State is never written to + /// disk. Use this in tests. + pub fn new() -> Self { + Self { + vms: Arc::new(Mutex::new(HashMap::new())), + persist: false, + } + } + + /// Create (or reuse) the process-wide persistent host. State is loaded + /// from [`STATE_FILE`] on first call and flushed after every mutation. + /// Use this in the real API service. + pub fn new_persistent() -> Self { + static LAZY_VMS: LazyLock>>> = LazyLock::new(|| { + let map = DummyVmHost::load_from_file().unwrap_or_default(); + Arc::new(Mutex::new(map)) + }); + Self { + vms: LAZY_VMS.clone(), + persist: true, + } + } + + /// Load the VM map from the JSON state file, if it exists. + fn load_from_file() -> Option> { + let data = std::fs::read_to_string(STATE_FILE).ok()?; + serde_json::from_str(&data).ok() + } + + /// Flush the current VM map to disk. No-op when `persist` is false. + async fn save(&self) { + if !self.persist { + return; + } + let vms = self.vms.lock().await; + if let Ok(json) = serde_json::to_string(&*vms) { + let _ = std::fs::write(STATE_FILE, json); + } + } +} + +#[async_trait] +impl VmHostClient for DummyVmHost { + async fn get_info(&self) -> OpResult { + Ok(VmHostInfo { + cpu: 100, + memory: 1 * TB, + disks: vec![VmHostDiskInfo { + name: "mock-disk".to_string(), + size: 1 * PB, + used: 0, + }], + }) + } + + async fn download_os_image(&self, _image: &VmOsImage) -> OpResult<()> { + Ok(()) + } + + async fn generate_mac(&self, _vm: &Vm) -> OpResult { + Ok(format!( + "ff:ff:ff:{:02x}:{:02x}:{:02x}", + rand::random::(), + rand::random::(), + rand::random::(), + )) + } + + /// Register the VM under its DB id in the `Creating` state, then + /// transition it to `Stopped` after a real async delay of 10–60 seconds, + /// simulating provisioning time on a real hypervisor. + async fn create_vm(&self, cfg: &FullVmInfo) -> OpResult<()> { + let vm_id = cfg.vm.id; + + // when using dummy host in real dev env, add a small delete in create_vm + #[cfg(not(test))] + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + { + let mut vms = self.vms.lock().await; + vms.insert( + vm_id, + MockVm { + state: VmRunningStates::Stopped, + ..MockVm::default() + }, + ); + } + self.save().await; + + Ok(()) + } + + async fn delete_vm(&self, vm: &Vm) -> OpResult<()> { + { + let mut vms = self.vms.lock().await; + vms.remove(&vm.id); + } + self.save().await; + Ok(()) + } + + async fn start_vm(&self, vm: &Vm) -> OpResult<()> { + { + let mut vms = self.vms.lock().await; + if let Some(m) = vms.get_mut(&vm.id) { + m.tick(); + m.state = VmRunningStates::Running; + } + } + self.save().await; + Ok(()) + } + + async fn stop_vm(&self, vm: &Vm) -> OpResult<()> { + { + let mut vms = self.vms.lock().await; + if let Some(m) = vms.get_mut(&vm.id) { + m.tick(); + m.state = VmRunningStates::Stopped; + m.uptime_secs = 0; + } + } + self.save().await; + Ok(()) + } + + async fn reset_vm(&self, vm: &Vm) -> OpResult<()> { + { + let mut vms = self.vms.lock().await; + if let Some(m) = vms.get_mut(&vm.id) { + m.tick(); + m.uptime_secs = 0; + m.state = VmRunningStates::Running; + } + } + self.save().await; + Ok(()) + } + + async fn unlink_primary_disk(&self, _vm: &Vm) -> OpResult<()> { + Ok(()) + } + + async fn import_template_disk(&self, _cfg: &FullVmInfo) -> OpResult<()> { + Ok(()) + } + + async fn resize_disk(&self, _cfg: &FullVmInfo) -> OpResult<()> { + Ok(()) + } + + async fn configure_vm(&self, _vm: &FullVmInfo) -> OpResult<()> { + Ok(()) + } + + async fn patch_firewall(&self, _cfg: &FullVmInfo) -> OpResult<()> { + Ok(()) + } + + /// Return the current state of a single VM. + /// + /// If the VM is not registered (e.g. it was deleted or never created), + /// return a Stopped state rather than a fatal error so the worker does not + /// endlessly try to re-spawn it. + async fn get_vm_state(&self, vm: &Vm) -> OpResult { + let mut vms = self.vms.lock().await; + if let Some(m) = vms.get_mut(&vm.id) { + m.tick(); + Ok(m.to_running_state()) + } else { + op_fatal!("Vm not found") + } + } + + /// Return states for all registered VMs. + /// + /// The worker uses the returned `u64` key as the VM's DB id to look up + /// the corresponding row, so we must use `vm.id` (not a hypervisor id). + async fn get_all_vm_states(&self) -> OpResult> { + let mut vms = self.vms.lock().await; + let states = vms + .iter_mut() + .map(|(vm_id, m)| { + m.tick(); + (*vm_id, m.to_running_state()) + }) + .collect(); + Ok(states) + } + + /// Return synthetic time-series data for the requested period. + /// + /// Generates one data point per minute for the period length so callers + /// receive a non-empty list with plausible values. + async fn get_time_series_data( + &self, + _vm: &Vm, + series: TimeSeries, + ) -> OpResult> { + let points: u64 = match series { + TimeSeries::Hourly => 60, + TimeSeries::Daily => 24 * 4, // 15-min buckets + TimeSeries::Weekly => 7 * 24, + TimeSeries::Monthly => 30 * 24, + TimeSeries::Yearly => 365, + }; + + let now = now_secs(); + let interval = match series { + TimeSeries::Hourly => 60, + TimeSeries::Daily => 900, + TimeSeries::Weekly => 3600, + TimeSeries::Monthly => 3600, + TimeSeries::Yearly => 86400, + }; + + let data = (0..points) + .map(|i| { + let ts = now.saturating_sub((points - i) * interval); + // Simple sinusoidal CPU/mem pattern so graphs look live + let phase = (i as f32) / (points as f32); + let cpu = 0.05 + 0.30 * (std::f32::consts::TAU * phase).sin().abs(); + let mem = 0.20 + 0.40 * (std::f32::consts::PI * phase).sin().abs(); + TimeSeriesData { + timestamp: ts, + cpu, + memory: mem, + memory_size: 1 * GB, + net_in: (64_000.0 * cpu) as f32, + net_out: (32_000.0 * cpu) as f32, + disk_write: (2_500_000.0 * cpu) as f32, + disk_read: (5_000_000.0 * cpu) as f32, + } + }) + .collect(); + + Ok(data) + } + + async fn connect_terminal(&self, _vm: &Vm) -> OpResult { + use tokio::sync::mpsc::channel; + let (client_tx, client_rx) = channel::>(256); + let (server_tx, mut server_rx) = channel::>(256); + tokio::spawn(async move { + while let Some(buf) = server_rx.recv().await { + if client_tx.send(buf).await.is_err() { + break; + } + } + }); + Ok(TerminalStream { + rx: client_rx, + tx: server_tx, + }) + } +} diff --git a/lnvps_api/src/host/mod.rs b/lnvps_api/src/host/mod.rs index 889b8cb..13494fc 100644 --- a/lnvps_api/src/host/mod.rs +++ b/lnvps_api/src/host/mod.rs @@ -18,6 +18,8 @@ mod libvirt; #[cfg(feature = "proxmox")] mod proxmox; +pub(crate) mod dummy_host; + pub struct TerminalStream { pub rx: Receiver>, pub tx: Sender>, @@ -93,9 +95,6 @@ pub async fn get_vm_host_client( } pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result> { - #[cfg(test)] - return Ok(Arc::new(crate::mocks::MockVmHost::new())); - Ok(match host.kind.clone() { #[cfg(feature = "proxmox")] VmHostKind::Proxmox if cfg.proxmox.is_some() => { @@ -114,6 +113,13 @@ pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result { + if cfg!(test) { + Arc::new(dummy_host::DummyVmHost::new()) + } else { + Arc::new(dummy_host::DummyVmHost::new_persistent()) + } + }, _ => bail!("Unknown host config: {}", host.kind), }) } @@ -344,14 +350,12 @@ mod tests { image_id: 1, template_id: Some(template.id), custom_template_id: None, + subscription_line_item_id: 0, ssh_key_id: 1, - created: Default::default(), - expires: Default::default(), disk_id: 1, mac_address: "ff:ff:ff:ff:ff:fe".to_string(), deleted: false, ref_code: None, - auto_renewal_enabled: false, disabled: false, }, host: VmHost { diff --git a/lnvps_api/src/host/proxmox.rs b/lnvps_api/src/host/proxmox.rs index f748992..b227d58 100644 --- a/lnvps_api/src/host/proxmox.rs +++ b/lnvps_api/src/host/proxmox.rs @@ -379,7 +379,10 @@ impl ProxmoxClient { .map_err(OpError::Transient)?; if existing.trim() != snippet_content.trim() { - let parent = snippet_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("/tmp"); + let parent = snippet_path + .rsplit_once('/') + .map(|(p, _)| p) + .unwrap_or("/tmp"); ssh.execute(&format!("mkdir -p '{parent}'")) .await .map_err(OpError::Transient)?; @@ -819,11 +822,7 @@ impl ProxmoxClient { } } - fn make_config( - &self, - value: &FullVmInfo, - vendor_snippet: Option<&str>, - ) -> Result { + fn make_config(&self, value: &FullVmInfo, vendor_snippet: Option<&str>) -> Result { let ip_config = value .ips .iter() @@ -2595,14 +2594,7 @@ mod tests { arch: "x86_64".to_string(), firewall_config: None, }; - let p = ProxmoxClient::new( - "http://localhost:8006".parse()?, - "", - "", - None, - q_cfg, - None, - ); + let p = ProxmoxClient::new("http://localhost:8006".parse()?, "", "", None, q_cfg, None); // With vendor snippet let vm = p.make_config(&cfg, Some("local:snippets/lnvps-vendor.yaml"))?; diff --git a/lnvps_api/src/lib.rs b/lnvps_api/src/lib.rs index 4e7ee35..adcaf6d 100644 --- a/lnvps_api/src/lib.rs +++ b/lnvps_api/src/lib.rs @@ -10,6 +10,7 @@ pub mod router; pub mod settings; #[cfg(feature = "proxmox")] pub mod ssh_client; +pub mod subscription; pub mod worker; #[cfg(test)] diff --git a/lnvps_api/src/mocks.rs b/lnvps_api/src/mocks.rs index 89e9da4..47be78f 100644 --- a/lnvps_api/src/mocks.rs +++ b/lnvps_api/src/mocks.rs @@ -3,7 +3,11 @@ use crate::dns::{BasicRecord, DnsServer, RecordType}; use crate::host::{ FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostInfo, }; +use crate::host::dummy_host::DummyVmHost; use crate::router::{ArpEntry, Router}; + +/// Type alias so tests can refer to the in-memory VM host as `MockVmHost`. +pub type MockVmHost = DummyVmHost; use anyhow::{Context, anyhow, bail, ensure}; use async_trait::async_trait; use bitcoin::hashes::Hash; @@ -21,8 +25,8 @@ use lnvps_db::nostr::LNVPSNostrDb; use lnvps_db::{ AccessPolicy, Company, DiskInterface, DiskType, IpRange, IpRangeAllocationMode, LNVpsDb, NostrDomain, NostrDomainHandle, OsDistribution, User, UserSshKey, Vm, VmCostPlan, - VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, - VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, + VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, VmHost, VmHostDisk, + VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; use nostr_sdk::Timestamp; use payments_rs::lightning::{ @@ -229,183 +233,6 @@ impl LightningNode for MockNode { } } -#[derive(Debug, Clone)] -pub struct MockVmHost { - vms: Arc>>, -} - -#[derive(Debug, Clone)] -struct MockVm { - pub state: VmRunningStates, -} - -impl Default for MockVmHost { - fn default() -> Self { - Self::new() - } -} - -impl MockVmHost { - pub fn new() -> Self { - static LAZY_VMS: LazyLock>>> = - LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); - Self { - vms: LAZY_VMS.clone(), - } - } -} - -#[async_trait] -impl VmHostClient for MockVmHost { - async fn get_info(&self) -> OpResult { - todo!() - } - - async fn download_os_image(&self, image: &VmOsImage) -> OpResult<()> { - Ok(()) - } - - async fn generate_mac(&self, vm: &Vm) -> OpResult { - Ok(format!( - "ff:ff:ff:{}:{}:{}", - hex::encode([rand::random::()]), - hex::encode([rand::random::()]), - hex::encode([rand::random::()]), - )) - } - - async fn start_vm(&self, vm: &Vm) -> OpResult<()> { - let mut vms = self.vms.lock().await; - if let Some(mut vm) = vms.get_mut(&vm.id) { - vm.state = VmRunningStates::Running; - } - Ok(()) - } - - async fn stop_vm(&self, vm: &Vm) -> OpResult<()> { - let mut vms = self.vms.lock().await; - if let Some(mut vm) = vms.get_mut(&vm.id) { - vm.state = VmRunningStates::Stopped; - } - Ok(()) - } - - async fn reset_vm(&self, vm: &Vm) -> OpResult<()> { - let mut vms = self.vms.lock().await; - if let Some(mut vm) = vms.get_mut(&vm.id) { - vm.state = VmRunningStates::Running; - } - Ok(()) - } - - async fn create_vm(&self, cfg: &FullVmInfo) -> OpResult<()> { - let mut vms = self.vms.lock().await; - let max_id = *vms.keys().max().unwrap_or(&0); - vms.insert( - max_id + 1, - MockVm { - state: VmRunningStates::Stopped, - }, - ); - Ok(()) - } - - async fn delete_vm(&self, vm: &Vm) -> OpResult<()> { - let mut vms = self.vms.lock().await; - vms.remove(&vm.id); - Ok(()) - } - - async fn unlink_primary_disk(&self, vm: &Vm) -> OpResult<()> { - Ok(()) - } - - async fn import_template_disk(&self, cfg: &FullVmInfo) -> OpResult<()> { - Ok(()) - } - - async fn resize_disk(&self, cfg: &FullVmInfo) -> OpResult<()> { - // Mock implementation - just return Ok for testing - Ok(()) - } - - async fn get_vm_state(&self, vm: &Vm) -> OpResult { - let vms = self.vms.lock().await; - if let Some(vm) = vms.get(&vm.id) { - Ok(VmRunningState { - timestamp: Utc::now().timestamp() as u64, - state: vm.state.clone(), - cpu_usage: 69.0, - mem_usage: 69.0, - uptime: 100, - net_in: 69, - net_out: 69, - disk_write: 69, - disk_read: 69, - }) - } else { - op_fatal!("No vm with id {}", vm.id) - } - } - - async fn get_all_vm_states(&self) -> OpResult> { - let vms = self.vms.lock().await; - let states = vms - .iter() - .map(|(vm_id, vm)| { - ( - *vm_id, - VmRunningState { - timestamp: Utc::now().timestamp() as u64, - state: vm.state.clone(), - cpu_usage: 69.0, - mem_usage: 69.0, - uptime: 100, - net_in: 69, - net_out: 69, - disk_write: 69, - disk_read: 69, - }, - ) - }) - .collect(); - Ok(states) - } - - async fn configure_vm(&self, vm: &FullVmInfo) -> OpResult<()> { - Ok(()) - } - - async fn patch_firewall(&self, cfg: &FullVmInfo) -> OpResult<()> { - todo!() - } - - async fn get_time_series_data( - &self, - vm: &Vm, - series: TimeSeries, - ) -> OpResult> { - Ok(vec![]) - } - - async fn connect_terminal(&self, vm: &Vm) -> OpResult { - use tokio::sync::mpsc::channel; - let (client_tx, client_rx) = channel::>(256); - let (server_tx, mut server_rx) = channel::>(256); - tokio::spawn(async move { - while let Some(buf) = server_rx.recv().await { - if client_tx.send(buf).await.is_err() { - break; - } - } - }); - Ok(TerminalStream { - rx: client_rx, - tx: server_tx, - }) - } -} - #[derive(Clone)] pub struct MockDnsServer { pub zones: Arc>>>, @@ -425,11 +252,18 @@ impl Default for MockDnsServer { impl MockDnsServer { pub fn new() -> Self { + static LAZY_ZONES: LazyLock>>>> = + LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); Self { - zones: Arc::new(Mutex::new(HashMap::new())), + zones: LAZY_ZONES.clone(), } } + + pub async fn reset() { + Self::new().zones.lock().await.clear(); + } } + #[async_trait] impl DnsServer for MockDnsServer { async fn add_record(&self, zone_id: &str, record: &BasicRecord) -> OpResult { diff --git a/lnvps_api/src/payments/invoice.rs b/lnvps_api/src/payments/invoice.rs index c8ea491..c8892e1 100644 --- a/lnvps_api/src/payments/invoice.rs +++ b/lnvps_api/src/payments/invoice.rs @@ -1,10 +1,8 @@ -use crate::payments::handle_upgrade; +use crate::subscription::SubscriptionHandler; use anyhow::Result; -use chrono::Utc; use futures::StreamExt; -use lnvps_api_common::WorkJob; -use lnvps_api_common::{VmHistoryLogger, WorkCommander}; -use lnvps_db::{LNVpsDb, PaymentMethod, PaymentType, VmPayment}; +use lnvps_api_common::VmStateCache; +use lnvps_db::{LNVpsDb, SubscriptionPayment, SubscriptionPaymentType}; use log::{error, info, warn}; use payments_rs::lightning::{InvoiceUpdate, LightningNode}; use std::sync::Arc; @@ -12,132 +10,52 @@ use std::sync::Arc; pub struct NodeInvoiceHandler { node: Arc, db: Arc, - tx: Arc, - vm_history_logger: VmHistoryLogger, + sub_handler: SubscriptionHandler, } impl NodeInvoiceHandler { pub fn new( node: Arc, db: Arc, - tx: Arc, + sub_handler: SubscriptionHandler, ) -> Self { - let vm_history_logger = VmHistoryLogger::new(db.clone()); Self { node, - tx, + sub_handler, db, - vm_history_logger, } } async fn mark_paid(&self, id: &Vec) -> Result<()> { - let p = self.db.get_vm_payment(id).await?; - self.mark_payment_paid(&p).await + let payment = self.db.get_subscription_payment(id).await?; + self.complete(&payment).await } async fn mark_paid_ext_id(&self, external_id: &str) -> Result<()> { - let p = self.db.get_vm_payment_by_ext_id(external_id).await?; - self.mark_payment_paid(&p).await + let payment = self + .db + .get_subscription_payment_by_ext_id(external_id) + .await?; + self.complete(&payment).await } - async fn mark_payment_paid(&self, payment: &VmPayment) -> Result<()> { - // Get VM state before payment processing - let vm_before = self.db.get_vm(payment.vm_id).await?; - - self.db.vm_payment_paid(payment).await?; - - // Get VM state after payment processing - let vm_after = self.db.get_vm(payment.vm_id).await?; - - // Log payment received in VM history - let payment_metadata = serde_json::json!({ - "payment_id": hex::encode(&payment.id), - "payment_method": "lightning" - }); - - if let Err(e) = self - .vm_history_logger - .log_vm_payment_received( - payment.vm_id, - payment.amount + payment.tax + payment.processing_fee, - &payment.currency, - payment.time_value, - Some(payment_metadata), - ) - .await - { - warn!("Failed to log payment for VM {}: {}", payment.vm_id, e); - } - - // Log VM renewal if this extends the expiration - if payment.time_value > 0 - && let Err(e) = self - .vm_history_logger - .log_vm_renewed( - payment.vm_id, - None, - vm_before.expires, - vm_after.expires, - Some(payment.amount + payment.tax + payment.processing_fee), - Some(&payment.currency), - Some(serde_json::json!({ - "time_added_seconds": payment.time_value, - "payment_id": hex::encode(&payment.id) - })), - ) - .await - { - warn!("Failed to log VM {} renewal: {}", payment.vm_id, e); - } - - info!( - "VM payment {} for {}, paid", - hex::encode(&payment.id), - payment.vm_id - ); - - // Handle upgrade payments differently - trigger upgrade processing instead of just checking VM - if payment.payment_type == PaymentType::Upgrade { - handle_upgrade(payment, &self.tx, self.db.clone()).await?; - - // cancel other upgrade payments - let other_upgrades = self - .db - .list_vm_payment_by_method_and_type( - payment.vm_id, - PaymentMethod::Lightning, - PaymentType::Upgrade, - ) - .await?; - for mut ugp in other_upgrades { - if ugp.id == payment.id { - continue; - } - - ugp.expires = Utc::now(); - let hex_id = hex::encode(&ugp.id); - if let Err(e) = self.node.cancel_invoice(&ugp.id).await { - warn!("Failed to cancel invoice {}: {}", hex_id, e); - } - if let Err(e) = self.db.update_vm_payment(&ugp).await { - warn!("Failed to update invoice {}: {}", hex_id, e); - } + async fn complete(&self, payment: &SubscriptionPayment) -> Result<()> { + let result = self.sub_handler.complete_payment(&payment).await?; + for p in result.expired_competing_upgrades { + let hex_id = hex::encode(&p.id); + if let Err(e) = self.node.cancel_invoice(&p.id).await { + warn!("Failed to cancel invoice {}: {}", hex_id, e); } - } else { - // Regular renewal payment - just check the VM - self.tx - .send(WorkJob::CheckVm { - vm_id: payment.vm_id, - }) - .await?; } - Ok(()) } pub async fn listen(&mut self) -> Result<()> { - let from_ph = self.db.last_paid_invoice().await?.map(|i| i.id.clone()); + let from_ph = self + .db + .last_paid_subscription_invoice() + .await? + .map(|i| i.id.clone()); info!( "Listening for invoices from {}", from_ph @@ -174,3 +92,305 @@ impl NodeInvoiceHandler { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mocks::MockNode; + use crate::provisioner::VmProvisioner; + use crate::settings::mock_settings; + use crate::subscription::SubscriptionHandler; + use anyhow::Result; + use chrono::Utc; + use lnvps_api_common::{ChannelWorkCommander, MockDb, MockExchangeRate, WorkJob}; + use lnvps_db::{ + IntervalType, LNVpsDbBase, Subscription, SubscriptionLineItem, SubscriptionPayment, + SubscriptionPaymentType, SubscriptionType, Vm, + }; + use std::sync::Arc; + + /// Build a DB with a VM, subscription, line item and unpaid payment. + async fn setup_renewal( + time_value: u64, + payment_type: SubscriptionPaymentType, + ) -> Result<( + Arc, + Arc, + SubscriptionHandler, + SubscriptionPayment, + u64, + )> { + let db = Arc::new(MockDb::default()); + let node = Arc::new(MockNode::default()); + + // Insert a user + SSH key so insert_vm FK checks pass + let pubkey: [u8; 32] = [1u8; 32]; + let user_id = db.upsert_user(&pubkey).await?; + let ssh_key_id = db + .insert_user_ssh_key(&lnvps_db::UserSshKey { + id: 0, + name: "test".to_string(), + user_id, + created: Utc::now(), + key_data: "ssh-rsa AAA==".into(), + }) + .await?; + + // Insert subscription + let (sub_id, line_item_ids) = db + .insert_subscription_with_line_items( + &Subscription { + id: 0, + user_id, + company_id: 1, + name: "test".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: false, + is_setup: false, + currency: "BTC".to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }, + vec![SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: SubscriptionType::Vps, + name: "vm renewal".to_string(), + description: None, + amount: 1000, + setup_amount: 0, + configuration: None, + }], + ) + .await?; + + // Insert VM linked to that subscription line item + let vm_id = db + .insert_vm(&Vm { + id: 0, + host_id: 1, + user_id, + image_id: 1, + template_id: Some(1), + custom_template_id: None, + subscription_line_item_id: line_item_ids[0], + ssh_key_id, + disk_id: 1, + mac_address: "aa:bb:cc:dd:ee:ff".to_string(), + deleted: false, + ..Default::default() + }) + .await?; + + let payment = SubscriptionPayment { + id: vec![42u8; 16], + subscription_id: sub_id, + user_id, + created: Utc::now(), + expires: Utc::now() + chrono::Duration::hours(1), + amount: 1000, + currency: "BTC".to_string(), + payment_method: lnvps_db::PaymentMethod::Lightning, + payment_type, + external_data: "".to_string().into(), + external_id: None, + is_paid: false, + rate: 1.0, + time_value: Some(time_value), + metadata: None, + tax: 0, + processing_fee: 0, + paid_at: None, + }; + db.insert_subscription_payment(&payment).await?; + + let sub = SubscriptionHandler::new( + mock_settings(), + db.clone(), + node.clone(), + Arc::new(MockExchangeRate::default()), + Arc::new(ChannelWorkCommander::new()), + VmStateCache::new(), + )?; + + Ok((db, node, sub, payment, vm_id)) + } + + /// complete for a Renewal payment marks it paid and enqueues CheckVm. + #[tokio::test] + async fn test_complete_renewal_marks_paid_and_enqueues_check_vm() -> Result<()> { + let (db, node, sub, payment, vm_id) = + setup_renewal(86400, SubscriptionPaymentType::Renewal).await?; + + let handler = NodeInvoiceHandler::new(node, db.clone(), sub.clone()); + handler.complete(&payment).await?; + + // Payment should be marked paid + let payments = db.subscription_payments.lock().await; + let p = payments.iter().find(|p| p.id == payment.id).unwrap(); + assert!(p.is_paid); + drop(payments); + + // A CheckVm job should have been enqueued + let jobs = sub.work_commander().recv().await?; + assert_eq!(jobs.len(), 1); + assert!( + matches!(&jobs[0].job, WorkJob::SpawnVm { vm_id: id } if *id == vm_id), + "expected SpawnVm job, got {:?}", + jobs[0].job + ); + + Ok(()) + } + + /// complete for an Upgrade payment enqueues ProcessVmUpgrade. + #[tokio::test] + async fn test_complete_upgrade_enqueues_process_vm_upgrade() -> Result<()> { + let (db, node, sub, mut payment, vm_id) = + setup_renewal(0, SubscriptionPaymentType::Upgrade).await?; + + // Add upgrade metadata + payment.metadata = Some(serde_json::json!({ + "new_cpu": 4, + "new_memory": null, + "new_disk": null + })); + db.update_subscription_payment(&payment).await?; + + let handler = NodeInvoiceHandler::new(node, db.clone(), sub.clone()); + handler.complete(&payment).await?; + + // A ProcessVmUpgrade job should have been enqueued + let jobs = sub.work_commander().recv().await?; + assert_eq!(jobs.len(), 1); + assert!( + matches!(&jobs[0].job, WorkJob::ProcessVmUpgrade { vm_id: id, .. } if *id == vm_id), + "expected ProcessVmUpgrade job, got {:?}", + jobs[0].job + ); + + Ok(()) + } + + /// complete extends the subscription expiry for a renewal. + #[tokio::test] + async fn test_complete_extends_subscription_expiry() -> Result<()> { + let time_value = 30u64 * 24 * 3600; + let (db, node, tx, payment, _vm_id) = + setup_renewal(time_value, SubscriptionPaymentType::Renewal).await?; + + let handler = NodeInvoiceHandler::new(node, db, tx); + handler.complete(&payment).await?; + + Ok(()) // expiry extension is tested thoroughly in mock tests + } + + /// Build a DB with a non-VM (IpRange) subscription and unpaid payment. + async fn setup_ip_range_renewal() -> Result<( + Arc, + Arc, + SubscriptionHandler, + SubscriptionPayment, + )> { + let db = Arc::new(MockDb::default()); + let node = Arc::new(MockNode::default()); + + let pubkey: [u8; 32] = [2u8; 32]; + let user_id = db.upsert_user(&pubkey).await?; + + let (sub_id, _line_item_ids) = db + .insert_subscription_with_line_items( + &Subscription { + id: 0, + user_id, + company_id: 1, + name: "ip range test".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: false, + is_setup: false, + currency: "EUR".to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }, + vec![SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: SubscriptionType::IpRange, + name: "ip range".to_string(), + description: None, + amount: 500, + setup_amount: 0, + configuration: None, + }], + ) + .await?; + + let payment = SubscriptionPayment { + id: vec![99u8; 16], + subscription_id: sub_id, + user_id, + created: Utc::now(), + expires: Utc::now() + chrono::Duration::hours(1), + amount: 500, + currency: "EUR".to_string(), + payment_method: lnvps_db::PaymentMethod::Lightning, + payment_type: SubscriptionPaymentType::Renewal, + external_data: "".to_string().into(), + external_id: None, + is_paid: false, + rate: 1.0, + time_value: None, + metadata: None, + tax: 0, + processing_fee: 0, + paid_at: None, + }; + db.insert_subscription_payment(&payment).await?; + let sub = SubscriptionHandler::new( + mock_settings(), + db.clone(), + node.clone(), + Arc::new(MockExchangeRate::default()), + Arc::new(ChannelWorkCommander::new()), + VmStateCache::new(), + )?; + + Ok((db, node, sub, payment)) + } + + /// complete for a non-VM (IpRange) renewal marks it paid and dispatches CheckSubscriptions. + #[tokio::test] + async fn test_complete_non_vm_renewal_dispatches_check_subscriptions() -> Result<()> { + let (db, node, sub, payment) = setup_ip_range_renewal().await?; + + let handler = NodeInvoiceHandler::new(node, db.clone(), sub.clone()); + handler.complete(&payment).await?; + + // Payment should be marked paid + let payments = db.subscription_payments.lock().await; + let p = payments.iter().find(|p| p.id == payment.id).unwrap(); + assert!(p.is_paid, "payment should be marked paid"); + drop(payments); + + // CheckSubscriptions should be dispatched (not CheckVm) + let jobs = sub.work_commander().recv().await?; + assert_eq!(jobs.len(), 1, "expected exactly one work job"); + assert!( + matches!(&jobs[0].job, WorkJob::CheckSubscriptions), + "expected CheckSubscriptions job for non-VM payment, got {:?}", + jobs[0].job + ); + + Ok(()) + } +} diff --git a/lnvps_api/src/payments/mod.rs b/lnvps_api/src/payments/mod.rs index 4ba183b..f34ac60 100644 --- a/lnvps_api/src/payments/mod.rs +++ b/lnvps_api/src/payments/mod.rs @@ -1,10 +1,11 @@ use crate::payments::invoice::NodeInvoiceHandler; use crate::settings::Settings; +use crate::subscription::SubscriptionHandler; use anyhow::Result; -use lnvps_api_common::{UpgradeConfig, WorkCommander, WorkJob}; -use lnvps_db::{LNVpsDb, PaymentMethod, VmPayment}; +use lnvps_db::{LNVpsDb, PaymentMethod, SubscriptionPayment, SubscriptionPaymentType}; use log::{error, info, warn}; use payments_rs::lightning::LightningNode; +use std::future::Future; use std::sync::Arc; use std::time::Duration; use tokio::task::JoinHandle; @@ -16,14 +17,18 @@ mod revolut; #[cfg(feature = "stripe")] mod stripe; +// ========================================================================= +// listen_all_payments +// ========================================================================= + pub async fn listen_all_payments( settings: &Settings, node: Arc, db: Arc, - sender: Arc, + sub_handler: SubscriptionHandler, ) -> Result>> { let mut ret = Vec::new(); - let mut handler = NodeInvoiceHandler::new(node.clone(), db.clone(), sender.clone()); + let mut handler = NodeInvoiceHandler::new(node.clone(), db.clone(), sub_handler.clone()); ret.push(tokio::spawn(async move { loop { if let Err(e) = handler.listen().await { @@ -54,7 +59,7 @@ pub async fn listen_all_payments( &config, &settings.public_url, db.clone(), - sender.clone(), + sub_handler.clone(), ) { Ok(mut handler) => { ret.push(tokio::spawn(async move { @@ -76,40 +81,42 @@ pub async fn listen_all_payments( } } - Ok(ret) -} + #[cfg(feature = "stripe")] + { + use crate::payments::stripe::StripePaymentHandler; + + let stripe_configs = db + .list_payment_method_configs() + .await? + .into_iter() + .filter(|c| c.payment_method == PaymentMethod::Stripe && c.enabled) + .collect::>(); -pub(crate) async fn handle_upgrade( - payment: &VmPayment, - tx: &Arc, - _db: Arc, -) -> Result<()> { - // Parse upgrade parameters from the dedicated upgrade_params field - if let Some(upgrade_params_json) = &payment.upgrade_params { - if let Ok(upgrade_params) = serde_json::from_str::(upgrade_params_json) { + for config in stripe_configs { info!( - "Processing upgrade payment for VM {} with params: CPU={:?}, Memory={:?}, Disk={:?}", - payment.vm_id, - upgrade_params.new_cpu, - upgrade_params.new_memory, - upgrade_params.new_disk - ); - tx.send(WorkJob::ProcessVmUpgrade { - vm_id: payment.vm_id, - config: upgrade_params, - }) - .await?; - } else { - warn!( - "Upgrade payment {} has invalid upgrade parameters JSON", - hex::encode(&payment.id) + "Starting Stripe payment handler for config: {}", + config.name ); + match StripePaymentHandler::new(&config, db.clone(), sub_handler.clone()) { + Ok(mut handler) => { + ret.push(tokio::spawn(async move { + loop { + if let Err(e) = handler.listen().await { + error!("stripe-error: {}", e); + } + sleep(Duration::from_secs(30)).await; + } + })); + } + Err(e) => { + error!( + "Failed to create Stripe payment handler for '{}': {}", + config.name, e + ); + } + } } - } else { - warn!( - "Upgrade payment {} missing upgrade_params field", - hex::encode(&payment.id) - ); } - Ok(()) + + Ok(ret) } diff --git a/lnvps_api/src/payments/revolut.rs b/lnvps_api/src/payments/revolut.rs index 3a75417..0222b1a 100644 --- a/lnvps_api/src/payments/revolut.rs +++ b/lnvps_api/src/payments/revolut.rs @@ -1,10 +1,8 @@ -use crate::payments::handle_upgrade; +use crate::subscription::SubscriptionHandler; use anyhow::{Context, Result}; -use chrono::Utc; + use isocountry::CountryCode; -use lnvps_api_common::WorkJob; -use lnvps_api_common::{VmHistoryLogger, WorkCommander}; -use lnvps_db::{LNVpsDb, PaymentMethod, PaymentMethodConfig, PaymentType, ProviderConfig}; +use lnvps_db::{LNVpsDb, PaymentMethodConfig, ProviderConfig, SubscriptionPaymentType}; use log::{error, info, warn}; use payments_rs::fiat::{ RevolutApi, RevolutConfig, RevolutOrderState, RevolutWebhookBody, RevolutWebhookEvent, @@ -16,10 +14,9 @@ use std::sync::Arc; pub struct RevolutPaymentHandler { api: RevolutApi, db: Arc, - tx: Arc, + subscription_handler: SubscriptionHandler, public_url: String, config_id: u64, - vm_history_logger: VmHistoryLogger, } impl RevolutPaymentHandler { @@ -27,7 +24,7 @@ impl RevolutPaymentHandler { config: &PaymentMethodConfig, public_url: &str, db: Arc, - sender: Arc, + subscription_handler: SubscriptionHandler, ) -> Result { let provider_config = config .get_provider_config() @@ -44,14 +41,12 @@ impl RevolutPaymentHandler { public_key: revolut_config.public_key.clone(), })?; - let vm_history_logger = VmHistoryLogger::new(db.clone()); Ok(Self { api, public_url: public_url.to_string(), config_id: config.id, db, - tx: sender, - vm_history_logger, + subscription_handler, }) } @@ -173,12 +168,9 @@ impl RevolutPaymentHandler { } async fn try_complete_payment(&self, ext_id: &str) -> Result<()> { - let mut payment = self.db.get_vm_payment_by_ext_id(ext_id).await?; - - // Get VM state before payment processing - let vm_before = self.db.get_vm(payment.vm_id).await?; + let mut payment = self.db.get_subscription_payment_by_ext_id(ext_id).await?; - // save payment state json into external_data + // Verify the Revolut order is completed and store order JSON let order = self.api.get_order(ext_id).await?; if !matches!(order.state, RevolutOrderState::Completed) { error!("Invalid order state {:?}", order); @@ -186,7 +178,7 @@ impl RevolutPaymentHandler { } payment.external_data = serde_json::to_string(&order)?.into(); - // check user country matches card country + // Update user country from card country if not already set (best-effort) if let Some(cc) = order .payments .and_then(|p| p.first().cloned()) @@ -194,106 +186,27 @@ impl RevolutPaymentHandler { .and_then(|p| p.card_country_code) .and_then(|c| CountryCode::for_alpha2(&c).ok()) { - let vm = self.db.get_vm(payment.vm_id).await?; - let mut user = self.db.get_user(vm.user_id).await?; - if user.country_code.is_none() { - // update user country code to match card country - user.country_code = Some(cc.alpha3().to_string()); - self.db.update_user(&user).await?; + if let Ok(mut user) = self.db.get_user(payment.user_id).await { + if user.country_code.is_none() { + user.country_code = Some(cc.alpha3().to_string()); + let _ = self.db.update_user(&user).await; + } } } - self.db.vm_payment_paid(&payment).await?; - - // Get VM state after payment processing - let vm_after = self.db.get_vm(payment.vm_id).await?; - - // Log payment received in VM history - let payment_metadata = serde_json::json!({ - "external_id": ext_id, - "payment_method": "revolut" - }); - - if let Err(e) = self - .vm_history_logger - .log_vm_payment_received( - payment.vm_id, - payment.amount + payment.tax + payment.processing_fee, - &payment.currency, - payment.time_value, - Some(payment_metadata), - ) - .await - { - warn!("Failed to log payment for VM {}: {}", payment.vm_id, e); - } - - // Log VM renewal if this extends the expiration - if payment.time_value > 0 - && let Err(e) = self - .vm_history_logger - .log_vm_renewed( - payment.vm_id, - None, - vm_before.expires, - vm_after.expires, - Some(payment.amount + payment.tax + payment.processing_fee), - Some(&payment.currency), - Some(serde_json::json!({ - "time_added_seconds": payment.time_value, - "external_id": ext_id - })), - ) - .await - { - warn!("Failed to log VM {} renewal: {}", payment.vm_id, e); - } - - // Handle upgrade payments differently - trigger upgrade processing instead of just checking VM - if payment.payment_type == lnvps_db::PaymentType::Upgrade { - handle_upgrade(&payment, &self.tx, self.db.clone()).await?; - - // cancel other upgrade payments - let other_upgrades = self - .db - .list_vm_payment_by_method_and_type( - payment.vm_id, - PaymentMethod::Revolut, - PaymentType::Upgrade, - ) - .await?; - for mut ugp in other_upgrades { - if ugp.id == payment.id { - continue; - } - - ugp.expires = Utc::now(); - let hex_id = hex::encode(&ugp.id); - if let Some(ext_id) = ugp.external_id.as_ref() { - if let Err(e) = self.api.cancel_order(ext_id).await { - warn!("Failed to cancel order {}: {}", hex_id, e); - } - } else { - warn!("External id does not exist on fiat payment: {}", hex_id); - } - if let Err(e) = self.db.update_vm_payment(&ugp).await { - warn!("Failed to update invoice {}: {}", hex_id, e); + let result = self.subscription_handler.complete_payment(&payment).await?; + for p in result.expired_competing_upgrades { + if let Some(eid) = p.external_id.as_ref() { + if let Err(e) = self.api.cancel_order(eid).await { + warn!("Failed to cancel order {}: {}", hex::encode(p.id), e); } + } else { + warn!( + "External id does not exist on fiat payment: {}", + hex::encode(p.id) + ); } - } else { - // Regular renewal payment - just check the VM - self.tx - .send(WorkJob::CheckVm { - vm_id: payment.vm_id, - }) - .await?; } - - info!( - "VM payment {} for {}, paid", - hex::encode(payment.id), - payment.vm_id - ); Ok(()) } } diff --git a/lnvps_api/src/payments/stripe.rs b/lnvps_api/src/payments/stripe.rs index b0bf7bd..b0469f6 100644 --- a/lnvps_api/src/payments/stripe.rs +++ b/lnvps_api/src/payments/stripe.rs @@ -1,8 +1,113 @@ -use anyhow::{Context, Result, anyhow}; -use reqwest::{Client, RequestBuilder, StatusCode}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; - -/// Stripe API client for handling payments, webhooks, and checkout sessions -pub struct StripePaymentHandler; +use crate::subscription::SubscriptionHandler; +use anyhow::{Context, Result}; +use lnvps_api_common::WorkCommander; +use lnvps_db::{ + LNVpsDb, PaymentMethod, PaymentMethodConfig, ProviderConfig, SubscriptionPaymentType, +}; +use log::{error, info, warn}; +use payments_rs::fiat::{StripeApi, StripeConfig, StripeWebhookEvent}; +use payments_rs::webhook::WEBHOOK_BRIDGE; +use std::sync::Arc; + +pub struct StripePaymentHandler { + api: StripeApi, + db: Arc, + subscription_handler: SubscriptionHandler, + config_id: u64, +} + +impl StripePaymentHandler { + pub fn new( + config: &PaymentMethodConfig, + db: Arc, + subscription_handler: SubscriptionHandler, + ) -> Result { + let provider_config = config + .get_provider_config() + .context("Failed to parse provider config")?; + + let stripe_config = provider_config + .as_stripe() + .context("Config is not a Stripe provider")?; + + let api = StripeApi::new(StripeConfig { + url: None, + api_key: stripe_config.secret_key.clone(), + webhook_secret: Some(stripe_config.webhook_secret.clone()), + })?; + + Ok(Self { + api, + config_id: config.id, + db, + subscription_handler, + }) + } + + async fn try_complete_payment(&self, ext_id: &str) -> Result<()> { + let payment = self.db.get_subscription_payment_by_ext_id(ext_id).await?; + + let result = self.subscription_handler.complete_payment(&payment).await?; + for p in result.expired_competing_upgrades { + if let Some(eid) = p.external_id.as_ref() { + if let Err(e) = self.api.cancel_payment_intent(eid).await { + warn!( + "Failed to cancel Stripe payment intent {}: {}", + hex::encode(p.id), + e + ); + } + } else { + warn!( + "External id does not exist on Stripe payment: {}", + hex::encode(p.id) + ); + } + } + + Ok(()) + } + + pub async fn listen(&mut self) -> Result<()> { + let webhook_secret = self + .api + .webhook_secret() + .context("Stripe webhook secret not configured")? + .to_string(); + + let mut rx = WEBHOOK_BRIDGE.listen(); + + info!("Stripe payment handler listening for webhook events"); + + while let Ok(msg) = rx.recv().await { + if !msg.endpoint.contains("stripe") { + continue; + } + + let event = match StripeWebhookEvent::verify(&webhook_secret, &msg) { + Ok(e) => e, + Err(e) => { + warn!("Failed to verify Stripe webhook signature: {}", e); + continue; + } + }; + + // Handle payment_intent.succeeded — look up our payment by external_id + if event.event_type == "payment_intent.succeeded" { + let ext_id: Option = event + .data + .object + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()); + if let Some(ext_id) = ext_id { + if let Err(e) = self.try_complete_payment(&ext_id).await { + error!("Stripe payment completion failed for {}: {}", ext_id, e); + } + } + } + } + + Ok(()) + } +} diff --git a/lnvps_api/src/provisioner/integration_retry_tests.rs b/lnvps_api/src/provisioner/integration_retry_tests.rs index c7010de..2ed05ff 100644 --- a/lnvps_api/src/provisioner/integration_retry_tests.rs +++ b/lnvps_api/src/provisioner/integration_retry_tests.rs @@ -194,14 +194,12 @@ mod tests { image_id: 1, template_id: Some(1), custom_template_id: None, + subscription_line_item_id: 0, ssh_key_id: ssh_key.id, - created: chrono::Utc::now(), - expires: chrono::Utc::now(), disk_id: 1, mac_address: "bc:24:11:00:00:01".to_string(), deleted: false, ref_code: None, - auto_renewal_enabled: false, disabled: false, }; @@ -240,14 +238,12 @@ mod tests { image_id: 1, template_id: Some(1), custom_template_id: None, + subscription_line_item_id: 0, ssh_key_id: ssh_key.id, - created: chrono::Utc::now(), - expires: chrono::Utc::now(), disk_id: 1, mac_address: "bc:24:11:00:00:01".to_string(), deleted: false, ref_code: None, - auto_renewal_enabled: false, disabled: false, }; @@ -281,14 +277,12 @@ mod tests { image_id: 1, template_id: Some(1), custom_template_id: None, + subscription_line_item_id: 0, ssh_key_id: ssh_key.id, - created: chrono::Utc::now(), - expires: chrono::Utc::now(), disk_id: 1, mac_address: "bc:24:11:00:00:01".to_string(), deleted: false, ref_code: None, - auto_renewal_enabled: false, disabled: false, }; diff --git a/lnvps_api/src/provisioner/mod.rs b/lnvps_api/src/provisioner/mod.rs index 5281895..107691d 100644 --- a/lnvps_api/src/provisioner/mod.rs +++ b/lnvps_api/src/provisioner/mod.rs @@ -1,5 +1,5 @@ -mod lnvps; -mod lnvps_network; +mod vm; +mod vm_network; #[cfg(test)] mod retry_tests; @@ -10,6 +10,6 @@ mod integration_retry_tests; #[cfg(test)] mod rollback_tests; -pub use lnvps::*; pub use lnvps_api_common::{HostCapacityService, NetworkProvisioner, PricingEngine}; -pub use lnvps_network::*; +pub use vm::*; +pub use vm_network::*; diff --git a/lnvps_api/src/provisioner/retry_tests.rs b/lnvps_api/src/provisioner/retry_tests.rs index 47dcb89..bf751e3 100644 --- a/lnvps_api/src/provisioner/retry_tests.rs +++ b/lnvps_api/src/provisioner/retry_tests.rs @@ -209,14 +209,12 @@ mod tests { image_id: 1, template_id: Some(1), custom_template_id: None, + subscription_line_item_id: 0, ssh_key_id: ssh_key.id, - created: chrono::Utc::now(), - expires: chrono::Utc::now(), disk_id: 1, mac_address: "bc:24:11:00:00:01".to_string(), deleted: false, ref_code: None, - auto_renewal_enabled: false, disabled: false, }) .await?; diff --git a/lnvps_api/src/provisioner/rollback_tests.rs b/lnvps_api/src/provisioner/rollback_tests.rs index 6015f0a..9e2c10d 100644 --- a/lnvps_api/src/provisioner/rollback_tests.rs +++ b/lnvps_api/src/provisioner/rollback_tests.rs @@ -11,7 +11,7 @@ #[cfg(test)] mod tests { use crate::mocks::{MockDnsServer, MockNode, MockRouter}; - use crate::provisioner::LNVpsProvisioner; + use crate::provisioner::VmProvisioner; use crate::router::Router; use crate::settings::mock_settings; use anyhow::Result; @@ -97,8 +97,7 @@ mod tests { setup_db_with_static_arp(&db).await?; - let provisioner = - LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone(), Some(dns)); + let provisioner = VmProvisioner::new(settings, db.clone()); let (user, ssh_key) = add_user(&db).await?; let vm = provisioner @@ -156,8 +155,7 @@ mod tests { setup_db_with_static_arp(&db).await?; - let provisioner = - LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone(), Some(dns)); + let provisioner = VmProvisioner::new(settings, db.clone()); let (user, ssh_key) = add_user(&db).await?; let vm = provisioner @@ -229,13 +227,7 @@ mod tests { setup_db_with_static_arp(&db).await?; - let provisioner = LNVpsProvisioner::new( - settings, - db.clone(), - node.clone(), - rates.clone(), - Some(Arc::new(dns.clone())), - ); + let provisioner = VmProvisioner::new(settings, db.clone()); let (user, ssh_key) = add_user(&db).await?; let vm = provisioner @@ -316,8 +308,7 @@ mod tests { setup_db_with_static_arp(&db).await?; - let provisioner = - LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone(), Some(dns)); + let provisioner = VmProvisioner::new(settings, db.clone()); let (user, ssh_key) = add_user(&db).await?; let vm = provisioner @@ -368,16 +359,15 @@ mod tests { clear_mock_state().await; let settings = mock_settings(); let db = Arc::new(MockDb::default()); - let node = Arc::new(MockNode::default()); let rates = Arc::new(MockExchangeRate::new()); - let dns = Arc::new(MockDnsServer::new()); + const MOCK_RATE: f32 = 69_420.0; rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; setup_db_with_static_arp(&db).await?; - let provisioner = - LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone(), Some(dns)); + MockDnsServer::reset().await; + let provisioner = VmProvisioner::new(settings, db.clone()); let (user, ssh_key) = add_user(&db).await?; let vm = provisioner @@ -417,8 +407,7 @@ mod tests { setup_db_with_static_arp(&db).await?; - let provisioner = - LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone(), Some(dns)); + let provisioner = VmProvisioner::new(settings, db.clone()); let (user, ssh_key) = add_user(&db).await?; let vm = provisioner @@ -467,8 +456,7 @@ mod tests { setup_db_with_static_arp(&db).await?; let _dns = MockDnsServer::new(); - let provisioner = - LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone(), Some(dns)); + let provisioner = VmProvisioner::new(settings, db.clone()); let (user, ssh_key) = add_user(&db).await?; let vm = provisioner @@ -533,7 +521,7 @@ mod tests { #[tokio::test] async fn test_rollback_unpersisted_arp_dns_on_save_vm_failure() -> Result<()> { clear_mock_state().await; - use crate::provisioner::LNVpsNetworkProvisioner; + use crate::provisioner::VmNetworkProvisioner; use try_procedure::RetryPolicy; let db = Arc::new(MockDb::default()); @@ -541,7 +529,7 @@ mod tests { setup_db_with_static_arp(&db).await?; - let network = LNVpsNetworkProvisioner::new( + let network = VmNetworkProvisioner::new( db.clone(), Some(dns.clone()), Some("mock-forward-zone-id".to_string()), @@ -560,13 +548,11 @@ mod tests { ssh_key_id: ssh_key.id, template_id: Some(1), custom_template_id: None, + subscription_line_item_id: 0, disk_id: 1, mac_address: "02:00:00:00:00:01".to_string(), // A valid MAC - expires: chrono::Utc::now() + chrono::Duration::days(30), - created: chrono::Utc::now(), ref_code: None, deleted: false, - auto_renewal_enabled: false, disabled: false, }; let vm_id = db.insert_vm(&vm).await?; @@ -646,8 +632,7 @@ mod tests { setup_db_with_static_arp(&db).await?; - let provisioner = - LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone(), Some(dns)); + let provisioner = VmProvisioner::new(settings, db.clone()); let (user, ssh_key) = add_user(&db).await?; let vm = provisioner @@ -696,8 +681,7 @@ mod tests { setup_db_with_static_arp(&db).await?; - let provisioner = - LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone(), Some(dns)); + let provisioner = VmProvisioner::new(settings, db.clone()); let (user, ssh_key) = add_user(&db).await?; let vm = provisioner diff --git a/lnvps_api/src/provisioner/lnvps.rs b/lnvps_api/src/provisioner/vm.rs similarity index 62% rename from lnvps_api/src/provisioner/lnvps.rs rename to lnvps_api/src/provisioner/vm.rs index 47b1f18..0216f35 100644 --- a/lnvps_api/src/provisioner/lnvps.rs +++ b/lnvps_api/src/provisioner/vm.rs @@ -1,6 +1,6 @@ use crate::dns::DnsServer; use crate::host::{FullVmInfo, VmHostClient, get_host_client}; -use crate::provisioner::LNVpsNetworkProvisioner; +use crate::provisioner::VmNetworkProvisioner; use crate::router::{ArpEntry, Router, get_router}; use crate::settings::{ProvisionerConfig, Settings}; use anyhow::{Context, Result, bail, ensure}; @@ -10,12 +10,13 @@ use isocountry::CountryCode; use lnvps_api_common::retry::{OpResult, Pipeline, RetryPolicy}; use lnvps_api_common::{ AvailableIp, CostResult, HostCapacityService, NetworkProvisioner, NewPaymentInfo, - PricingEngine, UpgradeConfig, UpgradeCostQuote, round_msat_to_sat, + PricingEngine, UpgradeConfig, UpgradeCostQuote, VmStateCache, round_msat_to_sat, }; use lnvps_api_common::{ExchangeRateService, op_fatal}; use lnvps_db::{ - IpRange, IpRangeAllocationMode, LNVpsDb, PaymentMethod, PaymentType, Vm, VmCustomTemplate, - VmIpAssignment, VmPayment, VmTemplate, + IntervalType, IpRange, IpRangeAllocationMode, LNVpsDb, PaymentMethod, PaymentType, + Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentType, + SubscriptionType, Vm, VmCustomTemplate, VmIpAssignment, VmPayment, VmTemplate, }; use log::{debug, info}; use payments_rs::currency::{Currency, CurrencyAmount}; @@ -27,48 +28,34 @@ use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -/// Main provisioner class for LNVPS -/// -/// Does all the hard work and logic for creating / expiring VM's +/// Main provisioner class for LNVPS (VMs) #[derive(Clone)] -pub struct LNVpsProvisioner { +pub struct VmProvisioner { read_only: bool, db: Arc, - node: Arc, - revolut: Option>, - rates: Arc, - tax_rates: HashMap, - pub network: LNVpsNetworkProvisioner, + pub network: VmNetworkProvisioner, provisioner_config: ProvisionerConfig, + pub delete_after: u16, } -impl LNVpsProvisioner { +impl VmProvisioner { /// Create a retry policy for network operations (DNS, Router, Host) fn retry_policy() -> RetryPolicy { RetryPolicy::default() } - pub fn new( - settings: Settings, - db: Arc, - node: Arc, - rates: Arc, - dns: Option>, - ) -> Self { + pub fn new(settings: Settings, db: Arc) -> Self { Self { - network: LNVpsNetworkProvisioner::new( + network: VmNetworkProvisioner::new( db.clone(), - dns, + settings.get_dns(), settings.dns.as_ref().map(|z| z.forward_zone_id.to_string()), Self::retry_policy(), ), - revolut: settings.get_revolut().expect("revolut config"), - tax_rates: settings.tax_rate, provisioner_config: settings.provisioner, read_only: settings.read_only, + delete_after: settings.delete_after, db, - node, - rates, } } @@ -128,7 +115,43 @@ impl LNVpsProvisioner { bail!("No host disk found") }; - let now = Utc::now(); + let region = self.db.get_host_region(template.region_id).await?; + let cost_plan = self.db.get_cost_plan(template.cost_plan_id).await?; + + // Create subscription for this VM + let subscription = Subscription { + id: 0, + user_id: user.id, + company_id: region.company_id, + name: format!("{} subscription", template.name), + description: None, + created: Utc::now(), + expires: None, + is_active: false, + is_setup: false, + currency: cost_plan.currency.clone(), + interval_amount: cost_plan.interval_amount, + interval_type: cost_plan.interval_type, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }; + let line_item = SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: SubscriptionType::Vps, + name: template.name.clone(), + description: None, + amount: cost_plan.amount, + setup_amount: 0, + configuration: None, + }; + let (subscription_id, line_item_ids) = self + .db + .insert_subscription_with_line_items(&subscription, vec![line_item]) + .await?; + let subscription_line_item_id = line_item_ids[0]; + let mut new_vm = Vm { id: 0, host_id: host.host.id, @@ -136,19 +159,27 @@ impl LNVpsProvisioner { image_id: image.id, template_id: Some(template.id), custom_template_id: None, + subscription_line_item_id, ssh_key_id: ssh_key.id, - created: now, - expires: now, disk_id: pick_disk.disk.id, mac_address: "ff:ff:ff:ff:ff:ff".to_string(), deleted: false, ref_code, - auto_renewal_enabled: false, // Default to disabled for new VMs disabled: false, }; let new_id = self.db.insert_vm(&new_vm).await?; new_vm.id = new_id; + + // Update subscription and line item names now that the VM ID is known + let mut sub = self.db.get_subscription(subscription_id).await?; + sub.name = format!("VM{} subscription", new_vm.id); + self.db.update_subscription(&sub).await?; + + let mut li = self.db.get_subscription_line_item(subscription_line_item_id).await?; + li.name = format!("VM{} - {}", new_vm.id, template.name); + self.db.update_subscription_line_item(&li).await?; + Ok(new_vm) } @@ -203,8 +234,42 @@ impl LNVpsProvisioner { // insert custom templates let template_id = self.db.insert_custom_vm_template(&template).await?; + let region = self.db.get_host_region(pricing.region_id).await?; + + // Create subscription for this custom VM (1-month interval, amount computed at payment time) + let subscription = Subscription { + id: 0, + user_id: user.id, + company_id: region.company_id, + name: "Custom VM subscription".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: false, + is_setup: false, + currency: pricing.currency.clone(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }; + let line_item = SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: SubscriptionType::Vps, + name: pricing.name.clone(), + description: None, + amount: 0, // computed dynamically + setup_amount: 0, + configuration: None, + }; + let (subscription_id, line_item_ids) = self + .db + .insert_subscription_with_line_items(&subscription, vec![line_item]) + .await?; + let subscription_line_item_id = line_item_ids[0]; - let now = Utc::now(); let mut new_vm = Vm { id: 0, host_id: host.host.id, @@ -212,397 +277,28 @@ impl LNVpsProvisioner { image_id: image.id, template_id: None, custom_template_id: Some(template_id), + subscription_line_item_id, ssh_key_id: ssh_key.id, - created: now, - expires: now, disk_id: pick_disk.disk.id, mac_address: "ff:ff:ff:ff:ff:ff".to_string(), deleted: false, ref_code, - auto_renewal_enabled: false, // Default to disabled for new VMs disabled: false, }; let new_id = self.db.insert_vm(&new_vm).await?; new_vm.id = new_id; - Ok(new_vm) - } - #[cfg(feature = "nostr-nwc")] - /// Attempt automatic renewal via Nostr Wallet Connect - pub async fn auto_renew_via_nwc( - &self, - vm_id: u64, - nwc_connection_string: &str, - ) -> Result { - use nostr_sdk::prelude::*; - - debug!("Attempting automatic renewal for VM {} via NWC", vm_id); - - // Use existing renew method to create the payment/invoice - let vm_payment = self.renew(vm_id, PaymentMethod::Lightning).await?; - - // Extract the invoice from external_data - let invoice: String = vm_payment.external_data.clone().into(); - debug!( - "Created renewal invoice for VM {}, attempting NWC payment", - vm_id - ); - - // Parse NWC connection string - let nwc_uri = nwc::prelude::NostrWalletConnectURI::from_str(nwc_connection_string) - .context("Invalid NWC connection string")?; - - // Create nostr client for NWC - let client = nwc::NWC::new(nwc_uri); - client.pay_invoice(PayInvoiceRequest::new(invoice)).await?; - info!("Successful NWC auto-renewal payment for VM {}", vm_id); - Ok(vm_payment) - } - - /// Create a renewal payment for a single interval - pub async fn renew(&self, vm_id: u64, method: PaymentMethod) -> Result { - self.renew_intervals(vm_id, method, 1).await - } - - /// Create a renewal payment for multiple intervals - pub async fn renew_intervals( - &self, - vm_id: u64, - method: PaymentMethod, - intervals: u32, - ) -> Result { - let pe = PricingEngine::new_for_vm( - self.db.clone(), - self.rates.clone(), - self.tax_rates.clone(), - vm_id, - ) - .await?; - let price = pe - .get_vm_cost_for_intervals(vm_id, method, intervals) - .await?; - self.price_to_payment(vm_id, method, price).await - } + // Update subscription and line item names now that the VM ID is known + let mut sub = self.db.get_subscription(subscription_id).await?; + sub.name = format!("VM{} subscription", new_vm.id); + self.db.update_subscription(&sub).await?; - /// Renew a VM using a specific amount - pub async fn renew_amount( - &self, - vm_id: u64, - amount: CurrencyAmount, - method: PaymentMethod, - ) -> Result { - let pe = PricingEngine::new_for_vm( - self.db.clone(), - self.rates.clone(), - self.tax_rates.clone(), - vm_id, - ) - .await?; - let price = pe.get_cost_by_amount(vm_id, amount, method).await?; - self.price_to_payment(vm_id, method, price).await - } - - /// Create a renewal/purchase payment for a subscription - pub async fn renew_subscription( - &self, - subscription_id: u64, - method: PaymentMethod, - ) -> Result { - use lnvps_db::{SubscriptionPayment, SubscriptionPaymentType}; - - // Get subscription and line items - let subscription = self.db.get_subscription(subscription_id).await?; - let line_items = self - .db - .list_subscription_line_items(subscription_id) - .await?; - ensure!(!line_items.is_empty(), "Subscription has no line items"); + let mut li = self.db.get_subscription_line_item(subscription_line_item_id).await?; + li.name = format!("VM{} - {}", new_vm.id, pricing.name); + self.db.update_subscription_line_item(&li).await?; - // Get user for tax calculation - let user = self.db.get_user(subscription.user_id).await?; - - // Calculate total cost in subscription currency - let mut monthly_cost: u64 = 0; - let mut setup_fee: u64 = 0; - - for item in &line_items { - monthly_cost += item.amount; - setup_fee += item.setup_amount; - } - - // Check if this is first payment (purchase) or renewal - let existing_payments = self.db.list_subscription_payments(subscription_id).await?; - let has_paid = existing_payments.iter().any(|p| p.is_paid); - - let (list_price_amount, payment_type) = if has_paid { - // Renewal - monthly cost only - (monthly_cost, SubscriptionPaymentType::Renewal) - } else { - // Purchase - monthly cost + setup fees - (monthly_cost + setup_fee, SubscriptionPaymentType::Purchase) - }; - - // Parse subscription currency - let subscription_currency = Currency::from_str(&subscription.currency) - .map_err(|e| anyhow::anyhow!("Invalid currency"))?; - let list_price = CurrencyAmount::from_u64(subscription_currency, list_price_amount); - - // Create pricing engine for currency conversion - let pe = PricingEngine::new( - self.db.clone(), - self.rates.clone(), - self.tax_rates.clone(), - subscription_currency, - ); - - // Convert list price to payment method currency and get rate - let converted = pe.get_amount_and_rate(list_price, method).await?; - - // Calculate tax on the converted amount - let tax = pe - .get_tax_for_user(user.id, converted.amount.value()) - .await?; - - // Calculate processing fee using subscription's company_id - let processing_fee = pe - .calculate_processing_fee( - subscription.company_id, - method, - converted.amount.currency(), - converted.amount.value(), - ) - .await; - - // Generate payment based on method - let subscription_payment = match method { - PaymentMethod::Lightning => { - ensure!( - converted.amount.currency() == Currency::BTC, - "Lightning payment must be in BTC" - ); - const INVOICE_EXPIRE: u64 = 600; - // Round to nearest satoshi for wallet compatibility - let invoice_amount = round_msat_to_sat(converted.amount.value() + tax); - let desc = match payment_type { - SubscriptionPaymentType::Purchase => { - format!("Subscription purchase: {}", subscription.name) - } - SubscriptionPaymentType::Renewal => { - format!("Subscription renewal: {}", subscription.name) - } - }; - - info!( - "Creating invoice for subscription {} for {} sats", - subscription_id, - invoice_amount / 1000 - ); - - let invoice = self - .node - .add_invoice(AddInvoiceRequest { - memo: Some(desc), - amount: invoice_amount, - expire: Some(INVOICE_EXPIRE as u32), - }) - .await?; - - SubscriptionPayment { - id: hex::decode(invoice.payment_hash())?, - subscription_id, - user_id: subscription.user_id, - created: Utc::now(), - expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE)), - amount: converted.amount.value(), - currency: converted.amount.currency().to_string(), - payment_method: method, - payment_type, - external_data: invoice.pr().into(), - external_id: invoice.external_id, - is_paid: false, - rate: converted.rate.rate, - tax, - processing_fee, - paid_at: None, - } - } - PaymentMethod::Revolut => { - let rev = if let Some(r) = &self.revolut { - r - } else { - bail!("Revolut not configured") - }; - ensure!( - converted.amount.currency() != Currency::BTC, - "Cannot create Revolut orders for BTC currency" - ); - - let desc = match payment_type { - SubscriptionPaymentType::Purchase => { - format!("Subscription purchase: {}", subscription.name) - } - SubscriptionPaymentType::Renewal => { - format!("Subscription renewal: {}", subscription.name) - } - }; - - let order_amount = CurrencyAmount::from_u64( - converted.amount.currency(), - converted.amount.value() + tax + processing_fee, - ); - let order = rev.create_order(&desc, order_amount, None).await?; - - let new_id: [u8; 32] = rand::random(); - SubscriptionPayment { - id: new_id.to_vec(), - subscription_id, - user_id: subscription.user_id, - created: Utc::now(), - expires: Utc::now().add(Duration::from_secs(3600)), - amount: converted.amount.value(), - currency: converted.amount.currency().to_string(), - payment_method: method, - payment_type, - external_data: order.raw_data.into(), - external_id: Some(order.external_id), - is_paid: false, - rate: converted.rate.rate, - tax, - processing_fee, - paid_at: None, - } - } - PaymentMethod::Paypal => bail!("PayPal not implemented"), - PaymentMethod::Stripe => bail!("Stripe not implemented"), - }; - - // Save payment to database - self.db - .insert_subscription_payment(&subscription_payment) - .await?; - - Ok(subscription_payment) - } - - async fn price_to_payment( - &self, - vm_id: u64, - method: PaymentMethod, - price: CostResult, - ) -> Result { - self.price_to_payment_with_type(vm_id, method, price, PaymentType::Renewal, None) - .await - } - - async fn price_to_payment_with_type( - &self, - vm_id: u64, - method: PaymentMethod, - price: CostResult, - payment_type: PaymentType, - upgrade_params: Option, - ) -> Result { - match price { - CostResult::Existing(p) => Ok(p), - CostResult::New(p) => { - let desc = match payment_type { - PaymentType::Renewal => format!("VM renewal {vm_id} to {}", p.new_expiry), - PaymentType::Upgrade => format!("VM upgrade {vm_id}"), - }; - let vm_payment = match method { - PaymentMethod::Lightning => { - ensure!( - p.currency == Currency::BTC, - "Cannot create invoices for non-BTC currency" - ); - const INVOICE_EXPIRE: u64 = 600; - // Round to nearest satoshi for wallet compatibility - let total_amount = round_msat_to_sat(p.amount + p.tax); - info!( - "Creating invoice for {vm_id} for {} sats", - total_amount / 1000 - ); - let invoice = self - .node - .add_invoice(AddInvoiceRequest { - memo: Some(desc), - amount: total_amount, - expire: Some(INVOICE_EXPIRE as u32), - }) - .await?; - VmPayment { - id: hex::decode(invoice.payment_hash())?, - vm_id, - created: Utc::now(), - expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE)), - amount: p.amount, - tax: p.tax, - processing_fee: p.processing_fee, - currency: p.currency.to_string(), - payment_method: method, - payment_type, - time_value: p.time_value, - is_paid: false, - rate: p.rate.rate, - external_data: invoice.pr().into(), - external_id: invoice.external_id, - upgrade_params, - paid_at: None, - } - } - PaymentMethod::Revolut => { - let rev = if let Some(r) = &self.revolut { - r - } else { - bail!("Revolut not configured") - }; - ensure!( - p.currency != Currency::BTC, - "Cannot create revolut orders for BTC currency" - ); - let order = rev - .create_order( - &desc, - CurrencyAmount::from_u64( - p.currency, - p.amount + p.tax + p.processing_fee, - ), - None, - ) - .await?; - let new_id: [u8; 32] = rand::random(); - VmPayment { - id: new_id.to_vec(), - vm_id, - created: Utc::now(), - expires: Utc::now().add(Duration::from_secs(3600)), - amount: p.amount, - tax: p.tax, - processing_fee: p.processing_fee, - currency: p.currency.to_string(), - payment_method: method, - payment_type, - time_value: p.time_value, - is_paid: false, - rate: p.rate.rate, - external_data: order.raw_data.into(), - external_id: Some(order.external_id), - upgrade_params, - paid_at: None, - } - } - PaymentMethod::Paypal => todo!(), - PaymentMethod::Stripe => { - todo!("Stripe payment integration not yet implemented") - } - }; - - self.db.insert_vm_payment(&vm_payment).await?; - - Ok(vm_payment) - } - } + Ok(new_vm) } /// Apply vm config to host @@ -793,23 +489,6 @@ impl LNVpsProvisioner { Ok(()) } - /// Calculate both upgrade cost and new renewal cost for a VM upgrade - pub async fn calculate_upgrade_cost( - &self, - vm_id: u64, - cfg: &UpgradeConfig, - method: PaymentMethod, - ) -> Result { - let pe = PricingEngine::new_for_vm( - self.db.clone(), - self.rates.clone(), - self.tax_rates.clone(), - vm_id, - ) - .await?; - pe.calculate_upgrade_cost(vm_id, cfg, method).await - } - /// Convert a VM from standard template to custom template pub async fn convert_to_custom_template(&self, vm_id: u64, cfg: &UpgradeConfig) -> Result<()> { let (mut vm, _, new_custom_template) = self.create_upgrade_template(vm_id, cfg).await?; @@ -826,38 +505,55 @@ impl LNVpsProvisioner { self.db.update_vm(&vm).await?; + // Update the subscription to 1-Month billing (custom VMs are always monthly) + let line_item = self + .db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let mut subscription = self.db.get_subscription(line_item.subscription_id).await?; + subscription.interval_amount = 1; + subscription.interval_type = IntervalType::Month; + self.db.update_subscription(&subscription).await?; + + // Calculate the new base-currency cost for the new custom template and update the line + // item's amount so the displayed subscription cost reflects the upgraded specs. + let new_price = + PricingEngine::get_custom_vm_cost_amount(&self.db, vm_id, &new_custom_template).await?; + + // Update the line item: mark as VmRenewal (no longer VmUpgrade), store the new config, + // and update the renewal amount to the new template's base-currency cost. + let mut updated_line_item = line_item; + updated_line_item.subscription_type = SubscriptionType::Vps; + updated_line_item.configuration = Some(serde_json::to_value(cfg)?); + updated_line_item.amount = new_price.total(); + self.db + .update_subscription_line_item(&updated_line_item) + .await?; + Ok(()) } - /// Create an upgrade payment - pub async fn create_upgrade_payment( - &self, - vm_id: u64, - cfg: &UpgradeConfig, - method: PaymentMethod, - ) -> Result { - let cost_difference = self.calculate_upgrade_cost(vm_id, cfg, method).await?; - - // create a payment entry for upgrade - let payment = NewPaymentInfo { - amount: cost_difference.upgrade.amount.value(), - currency: cost_difference.upgrade.amount.currency(), - rate: cost_difference.upgrade.rate, - time_value: 0, //upgrades dont add time - new_expiry: Default::default(), - tax: 0, // No tax on upgrades for now - processing_fee: 0, // No processing fee on upgrades for now - }; - let upgrade_params_json = serde_json::to_string(cfg)?; + /// Update the subscription line item's renewal amount for a VM that already uses a custom + /// template. Called after the custom template's specs have been updated in the database so + /// that `ApiSubscriptionLineItem.price` reflects the new cost. + pub async fn update_line_item_cost_for_custom_vm(&self, vm_id: u64) -> Result<()> { + let vm = self.db.get_vm(vm_id).await?; + let custom_template_id = vm + .custom_template_id + .ok_or_else(|| anyhow::anyhow!("VM does not have a custom template"))?; + let template = self.db.get_custom_vm_template(custom_template_id).await?; - self.price_to_payment_with_type( - vm_id, - method, - CostResult::New(payment), - PaymentType::Upgrade, - Some(upgrade_params_json), - ) - .await + let new_price = + PricingEngine::get_custom_vm_cost_amount(&self.db, vm_id, &template).await?; + + let mut line_item = self + .db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + line_item.amount = new_price.total(); + self.db.update_subscription_line_item(&line_item).await?; + + Ok(()) } /// Create a new custom template using a vm's existing standard template @@ -901,7 +597,7 @@ impl LNVpsProvisioner { let custom_pricing = compatible_pricing .ok_or_else(|| anyhow::anyhow!( - "No custom pricing available for this region that supports disk type {:?} with interface {:?}", + "No custom pricing available for this region that supports disk type {:?} with interface {:?}", current_template.disk_type, current_template.disk_interface ))?; @@ -977,7 +673,7 @@ pub struct SpawnVmContext { /// The client impl to provision this vm on the host host_client: Arc, /// Network provisioner access - network: LNVpsNetworkProvisioner, + network: VmNetworkProvisioner, /// Generated mac address, can be rolled back if the entry has an ID generated_mac: Option, @@ -1058,7 +754,7 @@ impl SpawnVmContext { None => op_fatal!("Cannot provision VM without an IPv4 address"), } if let Some(mut v6) = ip.ip6 { - let assignment = LNVpsProvisioner::v6_to_allocation( + let assignment = VmProvisioner::v6_to_allocation( &mut v6, self.info.vm.id, &self.info.vm.mac_address, @@ -1099,10 +795,15 @@ mod tests { use super::*; use crate::mocks::{MockDnsServer, MockNode, MockRouter}; use crate::settings::mock_settings; - use lnvps_api_common::{GB, InMemoryRateCache, MockDb, MockExchangeRate, TB, Ticker}; + use crate::subscription::{SubscriptionHandler, SubscriptionLineItemHandler, VmLineItemHandler}; + use lnvps_api_common::{ + ChannelWorkCommander, GB, InMemoryRateCache, MockDb, MockExchangeRate, TB, Ticker, + WorkCommander, WorkJob, + }; use lnvps_db::{ - AccessPolicy, DiskInterface, DiskType, LNVpsDbBase, NetworkAccessPolicy, RouterKind, User, - UserSshKey, VmCustomPricing, VmCustomPricingDisk, VmTemplate, + AccessPolicy, DiskInterface, DiskType, IntervalType, LNVpsDbBase, NetworkAccessPolicy, + RouterKind, Subscription, User, UserSshKey, VmCustomPricing, VmCustomPricingDisk, + VmTemplate, }; use std::net::IpAddr; use std::str::FromStr; @@ -1172,14 +873,18 @@ mod tests { r.reverse_zone_id = Some("mock-v6-rev-zone-id".to_string()); } + let wrk: Arc = Arc::new(ChannelWorkCommander::new()); let dns = MockDnsServer::new(); - let provisioner = LNVpsProvisioner::new( + dns.zones.lock().await.clear(); // reset dns server zones + let sub_handler = SubscriptionHandler::new( settings, db.clone(), node.clone(), rates.clone(), - Some(Arc::new(dns.clone())), - ); + wrk.clone(), + VmStateCache::new(), + )?; + let provisioner = sub_handler.vm_provisioner(); let (user, ssh_key) = add_user(&db).await?; let vm = provisioner @@ -1187,9 +892,18 @@ mod tests { .await?; println!("{:?}", vm); + let sub = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + // renew vm - let payment = provisioner.renew(vm.id, PaymentMethod::Lightning).await?; - assert_eq!(vm.id, payment.vm_id); + let payment = sub_handler + .renew_subscription(sub.id, PaymentMethod::Lightning, 1) + .await?; + assert!( + vm.subscription_line_item_id > 0, + "VM must have a subscription line item" + ); assert_eq!(payment.tax, (payment.amount as f64 * 0.01).floor() as u64); // check invoice amount matches rounded amount+tax @@ -1301,15 +1015,7 @@ mod tests { env_logger::try_init().ok(); let settings = settings(); let db = Arc::new(MockDb::default()); - let node = Arc::new(MockNode::default()); - let rates = Arc::new(InMemoryRateCache::default()); - let prov = LNVpsProvisioner::new( - settings.clone(), - db.clone(), - node.clone(), - rates.clone(), - None, - ); + let prov = VmProvisioner::new(settings.clone(), db.clone()); let large_template = VmTemplate { id: 0, @@ -1345,25 +1051,23 @@ mod tests { // ── helpers ────────────────────────────────────────────────────────────── /// Build a minimal provisioner backed by the given MockDb (no DNS, no rates needed). - fn make_provisioner(db: Arc) -> LNVpsProvisioner { - let node = Arc::new(MockNode::default()); - let rates = Arc::new(MockExchangeRate::new()); - LNVpsProvisioner::new(mock_settings(), db, node, rates, None) + fn make_provisioner(db: Arc) -> VmProvisioner { + VmProvisioner::new(mock_settings(), db) } - /// Build a provisioner with a BTC/EUR exchange rate set (needed for renewal). - async fn make_provisioner_with_rates(db: Arc) -> Result { + async fn make_sub_handler(db: Arc) -> Result { let node = Arc::new(MockNode::default()); let rates = Arc::new(MockExchangeRate::new()); const MOCK_RATE: f32 = 69_420.0; rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; - Ok(LNVpsProvisioner::new( + Ok(SubscriptionHandler::new( mock_settings(), - db, + db.clone(), node, rates, - None, - )) + Arc::new(ChannelWorkCommander::new()), + VmStateCache::new(), + )?) } /// Insert a VmCustomPricing + one VmCustomPricingDisk into db and return the pricing id. @@ -1412,9 +1116,47 @@ mod tests { Ok(pricing_id) } + /// Create a minimal subscription + line item for test VMs. + /// Returns the line_item_id to set on the Vm. + async fn make_test_subscription(db: &Arc, user_id: u64) -> Result { + let (_sub_id, line_item_ids) = db + .insert_subscription_with_line_items( + &Subscription { + id: 0, + user_id, + company_id: 1, + name: "test sub".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: false, + is_setup: false, + currency: "BTC".to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }, + vec![lnvps_db::SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: lnvps_db::SubscriptionType::Vps, + name: "test item".to_string(), + description: None, + amount: 1000, + setup_amount: 0, + configuration: None, + }], + ) + .await?; + Ok(line_item_ids[0]) + } + /// Insert a VM that uses the default mock standard template (template_id = 1). async fn insert_standard_template_vm(db: &Arc) -> Result { let (user, ssh_key) = add_user(db).await?; + let subscription_line_item_id = make_test_subscription(db, user.id).await?; let vm_id = db .insert_vm(&Vm { id: 0, @@ -1423,6 +1165,7 @@ mod tests { image_id: 1, template_id: Some(1), custom_template_id: None, + subscription_line_item_id, ssh_key_id: ssh_key.id, disk_id: 1, mac_address: "aa:bb:cc:dd:ee:ff".to_string(), @@ -1825,6 +1568,7 @@ mod tests { // Directly insert VM (bypassing provision_custom) to simulate an existing VM // that was created before the pricing was disabled + let subscription_line_item_id = make_test_subscription(&db, user.id).await?; let vm_id = db .insert_vm(&Vm { id: 0, @@ -1833,6 +1577,7 @@ mod tests { image_id: 1, template_id: None, custom_template_id: Some(custom_template_id), + subscription_line_item_id, ssh_key_id: ssh_key.id, disk_id: 1, mac_address: "aa:bb:cc:dd:ee:ff".to_string(), @@ -1841,10 +1586,31 @@ mod tests { }) .await?; - let prov = make_provisioner_with_rates(db).await?; - let payment = prov.renew(vm_id, PaymentMethod::Lightning).await?; + let sub = db + .get_subscription_line_item(subscription_line_item_id) + .await?; + let prov = make_sub_handler(db).await?; + let payment = prov + .renew_subscription(sub.id, PaymentMethod::Lightning, 1) + .await?; - assert_eq!(payment.vm_id, vm_id); + assert_eq!(payment.currency, "BTC", "payment currency should be BTC"); + assert!( + payment.time_value.is_some(), + "time_value must be set for VM renewal" + ); + assert!( + payment.time_value.unwrap() > 0, + "time_value must be positive" + ); + // With rate 69_420 EUR/BTC and custom pricing (2cpu@100 + 4GB@50 + 150 ip4 + 0 ip6 + 50GB@10*5) + // = (200 + 200 + 150 + 0 + 500) = 1050 EUR cents → ~1.51M millisats at 69420 rate + // Sanity check: well above zero and below 10_000_000 (10k sats) + assert!( + payment.amount > 0 && payment.amount < 10_000_000_000, + "amount {} is unreasonably large (double-conversion bug?)", + payment.amount + ); Ok(()) } @@ -1906,9 +1672,28 @@ mod tests { let mut templates = db.templates.lock().await; templates.get_mut(&1).unwrap().enabled = false; } - let prov = make_provisioner_with_rates(db).await?; - let payment = prov.renew(vm_id, PaymentMethod::Lightning).await?; - assert_eq!(payment.vm_id, vm_id); + let vm = db.get_vm(vm_id).await?; + let sub = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let prov = make_sub_handler(db).await?; + let payment = prov + .renew_subscription(sub.subscription_id, PaymentMethod::Lightning, 1) + .await?; + + assert_eq!(payment.currency, "BTC"); + assert!( + payment.time_value.is_some(), + "time_value must be set for VM renewal" + ); + assert!(payment.time_value.unwrap() > 0); + // cost_plan amount=132 EUR cents at rate 69420 EUR/BTC → ~1,902 millisats + // Sanity: > 0 and well under 1 billion millisats + assert!( + payment.amount > 0 && payment.amount < 1_000_000_000, + "amount {} is unreasonably large (double-conversion bug?)", + payment.amount + ); Ok(()) } @@ -1922,9 +1707,27 @@ mod tests { templates.get_mut(&1).unwrap().expires = Some(Utc::now() - chrono::Duration::seconds(1)); } - let prov = make_provisioner_with_rates(db).await?; - let payment = prov.renew(vm_id, PaymentMethod::Lightning).await?; - assert_eq!(payment.vm_id, vm_id); + + let vm = db.get_vm(vm_id).await?; + let sub = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let prov = make_sub_handler(db).await?; + let payment = prov + .renew_subscription(sub.subscription_id, PaymentMethod::Lightning, 1) + .await?; + + assert_eq!(payment.currency, "BTC"); + assert!( + payment.time_value.is_some(), + "time_value must be set for VM renewal" + ); + assert!(payment.time_value.unwrap() > 0); + assert!( + payment.amount > 0 && payment.amount < 1_000_000_000, + "amount {} is unreasonably large (double-conversion bug?)", + payment.amount + ); Ok(()) } @@ -1988,6 +1791,7 @@ mod tests { ..Default::default() }) .await?; + let subscription_line_item_id = make_test_subscription(&db, user.id).await?; let vm_id = db .insert_vm(&Vm { id: 0, @@ -1996,6 +1800,7 @@ mod tests { image_id: 1, template_id: None, custom_template_id: Some(custom_template_id), + subscription_line_item_id, ssh_key_id: ssh_key.id, disk_id: 1, mac_address: "aa:bb:cc:dd:ee:ff".to_string(), @@ -2003,9 +1808,522 @@ mod tests { ..Default::default() }) .await?; - let prov = make_provisioner_with_rates(db).await?; - let payment = prov.renew(vm_id, PaymentMethod::Lightning).await?; - assert_eq!(payment.vm_id, vm_id); + + let vm = db.get_vm(vm_id).await?; + let sub = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let prov = make_sub_handler(db).await?; + let payment = prov + .renew_subscription(sub.subscription_id, PaymentMethod::Lightning, 1) + .await?; + + assert_eq!(payment.currency, "BTC"); + assert!( + payment.time_value.is_some(), + "time_value must be set for VM renewal" + ); + assert!(payment.time_value.unwrap() > 0); + // Custom pricing: cpu=2@100 + mem=4GB@50 + ip4@200 + disk=50GB@10*5 = 200+200+200+0+500 + // = 1100 EUR cents at rate 69420 → ~1.59M millisats + // Sanity: > 0 and well under 1 billion millisats + assert!( + payment.amount > 0 && payment.amount < 1_000_000_000, + "amount {} is unreasonably large (double-conversion bug?)", + payment.amount + ); + Ok(()) + } + + // ── provision subscription creation tests ──────────────────────────────── + + /// provision() creates a subscription and line item linked to the VM. + #[tokio::test] + async fn test_provision_creates_subscription() -> Result<()> { + let db = Arc::new(MockDb::default()); + let prov = make_provisioner(db.clone()); + let (user, ssh_key) = add_user(&db).await?; + + let vm = prov.provision(user.id, 1, 1, ssh_key.id, None).await?; + + assert!( + vm.subscription_line_item_id > 0, + "VM must have a subscription_line_item_id" + ); + + // Line item must exist + let line_item = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + assert_eq!(line_item.subscription_type, lnvps_db::SubscriptionType::Vps); + + // Subscription must exist and have interval from cost_plan + let sub = db.get_subscription(line_item.subscription_id).await?; + assert_eq!(sub.user_id, user.id); + // Default cost_plan has interval_amount=1, interval_type=Month + assert_eq!(sub.interval_amount, 1); + assert!( + matches!(sub.interval_type, IntervalType::Month), + "expected Month interval" + ); + assert!(!sub.is_active, "subscription should start inactive"); + assert!(!sub.is_setup, "subscription should start un-setup"); + + Ok(()) + } + + /// provision_custom() creates a subscription with 1-Month interval. + #[tokio::test] + async fn test_provision_custom_creates_subscription() -> Result<()> { + let db = Arc::new(MockDb::default()); + let prov = make_provisioner(db.clone()); + let (user, ssh_key) = add_user(&db).await?; + let pricing_id = insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + + let template = lnvps_db::VmCustomTemplate { + id: 0, + cpu: 2, + memory: 4 * GB, + disk_size: 50 * GB, + disk_type: DiskType::SSD, + disk_interface: DiskInterface::PCIe, + pricing_id, + ..Default::default() + }; + + let vm = prov + .provision_custom(user.id, template, 1, ssh_key.id, None) + .await?; + + assert!(vm.subscription_line_item_id > 0); + + let line_item = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + assert_eq!(line_item.subscription_type, lnvps_db::SubscriptionType::Vps); + + let sub = db.get_subscription(line_item.subscription_id).await?; + assert_eq!(sub.user_id, user.id); + // Custom VMs always use 1-Month interval + assert_eq!(sub.interval_amount, 1); + assert!( + matches!(sub.interval_type, IntervalType::Month), + "expected Month interval" + ); + assert!(!sub.is_active); + assert!(!sub.is_setup); + + Ok(()) + } + + // ── subscription line item amount update tests ─────────────────────────── + + /// Regression: convert_to_custom_template must update line_item.amount to the new + /// base-currency cost of the custom template so that the subscription's displayed + /// renewal cost is not stale after a standard→custom upgrade. + #[tokio::test] + async fn test_convert_to_custom_template_updates_line_item_amount() -> Result<()> { + let db = Arc::new(MockDb::default()); + insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + let vm_id = insert_standard_template_vm(&db).await?; + let prov = make_provisioner(db.clone()); + + // Record the old line item amount before the upgrade. + let vm = db.get_vm(vm_id).await?; + let old_line_item = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let old_amount = old_line_item.amount; + + // Upgrade: add more CPU so the new cost will be higher. + let cfg = UpgradeConfig { + new_cpu: Some(4), + new_memory: None, + new_disk: None, + }; + prov.convert_to_custom_template(vm_id, &cfg).await?; + + // After conversion the line item amount must reflect the new custom template cost + // and must differ from the old standard-template amount. + let vm_after = db.get_vm(vm_id).await?; + let new_line_item = db + .get_subscription_line_item(vm_after.subscription_line_item_id) + .await?; + + assert_ne!( + new_line_item.amount, old_amount, + "line_item.amount must be updated after upgrade (was stale: {})", + old_amount + ); + assert!( + new_line_item.amount > 0, + "new line_item.amount must be positive" + ); + Ok(()) + } + + /// Regression: update_line_item_cost_for_custom_vm must update line_item.amount for a VM + /// that already uses a custom template after its specs are changed. + #[tokio::test] + async fn test_update_line_item_cost_for_custom_vm_updates_amount() -> Result<()> { + let db = Arc::new(MockDb::default()); + let pricing_id = insert_custom_pricing(&*db, DiskType::SSD, DiskInterface::PCIe).await?; + + let (user, ssh_key) = add_user(&db).await?; + + // Start with a small custom template (2 CPU). + let small_template_id = db + .insert_custom_vm_template(&VmCustomTemplate { + id: 0, + cpu: 2, + memory: 4 * GB, + disk_size: 64 * GB, + disk_type: DiskType::SSD, + disk_interface: DiskInterface::PCIe, + pricing_id, + ..Default::default() + }) + .await?; + + let subscription_line_item_id = make_test_subscription(&db, user.id).await?; + let vm_id = db + .insert_vm(&Vm { + id: 0, + host_id: 1, + user_id: user.id, + image_id: 1, + template_id: None, + custom_template_id: Some(small_template_id), + subscription_line_item_id, + ssh_key_id: ssh_key.id, + disk_id: 1, + mac_address: "aa:bb:cc:dd:ee:f1".to_string(), + deleted: false, + ..Default::default() + }) + .await?; + + let prov = make_provisioner(db.clone()); + + // Read the amount before the template update. + let vm = db.get_vm(vm_id).await?; + let old_amount = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await? + .amount; + + // Simulate the worker upgrading the template to 4 CPU. + { + let mut custom_template_map = db.custom_template.lock().await; + let tpl = custom_template_map.get_mut(&small_template_id).unwrap(); + tpl.cpu = 4; + } + + // Now call the helper that should refresh the line item cost. + prov.update_line_item_cost_for_custom_vm(vm_id).await?; + + let new_amount = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await? + .amount; + + assert_ne!( + new_amount, old_amount, + "line_item.amount must be updated after custom template spec change (was stale: {})", + old_amount + ); + assert!(new_amount > 0, "new line_item.amount must be positive"); + Ok(()) + } + + /// provision() sets ref_code on the VM. + #[tokio::test] + async fn test_provision_sets_ref_code() -> Result<()> { + let db = Arc::new(MockDb::default()); + let prov = make_provisioner(db.clone()); + let (user, ssh_key) = add_user(&db).await?; + + let vm = prov + .provision(user.id, 1, 1, ssh_key.id, Some("TEST123".to_string())) + .await?; + + assert_eq!(vm.ref_code, Some("TEST123".to_string())); + Ok(()) + } + + // ── subscription / VM lifecycle tests ──────────────────────────────────── + + /// After any non-upgrade payment is completed, `WorkJob::SpawnVm` must be + /// queued regardless of payment type. The MAC-address guard inside the + /// worker makes it safe to queue SpawnVm for both first and renewal payments. + #[tokio::test] + async fn test_payment_activates_subscription_and_queues_vm() -> Result<()> { + let db = Arc::new(MockDb::default()); + let wrk = Arc::new(ChannelWorkCommander::new()); + let sub_handler = make_sub_handler_with_commander(db.clone(), wrk.clone()).await?; + let provisioner = sub_handler.vm_provisioner(); + let (user, ssh_key) = add_user(&db).await?; + + // Provision a VM (subscription starts inactive, expires=None) + let vm = provisioner + .provision(user.id, 1, 1, ssh_key.id, None) + .await?; + + let li = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let sub_before = db.get_subscription(li.subscription_id).await?; + assert!(!sub_before.is_active, "subscription must start inactive"); + assert!(sub_before.expires.is_none(), "expires must start as None"); + + // Create and complete a payment + let payment = sub_handler + .renew_subscription(li.id, PaymentMethod::Lightning, 1) + .await?; + sub_handler.complete_payment(&payment).await?; + + // Subscription must now be active with an expiry date + let sub_after = db.get_subscription(li.subscription_id).await?; + assert!(sub_after.is_active, "subscription must be active after payment"); + assert!( + sub_after.expires.is_some(), + "expires must be set after payment" + ); + assert!( + sub_after.expires.unwrap() > Utc::now(), + "expires must be in the future" + ); + assert!(sub_after.is_setup, "is_setup must be true after first payment"); + + // WorkJob::SpawnVm must be queued for every non-upgrade payment. + // recv() is non-blocking here since complete_payment already sent the job + // synchronously above. + let msgs = tokio::time::timeout( + std::time::Duration::from_millis(100), + wrk.recv(), + ) + .await + .expect("timed out waiting for WorkJob::SpawnVm") + ?; + let found_spawn_vm = msgs + .iter() + .any(|m| matches!(&m.job, WorkJob::SpawnVm { vm_id } if *vm_id == vm.id)); + assert!( + found_spawn_vm, + "expected WorkJob::SpawnVm {{ vm_id: {} }} in queued jobs: {:?}", + vm.id, + msgs.iter().map(|m| format!("{:?}", m.job)).collect::>() + ); + Ok(()) } + + /// Two payments created before either is confirmed (both appear as Purchase + /// type) must both queue `WorkJob::SpawnVm`. The MAC-address guard in the + /// worker makes the second job a no-op once the VM is already provisioned. + #[tokio::test] + async fn test_double_payment_both_queue_spawn_vm() -> Result<()> { + let db = Arc::new(MockDb::default()); + let wrk = Arc::new(ChannelWorkCommander::new()); + let sub_handler = make_sub_handler_with_commander(db.clone(), wrk.clone()).await?; + let provisioner = sub_handler.vm_provisioner(); + let (user, ssh_key) = add_user(&db).await?; + + let vm = provisioner + .provision(user.id, 1, 1, ssh_key.id, None) + .await?; + let li = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + + // Create two payments before either is confirmed. + let p1 = sub_handler + .renew_subscription(li.id, PaymentMethod::Lightning, 1) + .await?; + let p2 = sub_handler + .renew_subscription(li.id, PaymentMethod::Lightning, 1) + .await?; + + // Confirm both. + sub_handler.complete_payment(&p1).await?; + sub_handler.complete_payment(&p2).await?; + + // Drain the queue — both payments must have queued SpawnVm. + // ChannelWorkCommander::recv() returns one message at a time, so drain + // in a loop until no more messages arrive within a short timeout. + let mut all_msgs = Vec::new(); + loop { + match tokio::time::timeout(std::time::Duration::from_millis(100), wrk.recv()).await { + Ok(Ok(msgs)) if !msgs.is_empty() => all_msgs.extend(msgs), + _ => break, + } + } + + let spawn_count = all_msgs + .iter() + .filter(|m| matches!(&m.job, WorkJob::SpawnVm { vm_id } if *vm_id == vm.id)) + .count(); + assert_eq!( + spawn_count, 2, + "expected 2 SpawnVm jobs, got {:?}", + all_msgs + .iter() + .map(|m| format!("{:?}", m.job)) + .collect::>() + ); + + Ok(()) + } + + /// When `on_expired` is called for a VM line item `on_expired` must succeed + /// and the VM must remain present in the database (it is only stopped, not + /// deleted). `stop_vm` is best-effort on the hypervisor; a no-op for a VM + /// that hasn't been spawned yet is acceptable. + #[tokio::test] + async fn test_on_expired_stops_vm() -> Result<()> { + let db = Arc::new(MockDb::default()); + let wrk: Arc = Arc::new(ChannelWorkCommander::new()); + let sub_handler = make_sub_handler(db.clone()).await?; + let provisioner = sub_handler.vm_provisioner(); + let (user, ssh_key) = add_user(&db).await?; + + let vm = provisioner + .provision(user.id, 1, 1, ssh_key.id, None) + .await?; + let vm_id = vm.id; + + let li = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let sub = db.get_subscription(li.subscription_id).await?; + + let handler = VmLineItemHandler::new(vm_id, db.clone(), wrk.clone(), provisioner, VmStateCache::new()).await?; + + // on_expired must succeed (stop is best-effort; silently no-ops for unspawned VMs) + handler.on_expired(&sub, &li).await?; + + // VM must still exist in the DB — on_expired only stops, it does NOT delete + assert!( + db.get_vm(vm_id).await.is_ok(), + "VM must still exist in DB after on_expired (stop only, not delete)" + ); + + Ok(()) + } + + /// When `on_grace_period_exceeded` is called the VM must be deleted from the + /// database (MockDb hard-deletes on `delete_vm`), so `get_vm` returns an error. + #[tokio::test] + async fn test_on_grace_period_exceeded_deletes_vm() -> Result<()> { + let db = Arc::new(MockDb::default()); + let wrk: Arc = Arc::new(ChannelWorkCommander::new()); + let sub_handler = make_sub_handler(db.clone()).await?; + let provisioner = sub_handler.vm_provisioner(); + let (user, ssh_key) = add_user(&db).await?; + + let vm = provisioner + .provision(user.id, 1, 1, ssh_key.id, None) + .await?; + let vm_id = vm.id; + + // Confirm VM exists in DB before deletion + assert!(db.get_vm(vm_id).await.is_ok(), "VM must exist before deletion"); + + let li = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let sub = db.get_subscription(li.subscription_id).await?; + + let handler = + VmLineItemHandler::new(vm_id, db.clone(), wrk.clone(), provisioner, VmStateCache::new()).await?; + handler.on_grace_period_exceeded(&sub, &li).await?; + + // VM must be gone from the database after grace period exceeded + assert!( + db.get_vm(vm_id).await.is_err(), + "VM must be deleted from DB after grace period" + ); + + Ok(()) + } + + /// Renewing an already-expired subscription extends `expires` beyond the + /// previous expiry date and re-activates the subscription. + #[tokio::test] + async fn test_renew_after_expiry_extends_expires() -> Result<()> { + let db = Arc::new(MockDb::default()); + let sub_handler = make_sub_handler(db.clone()).await?; + let provisioner = sub_handler.vm_provisioner(); + let (user, ssh_key) = add_user(&db).await?; + + let vm = provisioner + .provision(user.id, 1, 1, ssh_key.id, None) + .await?; + let li = db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + + // First payment — activates subscription + let payment1 = sub_handler + .renew_subscription(li.id, PaymentMethod::Lightning, 1) + .await?; + sub_handler.complete_payment(&payment1).await?; + + let sub_after_first = db.get_subscription(li.subscription_id).await?; + let first_expiry = sub_after_first + .expires + .expect("expires must be set after first payment"); + assert!(sub_after_first.is_active); + + // Manually wind the expiry into the past to simulate an expired subscription + { + let mut subs = db.subscriptions.lock().await; + let sub = subs.get_mut(&li.subscription_id).unwrap(); + sub.expires = Some(Utc::now() - chrono::Duration::days(5)); + sub.is_active = false; + } + + // Second payment — must re-activate and extend beyond the (now-past) expiry + let payment2 = sub_handler + .renew_subscription(li.id, PaymentMethod::Lightning, 1) + .await?; + sub_handler.complete_payment(&payment2).await?; + + let sub_after_second = db.get_subscription(li.subscription_id).await?; + assert!( + sub_after_second.is_active, + "subscription must be re-activated after second payment" + ); + let second_expiry = sub_after_second + .expires + .expect("expires must be set after second payment"); + assert!( + second_expiry > Utc::now(), + "new expiry must be in the future" + ); + assert!( + second_expiry > first_expiry, + "new expiry must be later than the first expiry" + ); + + Ok(()) + } + + /// Helper: build a SubscriptionHandler wired to a specific WorkCommander. + async fn make_sub_handler_with_commander( + db: Arc, + wrk: Arc, + ) -> Result { + let node = Arc::new(MockNode::default()); + let rates = Arc::new(MockExchangeRate::new()); + rates.set_rate(Ticker::btc_rate("EUR")?, 69_420.0).await; + Ok(SubscriptionHandler::new( + mock_settings(), + db.clone(), + node, + rates, + wrk, + VmStateCache::new(), + )?) + } } diff --git a/lnvps_api/src/provisioner/lnvps_network.rs b/lnvps_api/src/provisioner/vm_network.rs similarity index 98% rename from lnvps_api/src/provisioner/lnvps_network.rs rename to lnvps_api/src/provisioner/vm_network.rs index a857481..2b19ae5 100644 --- a/lnvps_api/src/provisioner/lnvps_network.rs +++ b/lnvps_api/src/provisioner/vm_network.rs @@ -11,9 +11,9 @@ use std::str::FromStr; use std::sync::Arc; use try_procedure::{OpError, RetryPolicy, retry_async}; -/// Network assignment tool for [super::LNVpsProvisioner] +/// Network assignment tool for [super::VmProvisioner] #[derive(Clone)] -pub struct LNVpsNetworkProvisioner { +pub struct VmNetworkProvisioner { db: Arc, /// DNS server to add entries to dns: Option>, @@ -23,7 +23,7 @@ pub struct LNVpsNetworkProvisioner { retry_policy: RetryPolicy, } -impl LNVpsNetworkProvisioner { +impl VmNetworkProvisioner { pub fn new( db: Arc, dns: Option>, diff --git a/lnvps_api/src/settings.rs b/lnvps_api/src/settings.rs index a87ce5d..ec2809e 100644 --- a/lnvps_api/src/settings.rs +++ b/lnvps_api/src/settings.rs @@ -1,10 +1,7 @@ use crate::dns::DnsServer; -use crate::exchange::ExchangeRateService; -use crate::provisioner::LNVpsProvisioner; use anyhow::Result; use isocountry::CountryCode; use lnvps_api_common::RedisConfig; -use lnvps_db::LNVpsDb; use payments_rs::fiat::FiatPaymentService; use payments_rs::lightning::LightningNode; use serde::{Deserialize, Serialize}; @@ -106,6 +103,9 @@ pub struct DnsServerConfig { pub enum DnsServerApi { #[serde(rename_all = "kebab-case")] Cloudflare { token: String }, + + #[cfg(test)] + Mock, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -211,29 +211,16 @@ pub struct EncryptionConfig { } impl Settings { - pub fn get_provisioner( - &self, - db: Arc, - node: Arc, - exchange: Arc, - ) -> Arc { - Arc::new(LNVpsProvisioner::new( - self.clone(), - db, - node, - exchange, - self.get_dns().expect("DNS server config"), - )) - } - - pub fn get_dns(&self) -> Result>> { + pub fn get_dns(&self) -> Option> { match &self.dns { - None => Ok(None), + None => None, Some(c) => match &c.api { #[cfg(feature = "cloudflare")] DnsServerApi::Cloudflare { token } => { - Ok(Some(Arc::new(crate::dns::Cloudflare::new(token)))) + Some(Arc::new(crate::dns::Cloudflare::new(token))) } + #[cfg(test)] + DnsServerApi::Mock => Some(Arc::new(crate::mocks::MockDnsServer::new())), }, } } @@ -304,9 +291,7 @@ pub fn mock_settings() -> Settings { smtp: None, dns: Some(DnsServerConfig { forward_zone_id: "mock-forward-zone-id".to_string(), - api: DnsServerApi::Cloudflare { - token: "abc".to_string(), - }, + api: DnsServerApi::Mock, }), nostr: None, revolut: None, diff --git a/lnvps_api/src/subscription/ip_range.rs b/lnvps_api/src/subscription/ip_range.rs new file mode 100644 index 0000000..ff3f11d --- /dev/null +++ b/lnvps_api/src/subscription/ip_range.rs @@ -0,0 +1,66 @@ +use crate::subscription::SubscriptionLineItemHandler; +use anyhow::Result; +use async_trait::async_trait; +use lnvps_api_common::{WorkCommander, WorkJob}; +use lnvps_db::{LNVpsDb, Subscription, SubscriptionLineItem, SubscriptionPayment}; +use log::info; +use std::sync::Arc; + +pub struct IpRangeLineItemHandler { + db: Arc, + tx: Arc, +} + +impl IpRangeLineItemHandler { + pub fn new(db: Arc, tx: Arc) -> Self { + Self { db, tx } + } +} + +#[async_trait] +impl SubscriptionLineItemHandler for IpRangeLineItemHandler { + async fn on_payment(&self, _payment: &SubscriptionPayment) -> Result<()> { + // Trigger the lifecycle worker to pick up the new expiry and activate the allocation + self.tx.send(WorkJob::CheckSubscriptions).await?; + Ok(()) + } + + async fn on_expired(&self, sub: &Subscription, line_item: &SubscriptionLineItem) -> Result<()> { + // Deactivate the ip_range_subscription row(s) linked to this line item + info!( + "IP range line item {} subscription {} expired — deactivating allocation", + line_item.id, sub.id + ); + let ip_subs = self + .db + .list_ip_range_subscriptions_by_line_item(line_item.id) + .await?; + for mut ips in ip_subs { + if ips.is_active { + ips.is_active = false; + ips.ended_at = Some(chrono::Utc::now()); + if let Err(e) = self.db.update_ip_range_subscription(&ips).await { + log::warn!( + "Failed to deactivate ip_range_subscription {}: {}", + ips.id, + e + ); + } + } + } + Ok(()) + } + + async fn on_grace_period_exceeded( + &self, + sub: &Subscription, + line_item: &SubscriptionLineItem, + ) -> Result<()> { + info!( + "IP range line item {} subscription {} grace period exceeded", + line_item.id, sub.id + ); + // Nothing more to do — allocation was already deactivated on_expired. + Ok(()) + } +} diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs new file mode 100644 index 0000000..60c70f7 --- /dev/null +++ b/lnvps_api/src/subscription/mod.rs @@ -0,0 +1,723 @@ +//! Generic subscription line-item lifecycle management. +//! +//! Every product type (VM, IP range, ASN sponsoring, DNS hosting, …) implements +//! [`SubscriptionLineItemHandler`]. Both the payment pipeline and the lifecycle +//! worker call into this single trait, so adding a new product means implementing +//! the trait once in one place. +//! +//! # Usage +//! +//! Build a handler for a specific line item with [`line_item_handler`]. +//! The payment pipeline calls [`SubscriptionLineItemHandler::on_payment`]. +//! The lifecycle worker calls [`SubscriptionLineItemHandler::on_expiring_soon`], +//! [`SubscriptionLineItemHandler::on_expired`], and +//! [`SubscriptionLineItemHandler::on_grace_period_exceeded`]. + +use anyhow::{Context, Result, bail, ensure}; +use async_trait::async_trait; +use chrono::Utc; +use lnvps_api_common::{ + CostResult, ExchangeRateService, NewPaymentInfo, PricingEngine, UpgradeConfig, WorkCommander, + round_msat_to_sat, +}; +use lnvps_db::{ + LNVpsDb, PaymentMethod, Subscription, SubscriptionLineItem, SubscriptionPayment, + SubscriptionPaymentType, SubscriptionType, +}; +use log::{debug, info, warn}; +use payments_rs::currency::{Currency, CurrencyAmount}; +use payments_rs::fiat::FiatPaymentService; +use payments_rs::lightning::{AddInvoiceRequest, LightningNode}; +use std::ops::Add; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +mod ip_range; +mod vm; + +use crate::provisioner::VmProvisioner; +use crate::settings::Settings; +pub use ip_range::IpRangeLineItemHandler; +pub use vm::VmLineItemHandler; +use lnvps_api_common::VmStateCache; + +// ========================================================================= +// Trait +// ========================================================================= + +/// Manages the full lifecycle of a single subscription line item. +#[async_trait] +pub trait SubscriptionLineItemHandler: Send + Sync { + /// Called after `subscription_payment_paid()` has marked the payment as + /// paid in the DB and extended `subscription.expires`. + async fn on_payment(&self, payment: &SubscriptionPayment) -> Result<()>; + + /// Called when `subscription.expires` has passed. + async fn on_expired(&self, sub: &Subscription, line_item: &SubscriptionLineItem) -> Result<()>; + + /// Called when `subscription.expires + delete_after` has passed. + async fn on_grace_period_exceeded( + &self, + sub: &Subscription, + line_item: &SubscriptionLineItem, + ) -> Result<()>; +} + +// ========================================================================= +// Factory +// ========================================================================= + +pub struct CompletePaymentResult { + /// Other VM upgrade payments which have been expired + pub expired_competing_upgrades: Vec, +} + +#[derive(Clone)] +pub struct SubscriptionHandler { + db: Arc, + tx: Arc, + + node: Arc, + revolut: Option>, + + pe: PricingEngine, + vm_provisioner: VmProvisioner, + vm_state_cache: VmStateCache, +} + +impl SubscriptionHandler { + pub fn new( + settings: Settings, + db: Arc, + node: Arc, + rates: Arc, + tx: Arc, + vm_state_cache: VmStateCache, + ) -> Result { + Ok(Self { + revolut: settings.get_revolut()?, + pe: PricingEngine::new(db.clone(), rates, settings.tax_rate.clone()), + vm_provisioner: VmProvisioner::new(settings, db.clone()), + db, + tx, + node, + vm_state_cache, + }) + } + + pub fn work_commander(&self) -> Arc { + self.tx.clone() + } + + pub fn vm_provisioner(&self) -> VmProvisioner { + self.vm_provisioner.clone() + } + + pub fn pricing_engine(&self) -> PricingEngine { + self.pe.clone() + } + + pub fn db(&self) -> Arc { + self.db.clone() + } + + pub async fn make_line_item_handler( + &self, + li: &SubscriptionLineItem, + ) -> Result> { + match li.subscription_type { + SubscriptionType::Vps => { + let vm = self.db.get_vm_by_line_item(li.id).await?; + Ok(Box::new( + VmLineItemHandler::new( + vm.id, + self.db.clone(), + self.tx.clone(), + self.vm_provisioner.clone(), + self.vm_state_cache.clone(), + ) + .await?, + )) + } + SubscriptionType::IpRange => Ok(Box::new(IpRangeLineItemHandler::new( + self.db.clone(), + self.tx.clone(), + ))), + _ => { + unimplemented!() + } + } + } + + pub async fn complete_payment( + &self, + payment: &SubscriptionPayment, + ) -> Result { + self.db.subscription_payment_paid(payment).await?; + + let line_items = self + .db + .list_subscription_line_items(payment.subscription_id) + .await?; + for li in &line_items { + match self.make_line_item_handler(li).await { + Ok(handler) => { + if let Err(e) = handler.on_payment(payment).await { + warn!( + "on_payment failed for line item {} (sub {}): {}", + li.id, payment.subscription_id, e + ); + } + } + Err(e) => { + warn!( + "Failed to build handler for line item {} (sub {}): {}", + li.id, payment.subscription_id, e + ); + } + } + } + + info!( + "Payment {} for subscription {} complete", + hex::encode(&payment.id), + payment.subscription_id + ); + + if payment.payment_type == SubscriptionPaymentType::Upgrade { + // Cancel other pending Lightning upgrade invoices for this subscription. + // If we can't find the VM the payment is still committed as paid — log a + // warning and return an empty result rather than propagating an error that + // would mislead callers into thinking the payment was not completed. + let vm = match self + .db + .get_vm_by_subscription(payment.subscription_id) + .await + { + Ok(vm) => vm, + Err(e) => { + warn!( + "Payment {} marked paid but get_vm_by_subscription failed (sub {}): {}", + hex::encode(&payment.id), + payment.subscription_id, + e + ); + return Ok(CompletePaymentResult { + expired_competing_upgrades: Vec::new(), + }); + } + }; + let other_upgrades = self + .db + .list_pending_vm_subscription_payments(vm.id) + .await? + .into_iter() + .filter(|p| { + p.payment_type == SubscriptionPaymentType::Upgrade && p.id != payment.id + }) + .collect::>(); + + let mut expired_upgrades = Vec::new(); + for ugp in other_upgrades.into_iter() { + let mut expired = ugp; + expired.expires = Utc::now(); + if let Err(e) = self.db.update_subscription_payment(&expired).await { + warn!( + "Failed to update invoice {}: {}", + hex::encode(&expired.id), + e + ); + } + expired_upgrades.push(expired); + } + Ok(CompletePaymentResult { + expired_competing_upgrades: expired_upgrades, + }) + } else { + Ok(CompletePaymentResult { + expired_competing_upgrades: Vec::new(), + }) + } + } + + /// Create a renewal/purchase payment for a subscription + pub async fn renew_subscription( + &self, + subscription_id: u64, + method: PaymentMethod, + intervals: u32, + ) -> Result { + let intervals = intervals.max(1); + + // Get subscription and line items + let subscription = self.db.get_subscription(subscription_id).await?; + let line_items = self + .db + .list_subscription_line_items(subscription_id) + .await?; + ensure!(!line_items.is_empty(), "Subscription has no line items"); + + // Get user for tax calculation + let user = self.db.get_user(subscription.user_id).await?; + + // Calculate total cost for the renewal. + // + // VmRenewal line items use get_vm_cost_for_intervals, which already + // performs the currency conversion (EUR→BTC etc.) internally and + // returns amounts in the payment method's currency together with the + // correct time_value. We must NOT pass those already-converted amounts + // through get_amount_and_rate again — that would cause double conversion. + // + // Non-VM line items store their price in the subscription's base currency + // and are accumulated separately for a single conversion pass at the end. + + let mut setup_fee: u64 = 0; + + // Accumulate NewPaymentInfo from all VM line items + let mut vm_payment_infos: Vec = Vec::new(); + // Accumulate non-VM amounts (in subscription currency) for conversion + let mut non_vm_interval_cost: u64 = 0; + + for item in &line_items { + if item.subscription_type == SubscriptionType::Vps { + let vm = self.db.get_vm_by_line_item(item.id).await?; + match self + .pe + .get_vm_cost_for_intervals(vm.id, method, intervals) + .await? + { + CostResult::New(p) => vm_payment_infos.push(p), + CostResult::Existing(p) => { + // An identical unpaid payment already exists — return it directly + return Ok(p); + } + } + } else { + non_vm_interval_cost += item.amount * intervals as u64; + } + setup_fee += item.setup_amount; + } + + // is_setup is set to true once the first (purchase) payment is confirmed. + let payment_type = if subscription.is_setup { + SubscriptionPaymentType::Renewal + } else { + SubscriptionPaymentType::Purchase + }; + + // Parse subscription currency (needed for non-VM item conversion) + let subscription_currency = Currency::from_str(&subscription.currency) + .map_err(|_| anyhow::anyhow!("Invalid currency"))?; + + // Convert non-VM amounts to the payment method currency if any exist + let (non_vm_converted_amount, non_vm_rate, non_vm_tax, non_vm_processing_fee): ( + u64, + f32, + u64, + u64, + ) = if non_vm_interval_cost > 0 { + let mut base = non_vm_interval_cost; + if !subscription.is_setup { + base += setup_fee; + } + let list_price = CurrencyAmount::from_u64(subscription_currency, base); + let converted = self.pe.get_amount_and_rate(list_price, method).await?; + let tax = self + .pe + .get_tax_for_user(user.id, converted.amount.value()) + .await?; + let processing_fee = self + .pe + .calculate_processing_fee( + subscription.company_id, + method, + converted.amount.currency(), + converted.amount.value(), + ) + .await; + ( + converted.amount.value(), + converted.rate.rate, + tax, + processing_fee, + ) + } else { + (0u64, 0f32, 0u64, 0u64) + }; + + // Aggregate all line item amounts. All VM infos are already in the + // payment method's currency so they can be summed directly. + let vm_amount: u64 = vm_payment_infos.iter().map(|p| p.amount).sum(); + // time_value: sum of all VM intervals (non-VM items don't extend expiry) + let time_value: u64 = vm_payment_infos.iter().map(|p| p.time_value).sum(); + // Use the rate from the first VM item if available, else from non-VM conversion + let rate = vm_payment_infos + .first() + .map(|p| p.rate.rate) + .unwrap_or(non_vm_rate); + // Tax and processing fee are already computed per-item by get_vm_cost_for_intervals; + // add non-VM taxes on top. + let tax: u64 = vm_payment_infos.iter().map(|p| p.tax).sum::() + non_vm_tax; + let processing_fee: u64 = vm_payment_infos + .iter() + .map(|p| p.processing_fee) + .sum::() + + non_vm_processing_fee; + + let total_amount = vm_amount + non_vm_converted_amount; + + // Payment method currency: BTC for Lightning, otherwise subscription currency + let payment_currency = vm_payment_infos + .first() + .map(|p| p.currency) + .unwrap_or(subscription_currency); + + // Wrap the aggregated values so the invoice/order creation below can use them + let converted_amount = total_amount; + let converted_currency = payment_currency; + + // Generate payment based on method + let subscription_payment = match method { + PaymentMethod::Lightning => { + ensure!( + converted_currency == Currency::BTC, + "Lightning payment must be in BTC" + ); + const INVOICE_EXPIRE: u64 = 600; + // Round to nearest satoshi for wallet compatibility + let invoice_amount = round_msat_to_sat(converted_amount + tax); + let desc = match payment_type { + SubscriptionPaymentType::Purchase => { + format!("Subscription purchase: {}", subscription.name) + } + SubscriptionPaymentType::Renewal => { + format!("Subscription renewal: {}", subscription.name) + } + SubscriptionPaymentType::Upgrade => { + format!("Subscription upgrade: {}", subscription.name) + } + }; + + info!( + "Creating invoice for subscription {} for {} sats", + subscription_id, + invoice_amount / 1000 + ); + + let invoice = self + .node + .add_invoice(AddInvoiceRequest { + memo: Some(desc), + amount: invoice_amount, + expire: Some(INVOICE_EXPIRE as u32), + }) + .await?; + + SubscriptionPayment { + id: hex::decode(invoice.payment_hash())?, + subscription_id, + user_id: subscription.user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE)), + amount: converted_amount, + currency: converted_currency.to_string(), + payment_method: method, + payment_type, + external_data: invoice.pr().into(), + external_id: invoice.external_id, + is_paid: false, + rate, + time_value: if time_value > 0 { + Some(time_value) + } else { + None + }, + metadata: None, + tax, + processing_fee, + paid_at: None, + } + } + PaymentMethod::Revolut => { + let rev = if let Some(r) = &self.revolut { + r + } else { + bail!("Revolut not configured") + }; + ensure!( + converted_currency != Currency::BTC, + "Cannot create Revolut orders for BTC currency" + ); + + let desc = match payment_type { + SubscriptionPaymentType::Purchase => { + format!("Subscription purchase: {}", subscription.name) + } + SubscriptionPaymentType::Renewal => { + format!("Subscription renewal: {}", subscription.name) + } + SubscriptionPaymentType::Upgrade => { + format!("Subscription upgrade: {}", subscription.name) + } + }; + + let order_amount = CurrencyAmount::from_u64( + converted_currency, + converted_amount + tax + processing_fee, + ); + let order = rev.create_order(&desc, order_amount, None).await?; + + let new_id: [u8; 32] = rand::random(); + SubscriptionPayment { + id: new_id.to_vec(), + subscription_id, + user_id: subscription.user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(3600)), + amount: converted_amount, + currency: converted_currency.to_string(), + payment_method: method, + payment_type, + external_data: order.raw_data.into(), + external_id: Some(order.external_id), + is_paid: false, + rate, + time_value: if time_value > 0 { + Some(time_value) + } else { + None + }, + metadata: None, + tax, + processing_fee, + paid_at: None, + } + } + PaymentMethod::Paypal => bail!("PayPal not implemented"), + PaymentMethod::Stripe => bail!("Stripe not implemented"), + }; + + // Save payment to database + self.db + .insert_subscription_payment(&subscription_payment) + .await?; + + Ok(subscription_payment) + } + + async fn price_to_payment( + &self, + vm_id: u64, + method: PaymentMethod, + price: CostResult, + ) -> Result { + self.price_to_payment_with_type( + vm_id, + method, + price, + SubscriptionPaymentType::Renewal, + None, + ) + .await + } + + async fn price_to_payment_with_type( + &self, + vm_id: u64, + method: PaymentMethod, + price: CostResult, + payment_type: SubscriptionPaymentType, + metadata: Option, + ) -> Result { + match price { + CostResult::Existing(p) => Ok(p), + CostResult::New(p) => { + let vm = self.db.get_vm(vm_id).await?; + let line_item = self + .db + .get_subscription_line_item(vm.subscription_line_item_id) + .await?; + let subscription_id = line_item.subscription_id; + let desc = match payment_type { + SubscriptionPaymentType::Renewal => { + format!("VM renewal {vm_id} to {}", p.new_expiry) + } + SubscriptionPaymentType::Upgrade => format!("VM upgrade {vm_id}"), + SubscriptionPaymentType::Purchase => format!("VM purchase {vm_id}"), + }; + let payment = match method { + PaymentMethod::Lightning => { + ensure!( + p.currency == Currency::BTC, + "Cannot create invoices for non-BTC currency" + ); + const INVOICE_EXPIRE: u64 = 600; + let total_amount = round_msat_to_sat(p.amount + p.tax); + info!( + "Creating invoice for vm {vm_id} for {} sats", + total_amount / 1000 + ); + let invoice = self + .node + .add_invoice(AddInvoiceRequest { + memo: Some(desc), + amount: total_amount, + expire: Some(INVOICE_EXPIRE as u32), + }) + .await?; + SubscriptionPayment { + id: hex::decode(invoice.payment_hash())?, + subscription_id, + user_id: vm.user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(INVOICE_EXPIRE)), + amount: p.amount, + currency: p.currency.to_string(), + payment_method: method, + payment_type, + external_data: invoice.pr().into(), + external_id: invoice.external_id, + is_paid: false, + rate: p.rate.rate, + time_value: Some(p.time_value), + metadata, + tax: p.tax, + processing_fee: p.processing_fee, + paid_at: None, + } + } + PaymentMethod::Revolut => { + let rev = if let Some(r) = &self.revolut { + r + } else { + bail!("Revolut not configured") + }; + ensure!( + p.currency != Currency::BTC, + "Cannot create revolut orders for BTC currency" + ); + let order = rev + .create_order( + &desc, + CurrencyAmount::from_u64( + p.currency, + p.amount + p.tax + p.processing_fee, + ), + None, + ) + .await?; + let new_id: [u8; 32] = rand::random(); + SubscriptionPayment { + id: new_id.to_vec(), + subscription_id, + user_id: vm.user_id, + created: Utc::now(), + expires: Utc::now().add(Duration::from_secs(3600)), + amount: p.amount, + currency: p.currency.to_string(), + payment_method: method, + payment_type, + external_data: order.raw_data.into(), + external_id: Some(order.external_id), + is_paid: false, + rate: p.rate.rate, + time_value: Some(p.time_value), + metadata, + tax: p.tax, + processing_fee: p.processing_fee, + paid_at: None, + } + } + PaymentMethod::Paypal => todo!(), + PaymentMethod::Stripe => { + todo!("Stripe payment integration not yet implemented") + } + }; + + self.db.insert_subscription_payment(&payment).await?; + + Ok(payment) + } + } + } + + #[cfg(feature = "nostr-nwc")] + /// Attempt automatic renewal via Nostr Wallet Connect + pub async fn auto_renew_via_nwc( + &self, + sub_id: u64, + nwc_string: &str, + ) -> Result { + use nostr_sdk::prelude::*; + + debug!("Attempting automatic renewal for sub {} via NWC", sub_id); + + // Use existing renew_subscription method to create the payment/invoice + let vm_payment = self + .renew_subscription(sub_id, PaymentMethod::Lightning, 1) + .await?; + + // Extract the invoice from external_data + let invoice: String = vm_payment.external_data.clone().into(); + debug!( + "Created renewal invoice for sub {}, attempting NWC payment", + sub_id + ); + + // Parse NWC connection string + let nwc_uri = + NostrWalletConnectUri::from_str(nwc_string).context("Invalid NWC connection string")?; + + // Create nostr client for NWC + let client = nwc::NostrWalletConnect::new(nwc_uri); + client.pay_invoice(PayInvoiceRequest::new(invoice)).await?; + info!("Successful NWC auto-renewal payment for sub {}", sub_id); + Ok(vm_payment) + } + + /// Renew a VM using a specific amount + pub async fn renew_amount( + &self, + vm_id: u64, + amount: CurrencyAmount, + method: PaymentMethod, + ) -> Result { + let price = self.pe.get_cost_by_amount(vm_id, amount, method).await?; + self.price_to_payment(vm_id, method, price).await + } + + /// Create a VM upgrade payment + pub async fn create_vm_upgrade_payment( + &self, + vm_id: u64, + cfg: &UpgradeConfig, + method: PaymentMethod, + ) -> Result { + let cost_difference = self + .pe + .calculate_vm_upgrade_cost(vm_id, cfg, method) + .await?; + + // create a payment entry for upgrade + let payment = NewPaymentInfo { + amount: cost_difference.upgrade.amount.value(), + currency: cost_difference.upgrade.amount.currency(), + rate: cost_difference.upgrade.rate, + time_value: 0, //upgrades dont add time + new_expiry: Default::default(), + tax: 0, // No tax on upgrades for now + processing_fee: 0, // No processing fee on upgrades for now + }; + let metadata = serde_json::to_value(cfg)?; + + self.price_to_payment_with_type( + vm_id, + method, + CostResult::New(payment), + SubscriptionPaymentType::Upgrade, + Some(metadata), + ) + .await + } +} diff --git a/lnvps_api/src/subscription/vm.rs b/lnvps_api/src/subscription/vm.rs new file mode 100644 index 0000000..f2c8f07 --- /dev/null +++ b/lnvps_api/src/subscription/vm.rs @@ -0,0 +1,268 @@ +use crate::provisioner::VmProvisioner; +use crate::subscription::SubscriptionLineItemHandler; +use anyhow::Result; +use async_trait::async_trait; +use lnvps_api_common::{ + UpgradeConfig, VmHistoryLogger, VmRunningState, VmRunningStates, VmStateCache, WorkCommander, + WorkJob, +}; +use lnvps_db::{ + LNVpsDb, Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentType, + SubscriptionType, Vm, +}; +use log::{error, info, warn}; +use std::sync::Arc; + +pub struct VmLineItemHandler { + vm: Vm, + vm_expires_before: chrono::DateTime, + db: Arc, + tx: Arc, + vm_history_logger: VmHistoryLogger, + provisioner: VmProvisioner, + vm_state_cache: VmStateCache, +} + +impl VmLineItemHandler { + pub async fn new( + vm_id: u64, + db: Arc, + tx: Arc, + provisioner: VmProvisioner, + vm_state_cache: VmStateCache, + ) -> Result { + let vm = db.get_vm(vm_id).await?; + let vm_expires_before = db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await + .ok() + .and_then(|s| s.expires) + .unwrap_or_else(chrono::Utc::now); + let vm_history_logger = VmHistoryLogger::new(db.clone()); + Ok(Self { + vm, + vm_expires_before, + db, + tx, + vm_history_logger, + provisioner, + vm_state_cache, + }) + } + + async fn queue_notification(&self, user_id: u64, message: String, title: Option) { + if let Err(e) = self + .tx + .send(WorkJob::SendNotification { + user_id, + message, + title, + }) + .await + { + error!("Failed to queue notification: {}", e); + } + } + + async fn queue_admin_notification(&self, message: String, title: Option) { + if let Err(e) = self + .tx + .send(WorkJob::SendAdminNotification { message, title }) + .await + { + warn!("Failed to send admin notification: {}", e); + } + } +} + +#[async_trait] +impl SubscriptionLineItemHandler for VmLineItemHandler { + async fn on_payment(&self, payment: &SubscriptionPayment) -> Result<()> { + let vm_id = self.vm.id; + let vm = self.db.get_vm(vm_id).await?; + // Get new expiry from subscription (authoritative source) + let vm_expires_after = self + .db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await + .ok() + .and_then(|s| s.expires) + .unwrap_or_else(chrono::Utc::now); + + let payment_metadata = serde_json::json!({ + "payment_id": hex::encode(&payment.id), + "payment_method": payment.payment_method.to_string() + }); + + if let Err(e) = self + .vm_history_logger + .log_vm_payment_received( + vm_id, + payment.amount + payment.tax + payment.processing_fee, + &payment.currency, + payment.time_value.unwrap_or(0), + Some(payment_metadata), + ) + .await + { + warn!("Failed to log payment for VM {}: {}", vm_id, e); + } + + let time_value = payment.time_value.unwrap_or(0); + if time_value > 0 { + if let Err(e) = self + .vm_history_logger + .log_vm_renewed( + vm_id, + None, + self.vm_expires_before, + vm_expires_after, + Some(payment.amount + payment.tax + payment.processing_fee), + Some(&payment.currency), + Some(serde_json::json!({ + "time_added_seconds": time_value, + "payment_id": hex::encode(&payment.id) + })), + ) + .await + { + warn!("Failed to log VM {} renewal: {}", vm_id, e); + } + } + + info!( + "Subscription payment {} for VM {}, paid", + hex::encode(&payment.id), + vm_id + ); + + if payment.payment_type == SubscriptionPaymentType::Upgrade { + // Parse upgrade parameters from the metadata field + if let Some(metadata) = &payment.metadata { + if let Ok(upgrade_params) = + serde_json::from_value::(metadata.clone()) + { + info!( + "Processing upgrade payment for VM {} with params: CPU={:?}, Memory={:?}, Disk={:?}", + vm_id, + upgrade_params.new_cpu, + upgrade_params.new_memory, + upgrade_params.new_disk + ); + self.tx + .send(WorkJob::ProcessVmUpgrade { + vm_id, + config: upgrade_params, + }) + .await?; + } else { + warn!( + "Upgrade payment {} has invalid upgrade parameters in metadata", + hex::encode(&payment.id) + ); + } + } else { + warn!( + "Upgrade payment {} missing metadata field", + hex::encode(&payment.id) + ); + } + } else { + // For the very first payment on a VM (mac_address == ff:ff:ff:ff:ff:ff), + // immediately set the cache state to Creating so the UI can show a + // meaningful "creating" status while the provisioner runs. + let vm = self.db.get_vm(vm_id).await?; + if vm.mac_address == "ff:ff:ff:ff:ff:ff" { + if let Err(e) = self + .vm_state_cache + .set_state( + vm_id, + VmRunningState { + state: VmRunningStates::Creating, + ..Default::default() + }, + ) + .await + { + warn!("Failed to set Creating state for VM {}: {}", vm_id, e); + } + } + // Always queue SpawnVm for non-upgrade payments. The worker checks + // whether the VM has ever been provisioned (via mac_address) and + // falls back to CheckVm if it already exists on the host. This is + // safe against multiple concurrent payments of any type: the + // mac_address guard makes SpawnVm idempotent. + self.tx.send(WorkJob::SpawnVm { vm_id }).await?; + } + + Ok(()) + } + + async fn on_expired( + &self, + _sub: &Subscription, + line_item: &SubscriptionLineItem, + ) -> Result<()> { + // skip anything that isn't the vm line item (skip upgrade lines) + if line_item.subscription_type != SubscriptionType::Vps { + return Ok(()); + } + info!("Stopping expired VM {}", self.vm.id); + if let Err(e) = self.provisioner.stop_vm(self.vm.id).await { + warn!("Failed to stop VM {}: {}", self.vm.id, e); + } else if let Err(e) = self + .vm_history_logger + .log_vm_expired(self.vm.id, None) + .await + { + warn!("Failed to log VM {} expiration: {}", self.vm.id, e); + } + self.queue_notification( + self.vm.user_id, + format!( + "Your VM #{} has expired and has been stopped.\n\nPlease renew your subscription within {} day(s) to restore access. If not renewed, the VM and all its data will be permanently deleted.", + self.vm.id, self.provisioner.delete_after + ), + Some(format!("[VM{}] Expired", self.vm.id)), + ).await; + Ok(()) + } + + async fn on_grace_period_exceeded( + &self, + sub: &Subscription, + line_item: &SubscriptionLineItem, + ) -> Result<()> { + // skip anything that isn't the vm line item (skip upgrade lines) + if line_item.subscription_type != SubscriptionType::Vps { + return Ok(()); + } + let vm_id = self.vm.id; + info!("VM {} subscription {} grace period exceeded", vm_id, sub.id); + if self.vm.deleted { + return Ok(()); + } + + if let Err(e) = self.provisioner.delete_vm(vm_id).await { + warn!("Failed to delete expired VM {}: {}", vm_id, e); + } else { + if let Err(e) = self + .vm_history_logger + .log_vm_deleted(vm_id, None, Some("expired and exceeded grace period"), None) + .await + { + warn!("Failed to log VM {} deletion: {}", vm_id, e); + } + } + let title = Some(format!("[VM{}] Deleted", self.vm.id)); + self.queue_admin_notification( + format!( + "VM #{} has been permanently deleted after exceeding the grace period without renewal.\nUser ID: {}", + self.vm.id, self.vm.user_id + ), + title, + ) + .await; + Ok(()) + } +} diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 717ee87..764896f 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -1,8 +1,9 @@ -use crate::host::{FullVmInfo, get_host_client}; -use crate::provisioner::LNVpsProvisioner; +use crate::host::{FullVmInfo, VmHostClient, get_host_client}; +use crate::provisioner::VmProvisioner; use crate::settings::{ProvisionerConfig, Settings, SmtpConfig}; use crate::ssh_client::SshClient; -use anyhow::{Context, Result, bail}; +use crate::subscription::SubscriptionHandler; +use anyhow::{Context, Result, anyhow, bail}; use chrono::{DateTime, Datelike, Days, TimeDelta, Utc}; use hickory_resolver::TokioResolver; use lettre::AsyncTransport; @@ -12,17 +13,22 @@ use lettre::{AsyncSmtpTransport, Tokio1Executor}; use lnvps_api_common::{ BlackholeWorkFeedback, ChannelWorkCommander, InMemoryKeyValueStore, JobFeedback, KeyValueStore, NetworkProvisioner, RedisConfig, RedisKeyValueStore, RedisWorkCommander, RedisWorkFeedback, - UpgradeConfig, VmHistoryLogger, VmRunningState, VmRunningStates, VmStateCache, WorkCommander, - WorkFeedback, WorkJob, WorkJobMessage, op_fatal, + UpgradeConfig, VmHistoryLogger, VmRunningState, VmStateCache, WorkCommander, WorkFeedback, + WorkJob, WorkJobMessage, op_fatal, retry::{OpError, Pipeline, RetryPolicy}, }; -use lnvps_db::{CpuArch, CpuFeature, CpuMfg, LNVpsDb, Vm, VmHost, VmIpAssignment, VmOsImage}; +use lnvps_db::{ + CpuArch, CpuFeature, CpuMfg, IntervalType, LNVpsDb, Subscription, SubscriptionLineItem, + SubscriptionType, Vm, VmHost, VmHostKind, VmIpAssignment, VmOsImage, +}; use log::{debug, error, info, warn}; use nostr_sdk::{Client, EventBuilder, PublicKey, ToBech32}; +use payments_rs::currency::{Currency, CurrencyAmount}; use serde::Deserialize; use std::collections::HashMap; use std::ops::{Add, Sub}; use std::path::Path; +use std::str::FromStr; use std::sync::Arc; use std::time::Duration; use tokio::task::JoinHandle; @@ -92,7 +98,7 @@ struct HostInfoOutput { pub struct Worker { settings: WorkerSettings, db: Arc, - provisioner: Arc, + subscription_handler: SubscriptionHandler, nostr: Option, vm_history_logger: VmHistoryLogger, vm_state_cache: VmStateCache, @@ -128,7 +134,8 @@ impl Worker { pub async fn new( db: Arc, - provisioner: Arc, + work_commander: Arc, + subscription_handler: SubscriptionHandler, settings: impl Into, vm_state_cache: VmStateCache, nostr: Option, @@ -136,12 +143,6 @@ impl Worker { let vm_history_logger = VmHistoryLogger::new(db.clone()); let settings = settings.into(); - let work_commander: Arc = if let Some(redis_config) = &settings.redis { - Arc::new(RedisWorkCommander::new(&redis_config.url, "workers", "api-worker").await?) - } else { - Arc::new(ChannelWorkCommander::new()) - }; - let kv: Arc = if let Some(c) = &settings.redis { Arc::new(RedisKeyValueStore::new(&c.url).await?) } else { @@ -156,9 +157,10 @@ impl Worker { let http_client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build()?; + Ok(Self { db, - provisioner, + subscription_handler, vm_state_cache, nostr, kv, @@ -199,147 +201,276 @@ impl Worker { Ok(()) } - /// Handle VM state - /// 1. Expire VM and send notification - /// 2. Stop VM if expired and still running - /// 3. Send notification for expiring soon - async fn handle_vm_state(&self, vm: &Vm, state: &VmRunningState) -> Result<()> { - const BEFORE_EXPIRE_NOTIFICATION: u64 = 1; + pub async fn get_last_check_subscriptions(&self) -> Result> { + let Some(v) = self.kv.get("worker-last-check-subscriptions").await? else { + return Ok(DateTime::UNIX_EPOCH); + }; + let timestamp = if v.len() == 8 { + u64::from_le_bytes(v.as_slice().try_into()?) + } else { + 0 + }; + Ok(DateTime::from_timestamp(timestamp as _, 0).unwrap()) + } + + pub async fn set_last_check_subscriptions(&self, ts: DateTime) -> Result<()> { + let t = ts.timestamp() as u64; + self.kv + .store("worker-last-check-subscriptions", &t.to_le_bytes()) + .await?; + Ok(()) + } - let last_check = self.get_last_check_vms().await?; + /// Handle subscription lifecycle state by dispatching to per-line-item handlers. + /// 1. Expiring soon: attempt NWC auto-renewal; notify user; call on_expiring_soon per line item + /// 2. Expired: call on_expired per line item + /// 3. Grace period exceeded: notify user; call on_grace_period_exceeded per line item + async fn handle_subscription_state( + &self, + sub: &Subscription, + last_check: DateTime, + ) -> Result<()> { + const BEFORE_EXPIRE_NOTIFICATION_DAYS: u64 = 1; + let Some(expires) = sub.expires else { + return Ok(()); + }; + + let line_items = self.db.list_subscription_line_items(sub.id).await?; + let sub_notification_subject = self.sub_notification_subject(sub, &line_items).await; + let sub_notification_descr = Self::sub_notification_message(sub, &line_items); - // Attempt automatic renewal or send notification of VM expiring soon - if vm.expires < Utc::now().add(Days::new(BEFORE_EXPIRE_NOTIFICATION)) - && vm.expires > last_check.add(Days::new(BEFORE_EXPIRE_NOTIFICATION)) + // --- Expiring soon --- + let expiry_window = Utc::now().add(Days::new(BEFORE_EXPIRE_NOTIFICATION_DAYS)); + if expires < expiry_window + && expires > last_check.add(Days::new(BEFORE_EXPIRE_NOTIFICATION_DAYS)) { - // Try automatic renewal via NWC if both user NWC and VM auto-renewal are enabled - let user = self.db.get_user(vm.user_id).await?; - let mut renewal_attempted = false; - let mut renewal_successful = false; - let mut nwc_error = String::new(); + // Track whether NWC auto-renewal was attempted and succeeded (so we skip the + // generic "expiring soon" notification below). + let mut auto_renewed = false; #[cfg(feature = "nostr-nwc")] - if vm.auto_renewal_enabled { + if sub.auto_renewal_enabled { + let user = self.db.get_user(sub.user_id).await?; if let Some(ref nwc_connection) = user.nwc_connection_string { let nwc_string: String = nwc_connection.clone().into(); if !nwc_string.is_empty() { info!( - "Attempting automatic renewal for VM {} via NWC (user has NWC configured and VM auto-renewal is enabled)", - vm.id + "Attempting auto-renewal for subscription {} via NWC", + sub.id ); - renewal_attempted = true; - match self - .provisioner - .auto_renew_via_nwc(vm.id, &nwc_string) + .subscription_handler + .auto_renew_via_nwc(sub.id, &nwc_string) .await { Ok(_) => { - renewal_successful = true; - info!("Successfully auto-renewed VM {} via NWC", vm.id); - self.queue_notification(vm.user_id, format!("Your VM #{} has been automatically renewed via Nostr Wallet Connect and will continue running.", vm.id), Some(format!("[VM{}] Auto-Renewed", vm.id))).await; + info!("Successfully auto-renewed subscription {} via NWC", sub.id); + self.queue_notification( + sub.user_id, + format!("Your subscription has been automatically renewed via Nostr Wallet Connect.\n{}", sub_notification_descr), + Some(format!("[{}] Auto-Renewed", sub_notification_subject)), + ).await; + auto_renewed = true; } Err(e) => { - warn!("Auto-renewal error for VM {}: {}", vm.id, e); - nwc_error = e.to_string(); + warn!("Auto-renewal error for subscription {}: {}", sub.id, e); + self.queue_notification( + sub.user_id, + format!( + "Your subscription will expire soon.\nAutomatic renewal failed: '{}'\nPlease renew manually in the next {} day(s).\n{}", + e, BEFORE_EXPIRE_NOTIFICATION_DAYS, sub_notification_descr + ), + Some(format!("[{}] Expiring Soon", sub_notification_subject)), + ) + .await; + auto_renewed = true; } } - } else { - info!( - "VM {} has auto-renewal enabled but user has no NWC connection configured", - vm.id - ); } - } else { - info!( - "VM {} has auto-renewal enabled but user has no NWC connection configured", - vm.id - ); } } - // If no renewal was attempted or renewal failed, send the expiry notification - if !renewal_attempted || !renewal_successful { - info!("Sending expire soon notification VM {}", vm.id); - let message = if renewal_attempted { - format!( - "Your VM #{} will expire soon.\nAutomatic renewal failed, please manually renew in the next {} days or your VM will be stopped.\nError: '{}'", - vm.id, BEFORE_EXPIRE_NOTIFICATION, nwc_error - ) - } else { - format!( - "Your VM #{} will expire soon, please renew in the next {} days or your VM will be stopped.", - vm.id, BEFORE_EXPIRE_NOTIFICATION - ) - }; - + // Send a plain expiry warning whenever NWC auto-renewal was not attempted + // (feature disabled, auto_renewal off, or no NWC string configured). + if !auto_renewed { self.queue_notification( - vm.user_id, - message, - Some(format!("[VM{}] Expiring Soon", vm.id)), + sub.user_id, + format!( + "Your subscription will expire soon. Please renew manually in the next {} day(s).\n{}", + BEFORE_EXPIRE_NOTIFICATION_DAYS, sub_notification_descr + ), + Some(format!("[{}] Expiring Soon", sub_notification_subject)), ) .await; } - } + } else if expires.add(Days::new(self.settings.delete_after as u64)) < Utc::now() { + // mark subscription as not-active + let mut sub = sub.clone(); + sub.is_active = false; + self.db.update_subscription(&sub).await?; - // Stop VM if expired and is running - if vm.expires < Utc::now() && state.state == VmRunningStates::Running { - info!("Stopping expired VM {}", vm.id); - if let Err(e) = self.provisioner.stop_vm(vm.id).await { - warn!("Failed to stop VM {}: {}", vm.id, e); - } else if let Err(e) = self.vm_history_logger.log_vm_expired(vm.id, None).await { - warn!("Failed to log VM {} expiration: {}", vm.id, e); + self.queue_notification( + sub.user_id, + format!( + "Your subscription has been cancelled.\n{}", + sub_notification_descr + ), + Some(format!("[{}] Cancelled", sub_notification_subject)), + ) + .await; + for li in &line_items { + match self.subscription_handler.make_line_item_handler(li).await { + Ok(h) => { + if let Err(e) = h.on_grace_period_exceeded(&sub, li).await { + warn!( + "on_grace_period_exceeded failed for line item {}: {}", + li.id, e + ); + } + } + Err(e) => warn!("Failed to build handler for line item {}: {}", li.id, e), + } } + } else if expires < Utc::now() { self.queue_notification( - vm.user_id, - format!("Your VM #{} has expired and is now stopped, please renew in the next {} days or your VM will be deleted.", vm.id, self.settings.delete_after), - Some(format!("[VM{}] Expired", vm.id)), - ).await; + sub.user_id, + format!("Your subscription has expired.\n{}", sub_notification_descr), + Some(format!("[{}] Expired", sub_notification_subject)), + ) + .await; + for li in &line_items { + match self.subscription_handler.make_line_item_handler(li).await { + Ok(h) => { + if let Err(e) = h.on_expired(sub, li).await { + warn!("on_expired failed for line item {}: {}", li.id, e); + } + } + Err(e) => warn!("Failed to build handler for line item {}: {}", li.id, e), + } + } } - // Delete VM if expired > self.settings.delete_after days - if vm.expires.add(Days::new(self.settings.delete_after as u64)) < Utc::now() && !vm.deleted + Ok(()) + } + + /// Get the subscription notification subject line + async fn sub_notification_subject( + &self, + sub: &Subscription, + line_items: &Vec, + ) -> String { + if line_items + .iter() + .all(|l| l.subscription_type == SubscriptionType::Vps) { - info!("Deleting expired VM {}", vm.id); - self.provisioner.delete_vm(vm.id).await?; + if let Ok(vm) = self.db.get_vm_by_subscription(sub.id).await { + return format!("VM{}", vm.id); + } + } + format!("Sub #{}", sub.id) + } - // Log VM deletion - if let Err(e) = self - .vm_history_logger - .log_vm_deleted(vm.id, None, Some("expired and exceeded grace period"), None) - .await - { - warn!("Failed to log VM {} deletion: {}", vm.id, e); + /// Get the subscription notification message body, describe the line items / services + fn sub_notification_message( + sub: &Subscription, + line_items: &Vec, + ) -> String { + let interval_str = match sub.interval_type { + IntervalType::Day => { + if sub.interval_amount == 1 { + "per day".to_string() + } else { + format!("every {} days", sub.interval_amount) + } + } + IntervalType::Month => { + if sub.interval_amount == 1 { + "per month".to_string() + } else { + format!("every {} months", sub.interval_amount) + } } + IntervalType::Year => { + if sub.interval_amount == 1 { + "per year".to_string() + } else { + format!("every {} years", sub.interval_amount) + } + } + }; - let title = Some(format!("[VM{}] Deleted", vm.id)); - self.queue_notification( - vm.user_id, - format!("Your VM #{} has been deleted!", vm.id), - title.clone(), - ) - .await; - self.queue_admin_notification(format!("VM{} is ready for deletion", vm.id), title) - .await; + let mut msg = format!("Subscription: {}\n\nServices:\n", sub.name); + + for li in line_items { + let formatted_amount = if let Ok(cur) = Currency::from_str(&sub.currency) { + CurrencyAmount::from_u64(cur, li.amount).to_string() + } else { + li.amount.to_string() + }; + + let formatted_setup_amount = if let Ok(cur) = Currency::from_str(&sub.currency) { + CurrencyAmount::from_u64(cur, li.setup_amount).to_string() + } else { + li.amount.to_string() + }; + + msg.push_str(&format!( + "- {} — {} {}", + li.name, formatted_amount, interval_str + )); + if li.setup_amount > 0 { + msg.push_str(&format!(" + {} setup fee", formatted_setup_amount)); + } + msg.push('\n'); + if let Some(ref desc) = li.description { + msg.push_str(&format!(" {}\n", desc)); + } } - Ok(()) + if let Some(ref desc) = sub.description { + msg.push_str(&format!("\nNote: {}\n", desc)); + } + + msg } - /// Check a VM's status - async fn check_vm(&self, vm: &Vm) -> Result<()> { - debug!("Checking VM: {}", vm.id); - let host = self.db.get_host(vm.host_id).await?; - let client = get_host_client(&host, &self.settings.provisioner_config)?; + /// Check all active subscriptions for expiry, auto-renewal, and deactivation. + pub async fn check_subscriptions(&self) -> Result<()> { + let last_check = self.get_last_check_subscriptions().await?; + let time_since = Utc::now().signed_duration_since(last_check); + if time_since.num_seconds() < Self::CHECK_VMS_SECONDS as i64 { + debug!( + "Skipping CheckSubscriptions - only {}s since last check", + time_since.num_seconds() + ); + return Ok(()); + } + + let subscriptions = self.db.list_lifecycle_subscriptions().await?; + for sub in &subscriptions { + if let Err(e) = self.handle_subscription_state(sub, last_check).await { + error!("Failed to handle subscription {} state: {}", sub.id, e); + } + } - match client.get_vm_state(vm).await { + self.set_last_check_subscriptions(Utc::now()).await?; + Ok(()) + } + + async fn handle_vm_state(&self, state: Result, vm: &Vm) -> Result<()> { + match state { Ok(s) => { - self.handle_vm_state(vm, &s).await?; self.vm_state_cache.set_state(vm.id, s).await?; } Err(e) => { warn!("Failed to get VM{} state: {}", vm.id, e); - if vm.expires > Utc::now() { + if !vm.deleted + && self + .vm_expires(vm) + .await + .map(|e| e > Utc::now()) + .unwrap_or(false) + { self.spawn_vm_internal(vm).await?; } } @@ -347,6 +478,32 @@ impl Worker { Ok(()) } + /// Resolve the authoritative expiry for a VM from its subscription. + async fn vm_expires(&self, vm: &Vm) -> Option> { + self.db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await + .ok()? + .expires + } + + /// Check VM state from hypervisor and update cache + /// Lifecycle enforcement (stop/delete) is handled by subscription lifecycle handlers. + async fn check_vm(&self, vm: &Vm) -> Result<()> { + debug!("Checking VM: {}", vm.id); + let host = self.db.get_host(vm.host_id).await?; + let client = get_host_client(&host, &self.settings.provisioner_config)?; + self.handle_vm_state( + client + .get_vm_state(vm) + .await + .map_err(|e| anyhow!("VM state error {e}")), + &vm, + ) + .await?; + Ok(()) + } + /// Check multiple VMs on a single host using bulk API async fn check_vms_on_host(&self, host_id: u64, vms: &[&Vm]) -> Result<()> { debug!("Checking {} VMs on host {}", vms.len(), host_id); @@ -354,28 +511,25 @@ impl Worker { let client = get_host_client(&host, &self.settings.provisioner_config)?; let states = client.get_all_vm_states().await?; - // Create a map of VM states by VM ID for quick lookup let state_map: HashMap = states.into_iter().collect(); for vm in vms { - if let Some(state) = state_map.get(&vm.id) { - // Use the bulk-fetched state - self.handle_vm_state(vm, state).await?; - self.vm_state_cache.set_state(vm.id, state.clone()).await?; - } else { - // VM not found in bulk response, handle as missing - warn!("VM {} not found in bulk response", vm.id); - if vm.expires > Utc::now() { - self.spawn_vm_internal(vm).await?; - } - } + self.handle_vm_state( + state_map + .get(&vm.id) + .map(|s| s.clone()) + .context("VM not found in bulk response"), + &vm, + ) + .await?; } Ok(()) } /// Spawn a VM and send notifications async fn spawn_vm_internal(&self, vm: &Vm) -> Result<()> { - let pipeline = self.provisioner.spawn_vm_pipeline(vm.id).await?; + let provisioner = self.subscription_handler.vm_provisioner(); + let pipeline = provisioner.spawn_vm_pipeline(vm.id).await?; pipeline.execute().await?; // Log VM created @@ -392,31 +546,40 @@ impl Worker { let user = self.db.get_user(vm.user_id).await?; let resources = FullVmInfo::vm_resources(vm.id, self.db.clone()).await?; - let msg = format!( - "VM #{} been created!\n\nOS: {}\nCPU: {}\nRAM: {}GB\nDisk: {}GB\n{}\n\nNPUB: {}", + let ip_lines = vm_ips + .iter() + .map(|i| { + if let Some(fwd) = &i.dns_forward { + format!("IP: {} ({})", i.ip, fwd) + } else { + format!("IP: {}", i.ip) + } + }) + .collect::>() + .join("\n"); + let user_msg = format!( + "Your VM #{} has been created!\n\nOS: {}\nCPU: {} vCPU\nRAM: {} GB\nDisk: {} GB\n{}\n\nNPUB: {}", vm.id, image, resources.cpu, resources.memory / crate::GB, resources.disk_size / crate::GB, - vm_ips - .iter() - .map(|i| if let Some(fwd) = &i.dns_forward { - format!("IP: {} ({})", i.ip, fwd) - } else { - format!("IP: {}", i.ip) - }) - .collect::>() - .join("\n"), + ip_lines, PublicKey::from_slice(&user.pubkey)?.to_bech32()? ); - self.queue_notification( - vm.user_id, - format!("Your {}", &msg), - Some(format!("[VM{}] Created", vm.id)), - ) - .await; - self.queue_admin_notification(msg, Some(format!("[VM{}] Created", vm.id))) + let admin_msg = format!( + "VM #{} has been created.\n\nOS: {}\nCPU: {} vCPU\nRAM: {} GB\nDisk: {} GB\n{}\n\nUser NPUB: {}", + vm.id, + image, + resources.cpu, + resources.memory / crate::GB, + resources.disk_size / crate::GB, + ip_lines, + PublicKey::from_slice(&user.pubkey)?.to_bech32()? + ); + self.queue_notification(vm.user_id, user_msg, Some(format!("[VM{}] Created", vm.id))) + .await; + self.queue_admin_notification(admin_msg, Some(format!("[VM{}] Created", vm.id))) .await; Ok(()) } @@ -466,29 +629,40 @@ impl Worker { // check VM status from db vm list let db_vms = self.db.list_vms().await?; + let provisioner = self.subscription_handler.vm_provisioner(); // Group VMs by host for bulk checking let mut vms_by_host: HashMap> = HashMap::new(); let mut vms_to_delete = Vec::new(); for vm in &db_vms { - let is_new_vm = vm.created == vm.expires; - - // only check spawned vms - if !is_new_vm { - vms_by_host.entry(vm.host_id).or_default().push(vm); + if vm.deleted { + continue; } - // delete vm if not paid (in new state) after 1 hour - if is_new_vm && !vm.deleted && vm.expires < Utc::now().sub(TimeDelta::hours(1)) { + // A VM is "new" (never paid) if its subscription has never been set up. + let Some(sub) = self + .db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await + .ok() + else { + warn!("Skipping VM{}, no subscription found (corrupted?)", vm.id); + continue; + }; + + let vm_old_enough_to_delete = Utc::now() - sub.created > TimeDelta::hours(1); + if vm_old_enough_to_delete && !sub.is_setup { vms_to_delete.push(vm); + } else if sub.is_setup { + vms_by_host.entry(vm.host_id).or_default().push(vm); } } // Process deletions first for vm in vms_to_delete { info!("Deleting unpaid VM {}", vm.id); - if let Err(e) = self.provisioner.delete_vm(vm.id).await { + if let Err(e) = provisioner.delete_vm(vm.id).await { error!("Failed to delete unpaid VM {}: {}", vm.id, e); self.queue_admin_notification( format!("Failed to delete unpaid VM {}:\n{}", vm.id, e), @@ -502,17 +676,6 @@ impl Worker { for (host_id, vms) in vms_by_host { if let Err(e) = self.check_vms_on_host(host_id, &vms).await { error!("Failed to check VMs on host {}: {}", host_id, e); - // Fall back to individual checking for this host - for vm in vms { - if let Err(e) = self.check_vm(vm).await { - error!("Failed to check VM {}: {}", vm.id, e); - self.queue_admin_notification( - format!("Failed to check VM {}:\n{}", vm.id, e), - Some(format!("VM {} Check Failed", vm.id)), - ) - .await - } - } } } @@ -741,6 +904,9 @@ impl Worker { } async fn patch_host(&self, host: &mut VmHost) -> Result<()> { + if host.kind == VmHostKind::Dummy { + return Ok(()); + } let client = match get_host_client(host, &self.settings.provisioner_config) { Ok(h) => h, Err(e) => bail!("Failed to get host client: {} {}", host.name, e), @@ -791,7 +957,13 @@ impl Worker { // Patch firewall configuration for all VMs on this host let vms = self.db.list_vms_on_host(host.id).await?; for vm in &vms { - if !vm.deleted && vm.expires > Utc::now() { + if !vm.deleted + && self + .vm_expires(vm) + .await + .map(|e| e > Utc::now()) + .unwrap_or(false) + { info!("Patching firewall for VM {} on host {}", vm.id, host.name); match FullVmInfo::load(vm.id, self.db.clone()).await { Ok(vm_config) => { @@ -1441,6 +1613,17 @@ impl Worker { let vm = self.db.get_vm(*vm_id).await?; self.check_vm(&vm).await?; } + WorkJob::SpawnVm { vm_id } => { + let vm = self.db.get_vm(*vm_id).await?; + if vm.mac_address == "ff:ff:ff:ff:ff:ff" { + // VM has never been provisioned on the host — spawn it now. + self.spawn_vm_internal(&vm).await?; + } else { + // VM already exists (a prior SpawnVm succeeded). + // Just sync its state into the cache. + self.check_vm(&vm).await?; + } + } WorkJob::SendNotification { user_id, message, @@ -1484,6 +1667,9 @@ impl Worker { WorkJob::CheckVms => { self.check_vms().await?; } + WorkJob::CheckSubscriptions => { + self.check_subscriptions().await?; + } WorkJob::DeleteVm { vm_id, reason, @@ -1495,7 +1681,8 @@ impl Worker { } // Delete the VM via provisioner - self.provisioner.delete_vm(*vm_id).await?; + let provisioner = self.subscription_handler.vm_provisioner(); + provisioner.delete_vm(*vm_id).await?; // Log VM deletion let metadata = if let Some(admin_id) = admin_user_id { @@ -1549,17 +1736,19 @@ impl Worker { bail!("Cannot start deleted VM {}", vm_id); } - // Check if VM is expired - if vm.expires < Utc::now() { - bail!( - "Cannot start expired VM {} - it has expired (expires: {})", - vm_id, - vm.expires - ); + // Check if VM is expired via subscription + if self + .vm_expires(&vm) + .await + .map(|e| e < Utc::now()) + .unwrap_or(false) + { + bail!("Cannot start expired VM {}", vm_id); } // Start the VM via provisioner - self.provisioner.start_vm(*vm_id).await?; + let provisioner = self.subscription_handler.vm_provisioner(); + provisioner.start_vm(*vm_id).await?; // Log VM start let metadata = if let Some(admin_id) = admin_user_id { @@ -1609,7 +1798,8 @@ impl Worker { } // Stop the VM via provisioner - self.provisioner.stop_vm(*vm_id).await?; + let provisioner = self.subscription_handler.vm_provisioner(); + provisioner.stop_vm(*vm_id).await?; // Log VM stop let metadata = if let Some(admin_id) = admin_user_id { @@ -1712,9 +1902,8 @@ impl Worker { reason, } => { info!("Admin {} creating VM for user {}", admin_user_id, user_id); - - let vm = self - .provisioner + let provisioner = self.subscription_handler.vm_provisioner(); + let vm = provisioner .provision( *user_id, *template_id, @@ -1856,7 +2045,7 @@ impl Worker { vm_id: u64, cfg: UpgradeConfig, db: Arc, - provisioner: Arc, + provisioner: VmProvisioner, settings: WorkerSettings, vm_history_logger: VmHistoryLogger, } @@ -1865,7 +2054,7 @@ impl Worker { vm_id, cfg: cfg.clone(), db: self.db.clone(), - provisioner: self.provisioner.clone(), + provisioner: self.subscription_handler.vm_provisioner(), settings: self.settings.clone(), vm_history_logger: self.vm_history_logger.clone(), }; @@ -1923,6 +2112,12 @@ impl Worker { // Update the custom template in the database ctx.db.update_custom_vm_template(&new_template).await?; + // Update the subscription line item's renewal amount so that the + // displayed subscription cost reflects the upgraded specs. + ctx.provisioner + .update_line_item_cost_for_custom_vm(ctx.vm_id) + .await?; + // Log the upgrade in VM history let upgrade_metadata = serde_json::json!({ "upgrade_type": "custom_template_update", @@ -2054,11 +2249,22 @@ impl Worker { .execute() .await?; + let upgraded_vm = self.db.get_vm(vm_id).await?; + let new_resources = FullVmInfo::vm_resources(vm_id, self.db.clone()).await; + let specs_line = match new_resources { + Ok(r) => format!( + "\n\nNew specifications:\nCPU: {} vCPU\nRAM: {} GB\nDisk: {} GB", + r.cpu, + r.memory / crate::GB, + r.disk_size / crate::GB + ), + Err(_) => String::new(), + }; self.queue_notification( - self.db.get_vm(vm_id).await?.user_id, + upgraded_vm.user_id, format!( - "Your VM #{} has been successfully upgraded. The new specifications are now active.", - vm_id + "Your VM #{} has been successfully upgraded. The new specifications are now active.{}", + vm_id, specs_line ), Some(format!("[VM{}] Upgrade Complete", vm_id)), ).await; @@ -2136,7 +2342,8 @@ impl Worker { dns_reverse_ref: None, }; - self.provisioner + self.subscription_handler + .vm_provisioner() .network .save_ip_assignment(&mut assignment) .await?; @@ -2191,7 +2398,8 @@ impl Worker { let mut assignment = self.db.get_vm_ip_assignment(assignment_id).await?; let range = self.db.get_ip_range(assignment.ip_range_id).await?; - self.provisioner + self.subscription_handler + .vm_provisioner() .network .delete_ip_assignment(&mut assignment, &range) .await?; @@ -2246,7 +2454,8 @@ impl Worker { let mut assignment = self.db.get_vm_ip_assignment(assignment_id).await?; let range = self.db.get_ip_range(assignment.ip_range_id).await?; - self.provisioner + self.subscription_handler + .vm_provisioner() .network .update_ip_assignment_policy(&mut assignment, &range) .await?; diff --git a/lnvps_api_admin/Cargo.toml b/lnvps_api_admin/Cargo.toml index 636b243..225be85 100644 --- a/lnvps_api_admin/Cargo.toml +++ b/lnvps_api_admin/Cargo.toml @@ -11,6 +11,10 @@ path = "src/bin/admin_api.rs" name = "generate_demo_data" path = "src/bin/generate_demo_data.rs" +[[bin]] +name = "migrate_vm_subscriptions" +path = "src/bin/migrate_vm_subscriptions.rs" + [features] default = ["lnvps_db/admin"] demo = ["dep:rand"] diff --git a/lnvps_api_admin/src/admin/cost_plans.rs b/lnvps_api_admin/src/admin/cost_plans.rs index 2312729..e8b28b0 100644 --- a/lnvps_api_admin/src/admin/cost_plans.rs +++ b/lnvps_api_admin/src/admin/cost_plans.rs @@ -54,14 +54,7 @@ async fn admin_list_cost_plans( let limit = params.limit.unwrap_or(50).min(100); let offset = params.offset.unwrap_or(0); - let all_cost_plans = this.db.list_cost_plans().await?; - let total = all_cost_plans.len() as u64; - - let cost_plans = all_cost_plans - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect::>(); + let (cost_plans, total) = this.db.list_cost_plans_paginated(limit, offset).await?; let mut cost_plan_infos = Vec::new(); for cost_plan in cost_plans { diff --git a/lnvps_api_admin/src/admin/custom_pricing.rs b/lnvps_api_admin/src/admin/custom_pricing.rs index d77eedf..ae8ff05 100644 --- a/lnvps_api_admin/src/admin/custom_pricing.rs +++ b/lnvps_api_admin/src/admin/custom_pricing.rs @@ -124,37 +124,10 @@ async fn admin_list_custom_pricing( let limit = params.limit.unwrap_or(50).min(100); let offset = params.offset.unwrap_or(0); - // For now, get all and filter manually - ideally this would be done in the database - let all_regions = if let Some(region_id) = params.region_id { - vec![region_id] - } else { - this.db - .list_host_region() - .await? - .into_iter() - .map(|r| r.id) - .collect() - }; - - let mut all_pricing = Vec::new(); - for region in all_regions { - let region_pricing = this.db.list_custom_pricing(region).await?; - all_pricing.extend(region_pricing); - } - - // Apply enabled filter if provided - if let Some(enabled_filter) = params.enabled { - all_pricing.retain(|p| p.enabled == enabled_filter); - } - - let total = all_pricing.len() as u64; - - // Apply pagination - let paginated_pricing: Vec<_> = all_pricing - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect(); + let (paginated_pricing, total) = this + .db + .list_custom_pricing_paginated(params.region_id, params.enabled, limit, offset) + .await?; let mut pricing_infos = Vec::new(); for pricing in paginated_pricing { diff --git a/lnvps_api_admin/src/admin/ip_space.rs b/lnvps_api_admin/src/admin/ip_space.rs index afb3303..359a634 100644 --- a/lnvps_api_admin/src/admin/ip_space.rs +++ b/lnvps_api_admin/src/admin/ip_space.rs @@ -60,35 +60,16 @@ async fn admin_list_ip_space( let limit = params.limit.unwrap_or(50).min(100); // Max 100 items per page let offset = params.offset.unwrap_or(0); - // Get all IP spaces (we'll filter in memory for now) - let all_spaces = this.db.list_available_ip_space().await?; - - // Filter based on query params - let filtered_spaces: Vec<_> = all_spaces - .into_iter() - .filter(|space| { - if let Some(is_available) = params.is_available { - if space.is_available != is_available { - return false; - } - } - if let Some(registry) = params.registry { - if (space.registry as u8) != registry { - return false; - } - } - true - }) - .collect(); - - let total = filtered_spaces.len() as u64; - - // Paginate - let paginated_spaces: Vec<_> = filtered_spaces - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect(); + let (paginated_spaces, total) = this + .db + .list_available_ip_space_paginated( + params.is_available, + None, + params.registry, + limit, + offset, + ) + .await?; // Convert to API format with enriched data let mut ip_spaces = Vec::new(); @@ -331,15 +312,10 @@ async fn admin_list_ip_space_pricing( let limit = params.limit.unwrap_or(50).min(100); let offset = params.offset.unwrap_or(0); - let all_pricing = this.db.list_ip_space_pricing_by_space(id).await?; - let total = all_pricing.len() as u64; - - // Paginate - let paginated_pricing: Vec<_> = all_pricing - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect(); + let (paginated_pricing, total) = this + .db + .list_ip_space_pricing_by_space_paginated(id, limit, offset) + .await?; // Convert to API format let pricing_infos: Vec<_> = paginated_pricing @@ -542,40 +518,16 @@ async fn admin_list_ip_space_subscriptions( let limit = params.limit.unwrap_or(50).min(100); let offset = params.offset.unwrap_or(0); - // Get all subscriptions for this IP space - // We need to get all subscriptions and filter by available_ip_space_id - let all_subscriptions = if let Some(user_id) = params.user_id { - this.db.list_ip_range_subscriptions_by_user(user_id).await? - } else { - // Get all subscriptions (use user_id 0 as sentinel for all) - // This is a limitation - we may need to add a new DB method for this - this.db.list_ip_range_subscriptions_by_user(0).await? - }; - - // Filter by space_id and optionally by is_active - let filtered_subs: Vec<_> = all_subscriptions - .into_iter() - .filter(|sub| { - if sub.available_ip_space_id != id { - return false; - } - if let Some(is_active) = params.is_active { - if sub.is_active != is_active { - return false; - } - } - true - }) - .collect(); - - let total = filtered_subs.len() as u64; - - // Paginate - let paginated_subs: Vec<_> = filtered_subs - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect(); + let (paginated_subs, total) = this + .db + .list_ip_range_subscriptions_by_space_paginated( + id, + params.user_id, + params.is_active, + limit, + offset, + ) + .await?; // Convert to API format with enriched data let mut sub_infos = Vec::new(); diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 4d54380..9cfec02 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -6,12 +6,12 @@ use std::str::FromStr; use std::sync::Arc; use lnvps_api_common::{ - ApiDiskInterface, ApiDiskType, ApiOsDistribution, ApiVmCostPlanIntervalType, VmRunningState, + ApiDiskInterface, ApiDiskType, ApiIntervalType, ApiOsDistribution, VmRunningState, }; use lnvps_db::{ AdminAction, AdminResource, AdminRole, IpRangeAllocationMode, NetworkAccessPolicy, - OsDistribution, PaymentMethod, RouterKind, SubscriptionType, VmHistory, VmHistoryActionType, - VmHostKind, VmPayment, + OsDistribution, PaymentMethod, RouterKind, SubscriptionPayment, SubscriptionType, VmHistory, + VmHistoryActionType, VmHostKind, VmPayment, }; // Admin API Enums - Using enums from common crate where available, creating new ones only where needed @@ -21,6 +21,7 @@ use lnvps_db::{ pub enum AdminVmHostKind { Proxmox, Libvirt, + Mock, } impl From for AdminVmHostKind { @@ -28,6 +29,7 @@ impl From for AdminVmHostKind { match host_kind { VmHostKind::Proxmox => AdminVmHostKind::Proxmox, VmHostKind::LibVirt => AdminVmHostKind::Libvirt, + VmHostKind::Dummy => AdminVmHostKind::Mock, } } } @@ -37,6 +39,7 @@ impl From for VmHostKind { match admin_host_kind { AdminVmHostKind::Proxmox => VmHostKind::Proxmox, AdminVmHostKind::Libvirt => VmHostKind::LibVirt, + AdminVmHostKind::Mock => VmHostKind::Dummy, } } } @@ -351,10 +354,10 @@ pub struct AdminVmInfo { // Core VM information (moved from ApiVmStatus) /// Unique VM ID (Same in proxmox) pub id: u64, - /// When the VM was created + /// When the subscription was created (i.e. when the VM was ordered) pub created: DateTime, - /// When the VM expires - pub expires: DateTime, + /// When the VM's subscription expires (None = never paid) + pub expires: Option>, /// Network MAC address pub mac_address: String, /// OS Image ID for linking @@ -411,6 +414,9 @@ pub struct AdminVmInfo { pub deleted: bool, pub ref_code: Option, pub disabled: bool, + /// Subscription linked to this VM (includes line items and payment count) + #[serde(skip_serializing_if = "Option::is_none")] + pub subscription: Option, } impl AdminVmInfo { @@ -529,10 +535,22 @@ impl AdminVmInfo { }); } + // Fetch subscription via the VM's subscription line item + let subscription = match db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await + { + Ok(sub) => AdminSubscriptionInfo::from_subscription(db, &sub).await.ok(), + Err(_) => None, + }; + + // Load subscription for expiry + auto_renewal (use shortcut function) + let sub = db.get_subscription_by_line_item_id(vm.subscription_line_item_id).await?; + Ok(Self { id: vm.id, - created: vm.created, - expires: vm.expires, + created: sub.created, + expires: sub.expires, mac_address: vm.mac_address.clone(), image_id: vm.image_id, image_name: format!("{} {} {}", image.distribution, image.flavour, image.version), @@ -544,7 +562,7 @@ impl AdminVmInfo { ssh_key_name: ssh_key.name, ip_addresses, running_state, - auto_renewal_enabled: vm.auto_renewal_enabled, + auto_renewal_enabled: sub.auto_renewal_enabled, cpu, cpu_mfg, cpu_arch, @@ -563,6 +581,7 @@ impl AdminVmInfo { deleted, ref_code, disabled: vm.disabled, + subscription, }) } } @@ -1206,7 +1225,7 @@ pub struct AdminCreateVmTemplateRequest { pub cost_plan_amount: Option, pub cost_plan_currency: Option, // Defaults to "USD" pub cost_plan_interval_amount: Option, // Defaults to 1 - pub cost_plan_interval_type: Option, // Defaults to Month + pub cost_plan_interval_type: Option, // Defaults to Month /// Maximum disk read IOPS (None = uncapped) pub disk_iops_read: Option, /// Maximum disk write IOPS (None = uncapped) @@ -1264,7 +1283,7 @@ pub struct AdminUpdateVmTemplateRequest { pub cost_plan_amount: Option, pub cost_plan_currency: Option, pub cost_plan_interval_amount: Option, - pub cost_plan_interval_type: Option, + pub cost_plan_interval_type: Option, /// Maximum disk read IOPS — use `null` to clear #[serde( default, @@ -1869,7 +1888,7 @@ pub struct AdminCostPlanInfo { pub amount: u64, pub currency: String, pub interval_amount: u64, - pub interval_type: ApiVmCostPlanIntervalType, + pub interval_type: ApiIntervalType, pub template_count: u64, // Number of VM templates using this cost plan } @@ -1880,7 +1899,7 @@ pub struct AdminCreateCostPlanRequest { pub amount: u64, pub currency: String, pub interval_amount: u64, - pub interval_type: ApiVmCostPlanIntervalType, + pub interval_type: ApiIntervalType, } #[derive(Deserialize)] @@ -1890,7 +1909,7 @@ pub struct AdminUpdateCostPlanRequest { pub amount: Option, pub currency: Option, pub interval_amount: Option, - pub interval_type: Option, + pub interval_type: Option, } impl From for AdminCostPlanInfo { @@ -1902,7 +1921,7 @@ impl From for AdminCostPlanInfo { amount: cost_plan.amount, currency: cost_plan.currency, interval_amount: cost_plan.interval_amount, - interval_type: ApiVmCostPlanIntervalType::from(cost_plan.interval_type), + interval_type: ApiIntervalType::from(cost_plan.interval_type), template_count: 0, // Will be filled by handler } } @@ -2051,9 +2070,9 @@ pub struct AdminRefundAmountInfo { pub currency: String, /// Exchange rate used for conversion (if applicable) pub rate: f32, - /// VM expiry date - pub expires: DateTime, - /// Seconds remaining until VM expires + /// Subscription expiry date (None = never paid) + pub expires: Option>, + /// Seconds remaining until subscription expires (0 if not set) pub seconds_remaining: i64, } @@ -2096,6 +2115,29 @@ impl AdminVmPaymentInfo { rate: payment.rate, } } + + pub fn from_subscription_payment( + payment: &SubscriptionPayment, + vm_id: u64, + company_base_currency: String, + ) -> Self { + Self { + id: hex::encode(&payment.id), + vm_id, + created: payment.created, + expires: payment.expires, + amount: payment.amount, + tax: payment.tax, + processing_fee: payment.processing_fee, + currency: payment.currency.clone(), + company_base_currency, + payment_method: AdminPaymentMethod::from(payment.payment_method), + external_id: payment.external_id.clone(), + is_paid: payment.is_paid, + paid_at: payment.paid_at, + rate: payment.rate, + } + } } // VM IP Assignment Management Models @@ -2225,7 +2267,10 @@ pub struct AdminSubscriptionInfo { pub created: DateTime, pub expires: Option>, pub is_active: bool, + pub is_setup: bool, pub currency: String, + pub interval_amount: u64, + pub interval_type: ApiIntervalType, pub setup_fee: u64, pub auto_renewal_enabled: bool, pub external_id: Option, @@ -2242,11 +2287,25 @@ pub struct AdminCreateSubscriptionRequest { pub expires: Option>, pub is_active: bool, pub currency: String, + /// Number of intervals per billing cycle (default 1) + #[serde(default = "default_interval_amount")] + pub interval_amount: u64, + /// Interval unit: "day", "month", or "year" (default "month") + #[serde(default = "default_interval_type")] + pub interval_type: ApiIntervalType, pub setup_fee: u64, pub auto_renewal_enabled: bool, pub external_id: Option, } +fn default_interval_amount() -> u64 { + 1 +} + +fn default_interval_type() -> ApiIntervalType { + ApiIntervalType::Month +} + #[derive(Deserialize)] pub struct AdminUpdateSubscriptionRequest { pub name: Option, @@ -2277,7 +2336,10 @@ impl From for AdminSubscriptionInfo { created: subscription.created, expires: subscription.expires, is_active: subscription.is_active, + is_setup: subscription.is_setup, currency: subscription.currency, + interval_amount: subscription.interval_amount, + interval_type: ApiIntervalType::from(subscription.interval_type), setup_fee: subscription.setup_fee, auto_renewal_enabled: subscription.auto_renewal_enabled, external_id: subscription.external_id, @@ -2306,7 +2368,10 @@ impl AdminCreateSubscriptionRequest { created: chrono::Utc::now(), expires: self.expires, is_active: self.is_active, + is_setup: false, currency: self.currency.trim().to_uppercase(), + interval_amount: self.interval_amount, + interval_type: lnvps_db::IntervalType::from(self.interval_type), setup_fee: self.setup_fee, auto_renewal_enabled: self.auto_renewal_enabled, external_id: self.external_id.clone(), @@ -2390,6 +2455,7 @@ pub struct AdminSubscriptionPaymentInfo { pub expires: DateTime, pub amount: u64, pub currency: String, + pub company_base_currency: String, pub payment_method: AdminPaymentMethod, pub payment_type: ApiSubscriptionPaymentType, pub external_id: Option, @@ -2397,6 +2463,10 @@ pub struct AdminSubscriptionPaymentInfo { #[serde(skip_serializing_if = "Option::is_none")] pub paid_at: Option>, pub rate: f32, + #[serde(skip_serializing_if = "Option::is_none")] + pub time_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, pub tax: u64, pub processing_fee: u64, } @@ -2405,6 +2475,7 @@ pub struct AdminSubscriptionPaymentInfo { pub enum ApiSubscriptionPaymentType { Purchase, Renewal, + Upgrade, } impl From for ApiSubscriptionPaymentType { @@ -2412,6 +2483,7 @@ impl From for ApiSubscriptionPaymentType { match payment_type { lnvps_db::SubscriptionPaymentType::Purchase => ApiSubscriptionPaymentType::Purchase, lnvps_db::SubscriptionPaymentType::Renewal => ApiSubscriptionPaymentType::Renewal, + lnvps_db::SubscriptionPaymentType::Upgrade => ApiSubscriptionPaymentType::Upgrade, } } } @@ -2421,12 +2493,13 @@ impl From for lnvps_db::SubscriptionPaymentType { match payment_type { ApiSubscriptionPaymentType::Purchase => lnvps_db::SubscriptionPaymentType::Purchase, ApiSubscriptionPaymentType::Renewal => lnvps_db::SubscriptionPaymentType::Renewal, + ApiSubscriptionPaymentType::Upgrade => lnvps_db::SubscriptionPaymentType::Upgrade, } } } -impl From for AdminSubscriptionPaymentInfo { - fn from(payment: lnvps_db::SubscriptionPayment) -> Self { +impl AdminSubscriptionPaymentInfo { + pub fn new(payment: lnvps_db::SubscriptionPayment, company_base_currency: String) -> Self { Self { id: hex::encode(&payment.id), subscription_id: payment.subscription_id, @@ -2435,12 +2508,38 @@ impl From for AdminSubscriptionPaymentInfo { expires: payment.expires, amount: payment.amount, currency: payment.currency, + company_base_currency, payment_method: AdminPaymentMethod::from(payment.payment_method), payment_type: ApiSubscriptionPaymentType::from(payment.payment_type), external_id: payment.external_id, is_paid: payment.is_paid, paid_at: payment.paid_at, rate: payment.rate, + time_value: payment.time_value, + metadata: payment.metadata, + tax: payment.tax, + processing_fee: payment.processing_fee, + } + } + + pub fn from_with_company(payment: lnvps_db::SubscriptionPaymentWithCompany) -> Self { + Self { + id: hex::encode(&payment.id), + subscription_id: payment.subscription_id, + user_id: payment.user_id, + created: payment.created, + expires: payment.expires, + amount: payment.amount, + currency: payment.currency, + company_base_currency: payment.company_base_currency, + payment_method: AdminPaymentMethod::from(payment.payment_method), + payment_type: ApiSubscriptionPaymentType::from(payment.payment_type), + external_id: payment.external_id, + is_paid: payment.is_paid, + paid_at: payment.paid_at, + rate: payment.rate, + time_value: payment.time_value, + metadata: payment.metadata, tax: payment.tax, processing_fee: payment.processing_fee, } @@ -2723,17 +2822,9 @@ impl AdminIpRangeSubscriptionInfo { ) -> anyhow::Result { let mut info = Self::from(sub.clone()); - // Get line item details - if let Ok(line_item) = db - .get_subscription_line_item(sub.subscription_line_item_id) - .await - { - info.subscription_id = Some(line_item.subscription_id); - - // Get subscription details for user_id - if let Ok(subscription) = db.get_subscription(line_item.subscription_id).await { - info.user_id = Some(subscription.user_id); - } + // Get subscription details for user_id (use shortcut function) + if let Ok(subscription) = db.get_subscription_by_line_item_id(sub.subscription_line_item_id).await { + info.user_id = Some(subscription.user_id); } // Get parent IP space CIDR diff --git a/lnvps_api_admin/src/admin/payment_methods.rs b/lnvps_api_admin/src/admin/payment_methods.rs index da1e888..7c75b84 100644 --- a/lnvps_api_admin/src/admin/payment_methods.rs +++ b/lnvps_api_admin/src/admin/payment_methods.rs @@ -35,13 +35,13 @@ async fn admin_list_payment_methods( let limit = params.limit.unwrap_or(50).min(100); let offset = params.offset.unwrap_or(0); - let all_configs = this.db.list_payment_method_configs().await?; - let total = all_configs.len() as u64; + let (page, total) = this + .db + .list_payment_method_configs_paginated(limit, offset) + .await?; - let configs: Vec = all_configs + let configs: Vec = page .into_iter() - .skip(offset as usize) - .take(limit as usize) .map(AdminPaymentMethodConfigInfo::from) .collect(); diff --git a/lnvps_api_admin/src/admin/reports.rs b/lnvps_api_admin/src/admin/reports.rs index b0f647c..db2f433 100644 --- a/lnvps_api_admin/src/admin/reports.rs +++ b/lnvps_api_admin/src/admin/reports.rs @@ -150,7 +150,7 @@ async fn admin_time_series_report( for payment in payments { time_series_payments.push(TimeSeriesPayment { id: hex::encode(&payment.id), - vm_id: payment.vm_id, + vm_id: payment.vm_id.unwrap_or(0), created: payment.created.to_rfc3339(), expires: payment.expires.to_rfc3339(), amount: payment.amount, @@ -159,16 +159,16 @@ async fn admin_time_series_report( external_id: payment.external_id, is_paid: payment.is_paid, rate: payment.rate, - time_value: payment.time_value, + time_value: payment.time_value.unwrap_or(0), tax: payment.tax, company_id: payment.company_id, company_name: payment.company_name.clone(), company_base_currency: payment.company_base_currency.clone(), user_id: payment.user_id, - host_id: payment.host_id, - host_name: payment.host_name.clone(), - region_id: payment.region_id, - region_name: payment.region_name.clone(), + host_id: payment.host_id.unwrap_or(0), + host_name: payment.host_name.clone().unwrap_or_default(), + region_id: payment.region_id.unwrap_or(0), + region_name: payment.region_name.clone().unwrap_or_default(), }); } diff --git a/lnvps_api_admin/src/admin/roles.rs b/lnvps_api_admin/src/admin/roles.rs index 9f87a9c..a45857d 100644 --- a/lnvps_api_admin/src/admin/roles.rs +++ b/lnvps_api_admin/src/admin/roles.rs @@ -46,11 +46,10 @@ async fn admin_list_roles( let limit = page.limit.unwrap_or(50).min(100); let offset = page.offset.unwrap_or(0); - let roles = this.db.list_roles().await?; - let total = roles.len() as u64; + let (roles, total) = this.db.list_roles_paginated(limit, offset).await?; let mut role_infos = Vec::new(); - for role in roles.into_iter().skip(offset as usize).take(limit as usize) { + for role in roles { let mut role_info: AdminRoleInfo = role.clone().into(); // Get role permissions diff --git a/lnvps_api_admin/src/admin/subscriptions.rs b/lnvps_api_admin/src/admin/subscriptions.rs index 8da4ac2..3869cb7 100644 --- a/lnvps_api_admin/src/admin/subscriptions.rs +++ b/lnvps_api_admin/src/admin/subscriptions.rs @@ -8,7 +8,9 @@ use crate::admin::model::{ use axum::extract::{Path, Query, State}; use axum::routing::{get, post}; use axum::{Json, Router}; -use lnvps_api_common::{ApiData, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery}; +use lnvps_api_common::{ + ApiData, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery, WorkJob, +}; use lnvps_db::{AdminAction, AdminResource, LNVpsDb}; use serde::Deserialize; use std::sync::Arc; @@ -109,19 +111,10 @@ async fn admin_list_subscriptions( let limit = params.limit.unwrap_or(50).min(100); let offset = params.offset.unwrap_or(0); - let all_subscriptions = if let Some(uid) = params.user_id { - this.db.list_subscriptions_by_user(uid).await? - } else { - this.db.list_subscriptions().await? - }; - - let total = all_subscriptions.len() as u64; - - let subscriptions = all_subscriptions - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect::>(); + let (subscriptions, total) = this + .db + .list_subscriptions_paginated(params.user_id, limit, offset) + .await?; let mut subscription_infos = Vec::new(); for subscription in subscriptions { @@ -372,17 +365,19 @@ async fn admin_list_subscription_payments( let limit = params.limit.unwrap_or(50).min(100); let offset = params.offset.unwrap_or(0); - // Verify subscription exists - let _subscription = this.db.get_subscription(subscription_id).await?; + // Verify subscription exists and fetch company base currency + let subscription = this.db.get_subscription(subscription_id).await?; + let company = this.db.get_company(subscription.company_id).await?; + let base_currency = company.base_currency; - let all_payments = this.db.list_subscription_payments(subscription_id).await?; - let total = all_payments.len() as u64; + let (page, total) = this + .db + .list_subscription_payments_paginated(subscription_id, limit, offset) + .await?; - let payments: Vec = all_payments + let payments: Vec = page .into_iter() - .skip(offset as usize) - .take(limit as usize) - .map(AdminSubscriptionPaymentInfo::from) + .map(|p| AdminSubscriptionPaymentInfo::new(p, base_currency.clone())) .collect(); ApiPaginatedData::ok(payments, total, limit, offset) @@ -398,8 +393,11 @@ async fn admin_get_subscription_payment( let payment_id = hex::decode(&id).map_err(|_| anyhow::anyhow!("Invalid payment ID format"))?; - let payment = this.db.get_subscription_payment(&payment_id).await?; - ApiData::ok(AdminSubscriptionPaymentInfo::from(payment)) + let payment = this + .db + .get_subscription_payment_with_company(&payment_id) + .await?; + ApiData::ok(AdminSubscriptionPaymentInfo::from_with_company(payment)) } /// Manually mark a subscription payment as paid (admin override). @@ -430,7 +428,19 @@ async fn admin_complete_subscription_payment( payment.subscription_id ); - // Re-read the payment to get updated state - let updated = this.db.get_subscription_payment(&payment_id).await?; - ApiData::ok(AdminSubscriptionPaymentInfo::from(updated)) + // Dispatch CheckSubscriptions so the lifecycle worker picks up the new expiry + if let Err(e) = this.work_commander.send(WorkJob::CheckSubscriptions).await { + log::error!( + "Payment completed but failed to dispatch CheckSubscriptions for subscription {}: {}", + payment.subscription_id, + e + ); + } + + // Re-read the payment to get updated state (with company info) + let updated = this + .db + .get_subscription_payment_with_company(&payment_id) + .await?; + ApiData::ok(AdminSubscriptionPaymentInfo::from_with_company(updated)) } diff --git a/lnvps_api_admin/src/admin/vm_ip_assignments.rs b/lnvps_api_admin/src/admin/vm_ip_assignments.rs index 5e6c6ab..0900225 100644 --- a/lnvps_api_admin/src/admin/vm_ip_assignments.rs +++ b/lnvps_api_admin/src/admin/vm_ip_assignments.rs @@ -110,11 +110,17 @@ async fn admin_create_vm_ip_assignment( return ApiData::err("Cannot assign IP to a deleted VM"); } - if vm.expires == vm.created { + // Check subscription state (use shortcut function) + let sub = this + .db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await?; + + if !sub.is_setup { return ApiData::err("Cannot assign IP to a new VM"); } - if vm.expires < Utc::now() { + if sub.expires.map(|e| e < Utc::now()).unwrap_or(true) { return ApiData::err("Cannot assign IP to an expired VM"); } diff --git a/lnvps_api_admin/src/admin/vm_templates.rs b/lnvps_api_admin/src/admin/vm_templates.rs index 17a2720..b289cd3 100644 --- a/lnvps_api_admin/src/admin/vm_templates.rs +++ b/lnvps_api_admin/src/admin/vm_templates.rs @@ -152,7 +152,7 @@ async fn admin_create_vm_template( let cost_plan_interval_amount = req.cost_plan_interval_amount.unwrap_or(1); let cost_plan_interval_type = req .cost_plan_interval_type - .unwrap_or(lnvps_api_common::ApiVmCostPlanIntervalType::Month); + .unwrap_or(lnvps_api_common::ApiIntervalType::Month); if cost_plan_interval_amount == 0 { return Err(anyhow::anyhow!("Cost plan interval amount cannot be zero").into()); diff --git a/lnvps_api_admin/src/admin/vms.rs b/lnvps_api_admin/src/admin/vms.rs index 2376772..3b209d5 100644 --- a/lnvps_api_admin/src/admin/vms.rs +++ b/lnvps_api_admin/src/admin/vms.rs @@ -12,7 +12,7 @@ use lnvps_api_common::{ ApiData, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery, PricingEngine, UpgradeConfig, VmHistoryLogger, VmRunningState, VmStateCache, WorkJob, }; -use lnvps_db::{AdminAction, AdminResource, PaymentType}; +use lnvps_db::{AdminAction, AdminResource, SubscriptionPaymentType}; use log::{error, info}; use serde::Deserialize; @@ -466,12 +466,15 @@ async fn admin_extend_vm( return ApiData::err("Cannot extend by more than 365 days"); } - let old_expires = vm.expires; - let new_expires = vm.expires + Days::new(req.days as u64); - - // Update VM expiration date in database - vm.expires = new_expires; - this.db.update_vm(&vm).await?; + // Extend the subscription expiry (single source of truth, use shortcut function) + let mut sub = this + .db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await?; + let old_expires = sub.expires.unwrap_or(Utc::now()); + let new_expires = old_expires + Days::new(req.days as u64); + sub.expires = Some(new_expires); + this.db.update_subscription(&sub).await?; // Log the extension in VM history let vm_history_logger = VmHistoryLogger::new(this.db.clone()); @@ -500,6 +503,16 @@ async fn admin_extend_vm( auth.user_id, id, req.days, new_expires ); + // Trigger SpawnVm so the worker provisions the VM if it has never been + // spawned (mac == ff:ff:ff:ff:ff:ff) or syncs its state if it already has. + if let Err(e) = this + .work_commander + .send(WorkJob::SpawnVm { vm_id: id }) + .await + { + error!("Failed to queue SpawnVm job for VM {}: {}", id, e); + } + ApiData::ok(()) } @@ -577,27 +590,23 @@ async fn admin_list_vm_payments( auth.require_permission(AdminResource::Payments, AdminAction::View)?; // Verify VM exists - let _vm = this.db.get_vm(vm_id).await?; + let vm = this.db.get_vm(vm_id).await?; - let limit = page.limit.unwrap_or(50).min(100); // Max 100 items per page + let limit = page.limit.unwrap_or(50).min(100); let offset = page.offset.unwrap_or(0); - // Get VM payments with pagination let payments = this .db - .list_vm_payment_paginated(vm_id, limit, offset) + .list_vm_subscription_payments_paginated(vm.id, limit, offset) .await?; - // For total count, we'll get all payments and count them - // This is not ideal for large datasets, but works for now - let all_payments = this.db.list_vm_payment(vm_id).await?; - let total = all_payments.len() as u64; + let total = this.db.count_vm_subscription_payments(vm.id).await?; let base_currency = this.db.get_vm_base_currency(vm_id).await?; let admin_payments: Vec = payments .iter() - .map(|p| AdminVmPaymentInfo::from_vm_payment(p, base_currency.clone())) + .map(|p| AdminVmPaymentInfo::from_subscription_payment(p, vm_id, base_currency.clone())) .collect(); ApiPaginatedData::ok(admin_payments, total, limit, offset) @@ -613,21 +622,26 @@ async fn admin_get_vm_payment( auth.require_permission(AdminResource::Payments, AdminAction::View)?; // Verify VM exists - let _vm = this.db.get_vm(vm_id).await?; + let vm = this.db.get_vm(vm_id).await?; // Decode payment ID from hex let payment_id_bytes = hex::decode(&payment_id).map_err(|_| "Invalid payment ID format")?; - // Get payment - let payment = this.db.get_vm_payment(&payment_id_bytes).await?; + // Get subscription payment + let payment = this.db.get_subscription_payment(&payment_id_bytes).await?; - // Verify payment belongs to this VM - if payment.vm_id != vm_id { + // Verify the payment's subscription belongs to this VM + let payment_vm = this + .db + .get_vm_by_subscription(payment.subscription_id) + .await?; + if payment_vm.id != vm.id { return ApiData::err("Payment does not belong to this VM"); } let base_currency = this.db.get_vm_base_currency(vm_id).await?; - let admin_payment_info = AdminVmPaymentInfo::from_vm_payment(&payment, base_currency); + let admin_payment_info = + AdminVmPaymentInfo::from_subscription_payment(&payment, vm_id, base_currency); ApiData::ok(admin_payment_info) } @@ -675,20 +689,26 @@ async fn admin_calculate_vm_refund( // Create pricing engine instance with real exchange rates let tax_rates = std::collections::HashMap::new(); - let pricing_engine = - PricingEngine::new_for_vm(this.db.clone(), this.exchange.clone(), tax_rates, vm_id).await?; + let pricing_engine = PricingEngine::new(this.db.clone(), this.exchange.clone(), tax_rates); // Calculate the refund amount from the specified date let refund_result = pricing_engine - .calculate_refund_amount_from_date(vm_id, payment_method, calculation_date) + .calculate_vm_refund_amount_from_date(vm_id, payment_method, calculation_date) .await?; + let vm_sub = this + .db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await?; let refund_info = AdminRefundAmountInfo { amount: refund_result.amount.value(), currency: refund_result.amount.currency().to_string(), rate: refund_result.rate.rate, - expires: vm.expires, - seconds_remaining: (vm.expires - calculation_date).num_seconds(), + expires: vm_sub.expires, + seconds_remaining: vm_sub + .expires + .map(|e| (e - calculation_date).num_seconds()) + .unwrap_or(0), }; ApiData::ok(refund_info) @@ -802,7 +822,7 @@ async fn admin_create_vm( /// Manually mark a VM payment as paid (admin override). /// -/// This calls `vm_payment_paid` which atomically sets `is_paid=true`, +/// This calls `subscription_payment_paid` which atomically sets `is_paid=true`, /// records `paid_at`, and extends the VM expiry by the payment's `time_value`. /// After that it dispatches a `CheckVm` work job (or `ProcessVmUpgrade` /// for upgrade payments) exactly like the normal payment-provider flow. @@ -814,14 +834,18 @@ async fn admin_complete_vm_payment( auth.require_permission(AdminResource::Payments, AdminAction::Update)?; // Verify VM exists - let _vm = this.db.get_vm(vm_id).await?; + let vm = this.db.get_vm(vm_id).await?; // Decode payment ID from hex let payment_id_bytes = hex::decode(&payment_id).map_err(|_| "Invalid payment ID format")?; - // Get payment and verify it belongs to this VM - let payment = this.db.get_vm_payment(&payment_id_bytes).await?; - if payment.vm_id != vm_id { + // Get subscription payment and verify it belongs to this VM + let payment = this.db.get_subscription_payment(&payment_id_bytes).await?; + let payment_vm = this + .db + .get_vm_by_subscription(payment.subscription_id) + .await?; + if payment_vm.id != vm.id { return ApiData::err("Payment does not belong to this VM"); } @@ -829,8 +853,8 @@ async fn admin_complete_vm_payment( return ApiData::err("Payment is already completed"); } - // Mark as paid (atomically sets is_paid, paid_at, extends VM expiry) - this.db.vm_payment_paid(&payment).await?; + // Mark as paid (atomically sets is_paid, paid_at, extends VM expiry via time_value) + this.db.subscription_payment_paid(&payment).await?; info!( "Admin {} manually completed VM payment {} for VM {}", @@ -838,12 +862,12 @@ async fn admin_complete_vm_payment( ); // Dispatch the appropriate work job - let job = if payment.payment_type == PaymentType::Upgrade { - // Parse upgrade config from the payment's upgrade_params field + let job = if payment.payment_type == SubscriptionPaymentType::Upgrade { + // Parse upgrade config from the payment's metadata field let config = payment - .upgrade_params - .as_deref() - .and_then(|json| serde_json::from_str::(json).ok()) + .metadata + .as_ref() + .and_then(|json| serde_json::from_value::(json.clone()).ok()) .unwrap_or_else(|| UpgradeConfig::new(None, None, None)); WorkJob::ProcessVmUpgrade { vm_id, config } } else { @@ -857,7 +881,11 @@ async fn admin_complete_vm_payment( } // Re-read the payment to get updated paid_at / is_paid - let updated = this.db.get_vm_payment(&payment_id_bytes).await?; + let updated = this.db.get_subscription_payment(&payment_id_bytes).await?; let base_currency = this.db.get_vm_base_currency(vm_id).await?; - ApiData::ok(AdminVmPaymentInfo::from_vm_payment(&updated, base_currency)) + ApiData::ok(AdminVmPaymentInfo::from_subscription_payment( + &updated, + vm_id, + base_currency, + )) } diff --git a/lnvps_api_admin/src/bin/admin_api.rs b/lnvps_api_admin/src/bin/admin_api.rs index abccdb9..004fb30 100644 --- a/lnvps_api_admin/src/bin/admin_api.rs +++ b/lnvps_api_admin/src/bin/admin_api.rs @@ -14,6 +14,7 @@ use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use tokio::net::TcpListener; +use tokio::net::TcpSocket; use tower_http::cors::CorsLayer; #[derive(Parser)] @@ -79,7 +80,7 @@ async fn main() -> Result<(), Error> { Some(i) => i.parse()?, None => SocketAddr::new(IpAddr::from([0, 0, 0, 0]), 8001), }; - let listener = TcpListener::bind(ip).await?; + let listener = bind_address(ip).await?; info!("Listening on {}", ip); let router = admin_router( db.clone(), @@ -93,6 +94,13 @@ async fn main() -> Result<(), Error> { Ok(()) } +async fn bind_address(address: SocketAddr) -> std::io::Result { + let socket = TcpSocket::new_v4()?; + socket.set_reuseaddr(true)?; + socket.bind(address)?; + socket.listen(1024) +} + struct NeverWorkCommander; #[async_trait] diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index d66fba4..a85cde2 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -5,10 +5,10 @@ use config::{Config, File}; use hex::FromHex; use lnvps_api_admin::settings::Settings; use lnvps_db::{ - AdminDb, Company, DiskInterface, DiskType, EncryptedString, EncryptionContext, IpRange, - IpRangeAllocationMode, LNVpsDbBase, LNVpsDbMysql, OsDistribution, PaymentMethod, PaymentType, - User, UserSshKey, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomTemplate, - VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, + AdminDb, Company, DiskInterface, DiskType, EncryptedString, EncryptionContext, IntervalType, + IpRange, IpRangeAllocationMode, LNVpsDbBase, LNVpsDbMysql, OsDistribution, PaymentMethod, + PaymentType, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomTemplate, VmHost, + VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; use log::info; use std::path::PathBuf; @@ -503,13 +503,13 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { // Amounts are in smallest currency units: cents for fiat, millisats for BTC // BTC: 1 BTC = 100,000,000 sats = 100,000,000,000 millisats // 0.0005 BTC = 50,000 sats = 50,000,000 millisats - let plans_data: Vec<(&str, u64, &str, i32, VmCostPlanIntervalType, DateTime)> = vec![ + let plans_data: Vec<(&str, u64, &str, i32, IntervalType, DateTime)> = vec![ ( "Nano BTC Plan", 50_000_000, // 0.0005 BTC in millisats (~$50) "BTC", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, years_ago(2), ), ( @@ -517,7 +517,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 100_000_000, // 0.001 BTC in millisats (~$100) "BTC", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(21), ), ( @@ -525,7 +525,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 200_000_000, // 0.002 BTC in millisats (~$200) "BTC", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(15), ), ( @@ -533,7 +533,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 500_000_000, // 0.005 BTC in millisats (~$500) "BTC", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(12), ), ( @@ -541,7 +541,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 800_000_000, // 0.008 BTC in millisats (~$800) "BTC", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(8), ), ( @@ -549,7 +549,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 1_200_000_000, // 0.012 BTC in millisats (~$1200) "BTC", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(3), ), ( @@ -557,7 +557,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 500, // $5.00 in cents "USD", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(20), ), ( @@ -565,7 +565,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 1000, // $10.00 in cents "USD", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(16), ), ( @@ -573,7 +573,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 1500, // $15.00 in cents "USD", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(10), ), ( @@ -581,7 +581,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 2500, // $25.00 in cents "USD", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(4), ), ( @@ -589,7 +589,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 450, // €4.50 in cents "EUR", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(9), ), ( @@ -597,7 +597,7 @@ async fn create_cost_plans(db: &LNVpsDbMysql) -> Result> { 1200, // €12.00 in cents "EUR", 1, - VmCostPlanIntervalType::Month, + IntervalType::Month, months_ago(1), ), ]; @@ -1174,14 +1174,12 @@ async fn create_vms( image_id: os_image.id, template_id: Some(template.id), custom_template_id: None, + subscription_line_item_id: 0, ssh_key_id: ssh_key.id, - created, - expires, disk_id: disk.id, mac_address: mac_address.clone(), deleted: false, ref_code: ref_code.clone(), - auto_renewal_enabled: false, disabled: false, }; @@ -1220,14 +1218,12 @@ async fn create_vms( image_id: os_image.id, template_id: None, custom_template_id: Some(custom_template.id), + subscription_line_item_id: 0, ssh_key_id: ssh_key.id, - created, - expires, disk_id: disk.id, mac_address: mac_address.clone(), deleted: false, ref_code: None, - auto_renewal_enabled: false, disabled: false, }; @@ -1297,12 +1293,18 @@ async fn create_payments(db: &LNVpsDbMysql, vms: &[Vm]) -> Result<()> { _ => 1.0, // USD base rate }; + let sub_created = db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await + .map(|s| s.created) + .unwrap_or_else(|_| Utc::now()); + let payment_id_bytes = hex::decode(&payment_id)?; let payment = VmPayment { id: payment_id_bytes, vm_id: vm.id, - created: vm.created, - expires: vm.created + Duration::hours(1), + created: sub_created, + expires: sub_created + Duration::hours(1), amount, external_data: EncryptedString::new(external_data), time_value: 7776000, @@ -1315,7 +1317,7 @@ async fn create_payments(db: &LNVpsDbMysql, vms: &[Vm]) -> Result<()> { tax: 0, processing_fee: 0, upgrade_params: None, - paid_at: Some(vm.created), // Demo data: assume paid immediately + paid_at: Some(sub_created), // Demo data: assume paid immediately }; db.insert_vm_payment(&payment).await?; diff --git a/lnvps_api_admin/src/bin/migrate_vm_subscriptions.rs b/lnvps_api_admin/src/bin/migrate_vm_subscriptions.rs new file mode 100644 index 0000000..837ce76 --- /dev/null +++ b/lnvps_api_admin/src/bin/migrate_vm_subscriptions.rs @@ -0,0 +1,368 @@ +/// Data migration tool: migrate VMs to the subscription payment system. +/// +/// Phase 1 — Subscription backfill: +/// For every VM (including deleted) that does not yet have a subscription_line_item_id: +/// - Standard VMs (template_id set): create a subscription from the cost plan interval/amount. +/// - Custom VMs (custom_template_id set): create a subscription with 1-Month interval. +/// - VMs with neither: skip with a warning. +/// +/// Phase 2 — Payment backfill: +/// For every vm_payment that has not yet been copied to subscription_payment: +/// - Look up the VM's subscription_line_item_id (set in Phase 1). +/// - Insert a matching subscription_payment row preserving all fields. +/// - PaymentType::Renewal → SubscriptionPaymentType::Renewal +/// - PaymentType::Upgrade → SubscriptionPaymentType::Upgrade +/// - upgrade_params JSON string → metadata serde_json::Value +/// +/// Both phases are idempotent. Use --dry-run to preview without writing. +use anyhow::{Context, Result, bail}; +use chrono::Utc; +use clap::Parser; +use config::{Config, File}; +use lnvps_api_admin::settings::Settings; +use lnvps_db::{ + EncryptionContext, IntervalType, LNVpsDb, LNVpsDbBase, LNVpsDbMysql, Subscription, + SubscriptionLineItem, SubscriptionPaymentType, SubscriptionType, VmForMigration, VmPaymentRaw, +}; +use log::{info, warn}; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Parser)] +#[clap( + about = "Migrate VMs and vm_payment records to the subscription payment system", + version, + author +)] +struct Args { + /// Path to the config file + #[clap(short, long)] + config: Option, + + /// Preview changes without writing to the database + #[clap(long)] + dry_run: bool, +} + +/// Compute interval-to-seconds matching PricingEngine::cost_plan_interval_to_seconds. +fn interval_to_seconds(interval_type: IntervalType, interval_amount: u64) -> i64 { + let base = match interval_type { + IntervalType::Day => 86_400i64, + IntervalType::Month => 2_592_000i64, // 30 days + IntervalType::Year => 31_536_000i64, // 365 days + }; + base * interval_amount as i64 +} + +#[tokio::main] +async fn main() -> Result<()> { + env_logger::init(); + + let args = Args::parse(); + let settings: Settings = Config::builder() + .add_source(File::from( + args.config.unwrap_or(PathBuf::from("config.yaml")), + )) + .build()? + .try_deserialize()?; + + if let Some(ref encryption_config) = settings.encryption { + EncryptionContext::init_from_file( + &encryption_config.key_file, + encryption_config.auto_generate, + )?; + info!("Database encryption initialized"); + } + + let db_impl = LNVpsDbMysql::new(&settings.db).await?; + db_impl.migrate().await?; + let db_impl = Arc::new(db_impl); + let db: Arc = db_impl.clone(); + + if args.dry_run { + info!("*** DRY RUN MODE — no changes will be written ***"); + } + + // Phase 1: create subscriptions for all VMs (including deleted) + let vm_ids = db_impl + .list_vm_ids_without_subscription() + .await + .context("Failed to list VMs needing subscription")?; + info!("Phase 1: {} VMs need a subscription", vm_ids.len()); + + let mut sub_migrated = 0usize; + let mut sub_errored = 0usize; + for vm_id in &vm_ids { + match migrate_vm_subscription(db_impl.clone(), db.clone(), *vm_id, args.dry_run).await { + Ok(()) => sub_migrated += 1, + Err(e) => { + warn!("Phase 1: Failed to migrate VM {}: {:#}", vm_id, e); + sub_errored += 1; + } + } + } + info!( + "Phase 1 complete: {} subscriptions created, {} errors", + sub_migrated, sub_errored + ); + + // Phase 2: backfill vm_payment → subscription_payment + let payment_vm_ids = db_impl + .list_vm_ids_with_uncopied_payments() + .await + .context("Failed to list VMs with uncopied payments")?; + info!( + "Phase 2: {} VMs have vm_payment records to backfill", + payment_vm_ids.len() + ); + + let mut pay_migrated = 0usize; + let mut pay_errored = 0usize; + for vm_id in &payment_vm_ids { + match migrate_vm_payments(db_impl.clone(), db.clone(), *vm_id, args.dry_run).await { + Ok(n) => pay_migrated += n, + Err(e) => { + warn!( + "Phase 2: Failed to migrate payments for VM {}: {:#}", + vm_id, e + ); + pay_errored += 1; + } + } + } + info!( + "Phase 2 complete: {} payments backfilled, {} VM errors", + pay_migrated, pay_errored + ); + + if sub_errored > 0 || pay_errored > 0 { + bail!( + "{} subscription errors, {} payment VM errors (see warnings above)", + sub_errored, + pay_errored + ); + } + + Ok(()) +} + +// ─── Phase 1: subscription creation ───────────────────────────────────────── + +async fn migrate_vm_subscription( + db_impl: Arc, + db: Arc, + vm_id: u64, + dry_run: bool, +) -> Result<()> { + let vm: VmForMigration = db_impl + .get_vm_for_migration(vm_id) + .await + .context("Failed to get VM")?; + + let company_id = db + .get_vm_company_id(vm_id) + .await + .context("Failed to get company id for VM")?; + let company = db + .get_company(company_id) + .await + .context("Failed to get company")?; + let currency = company.base_currency.clone(); + + let (interval_amount, interval_type, line_item_amount, description) = + if let Some(template_id) = vm.template_id { + let template = db + .get_vm_template(template_id) + .await + .context("Failed to get VM template")?; + let cost_plan = db + .get_cost_plan(template.cost_plan_id) + .await + .context("Failed to get cost plan")?; + let desc = format!("{} (VM {})", template.name, vm_id); + ( + cost_plan.interval_amount, + cost_plan.interval_type, + cost_plan.amount, + desc, + ) + } else if vm.custom_template_id.is_some() { + let desc = format!("Custom VM {}", vm_id); + (1u64, IntervalType::Month, 0u64, desc) + } else { + bail!( + "VM {} has neither template_id nor custom_template_id", + vm_id + ); + }; + + let time_value = interval_to_seconds(interval_type, interval_amount); + info!( + "{} VM {} → subscription ({} {}, time_value={}s, amount={})", + if dry_run { "[DRY RUN]" } else { "Phase 1:" }, + vm_id, + interval_amount, + match interval_type { + IntervalType::Day => "day(s)", + IntervalType::Month => "month(s)", + IntervalType::Year => "year(s)", + }, + time_value, + line_item_amount, + ); + + if dry_run { + return Ok(()); + } + + // Deleted VMs should have inactive subscriptions — they are no longer running. + let is_active = !vm.deleted; + + let subscription = Subscription { + id: 0, + user_id: vm.user_id, + company_id, + name: format!("VM {} Subscription", vm_id), + description: Some(description.clone()), + created: Utc::now(), + expires: None, // vm.expires column removed; set manually after migration if needed + is_active, + is_setup: true, + currency, + interval_amount, + interval_type, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }; + let line_item = SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: SubscriptionType::Vps, + name: description, + description: None, + amount: line_item_amount, + setup_amount: 0, + configuration: None, + }; + + let (_sub_id, line_item_ids) = db + .insert_subscription_with_line_items(&subscription, vec![line_item]) + .await + .context("Failed to insert subscription")?; + let subscription_line_item_id = line_item_ids[0]; + + db_impl + .set_vm_subscription_line_item(vm_id, subscription_line_item_id) + .await + .context("Failed to link VM to subscription")?; + + info!( + "Phase 1: VM {} → subscription line item {}", + vm_id, subscription_line_item_id + ); + Ok(()) +} + +// ─── Phase 2: payment backfill ─────────────────────────────────────────────── + +async fn migrate_vm_payments( + db_impl: Arc, + db: Arc, + vm_id: u64, + dry_run: bool, +) -> Result { + // Get the subscription_line_item_id (must exist after Phase 1) + let vm: VmForMigration = db_impl + .get_vm_for_migration(vm_id) + .await + .context("Failed to get VM")?; + + let subscription_line_item_id = vm + .subscription_line_item_id + .filter(|&id| id != 0) + .with_context(|| format!("VM {} has no subscription_line_item_id", vm_id))?; + + let subscription_id = db.get_subscription_by_line_item_id(subscription_line_item_id).await?.id; + + // Load all vm_payment rows for this VM (raw — external_data not decrypted) + let vm_payments: Vec = db_impl + .list_vm_payments_for_migration(vm_id) + .await + .context("Failed to list vm_payments")?; + + // Idempotency check: find already-copied ids via raw query to avoid decryption. + let existing_ids: std::collections::HashSet> = db_impl + .list_subscription_payment_ids_for_subscription(subscription_id) + .await + .context("Failed to list existing subscription payment ids")? + .into_iter() + .collect(); + + let mut copied = 0usize; + + for vp in &vm_payments { + // Idempotency: skip if a subscription_payment with the same id already exists + if existing_ids.contains(&vp.id) { + continue; + } + + let payment_type = match vp.payment_type { + lnvps_db::PaymentType::Renewal => SubscriptionPaymentType::Renewal, + lnvps_db::PaymentType::Upgrade => SubscriptionPaymentType::Upgrade, + }; + + // Parse upgrade_params string → serde_json::Value for metadata + let metadata = vp + .upgrade_params + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + + // time_value: VmPaymentRaw has u64 (0 = none), SubscriptionPayment has Option + let time_value = if vp.time_value > 0 { + Some(vp.time_value) + } else { + None + }; + + let payment_type_u16 = payment_type as u16; + let metadata_str: Option = + metadata.as_ref().map(|v: &serde_json::Value| v.to_string()); + + if dry_run { + info!( + "[DRY RUN] VM {} payment {} → subscription_payment (paid={}, amount={} {})", + vm_id, + hex::encode(&vp.id), + vp.is_paid, + vp.amount, + vp.currency + ); + } else { + db_impl + .insert_subscription_payment_raw( + vp, + subscription_id, + vm.user_id, + payment_type_u16, + time_value, + metadata_str.as_deref(), + ) + .await + .with_context(|| { + format!( + "Failed to insert subscription_payment for vm_payment {}", + hex::encode(&vp.id) + ) + })?; + info!( + "Phase 2: VM {} payment {} → subscription_payment", + vm_id, + hex::encode(&vp.id) + ); + } + copied += 1; + } + + Ok(copied) +} diff --git a/lnvps_api_common/src/capacity.rs b/lnvps_api_common/src/capacity.rs index 9d98c4e..66fb887 100644 --- a/lnvps_api_common/src/capacity.rs +++ b/lnvps_api_common/src/capacity.rs @@ -197,7 +197,23 @@ impl HostCapacityService { disk_type: Option, disk_interface: Option, ) -> Result { - let vms = self.db.list_vms_on_host(host.id).await?; + let all_vms = self.db.list_vms_on_host(host.id).await?; + // Only count VMs that have been paid for (subscription is_setup = true) + let mut vms = Vec::new(); + for vm in all_vms { + if vm.deleted { + continue; + } + let is_paid = self + .db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await + .map(|s| s.is_setup) + .unwrap_or(false); + if is_paid { + vms.push(vm); + } + } // load ip ranges let ip_ranges = self.db.list_ip_range_in_region(host.region_id).await?; @@ -220,7 +236,7 @@ impl HostCapacityService { let templates = self.db.list_vm_templates().await?; let custom_templates: Vec> = join_all( vms.iter() - .filter(|v| v.custom_template_id.is_some() && v.expires > Utc::now()) + .filter(|v| v.custom_template_id.is_some()) .map(|v| { self.db .get_custom_vm_template(v.custom_template_id.unwrap()) @@ -243,7 +259,6 @@ impl HostCapacityService { // a mapping between vm_id and resources let vm_resources: HashMap = vms .iter() - .filter(|v| v.expires > Utc::now()) .filter_map(|v| { if let Some(x) = v.template_id { templates.iter().find(|t| t.id == x).map(|t| VmResources { diff --git a/lnvps_api_common/src/lib.rs b/lnvps_api_common/src/lib.rs index fccf091..74e09ca 100644 --- a/lnvps_api_common/src/lib.rs +++ b/lnvps_api_common/src/lib.rs @@ -35,6 +35,7 @@ pub const KB: u64 = 1024; pub const MB: u64 = KB * 1024; pub const GB: u64 = MB * 1024; pub const TB: u64 = GB * 1024; +pub const PB: u64 = TB * 1024; #[derive(Deserialize, Default)] #[serde(default)] diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 8cb4adb..09d2589 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -1,22 +1,20 @@ use crate::{ExchangeRateService, Ticker, TickerRate}; use anyhow::{Context, anyhow}; -use chrono::{TimeDelta, Utc}; +use chrono::{Days, Months, TimeDelta, Utc}; use lnvps_db::nostr::LNVPSNostrDb; use lnvps_db::{ AccessPolicy, AvailableIpSpace, Company, CpuArch, CpuMfg, DbError, DbResult, DiskInterface, - DiskType, IpRange, IpRangeAllocationMode, IpRangeSubscription, IpSpacePricing, LNVpsDbBase, - NostrDomain, NostrDomainHandle, OsDistribution, PaymentMethod, PaymentMethodConfig, Referral, - ReferralCostUsage, ReferralPayout, Router, Subscription, SubscriptionLineItem, - SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, - VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, - VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, + DiskType, IntervalType, IpRange, IpRangeAllocationMode, IpRangeSubscription, IpSpacePricing, + LNVpsDbBase, NostrDomain, NostrDomainHandle, OsDistribution, PaymentMethod, + PaymentMethodConfig, Referral, ReferralCostUsage, ReferralPayout, Router, Subscription, + SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, + Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, VmHost, + VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; use async_trait::async_trait; #[cfg(feature = "admin")] -use lnvps_db::{ - AdminRole, AdminRoleAssignment, AdminUserInfo, AdminVmHost, RegionStats, VmPaymentWithCompany, -}; +use lnvps_db::{AdminRole, AdminRoleAssignment, AdminUserInfo, AdminVmHost, RegionStats}; use std::collections::HashMap; use std::ops::Add; use std::sync::Arc; @@ -46,6 +44,7 @@ pub struct MockDb { pub subscriptions: Arc>>, pub subscription_line_items: Arc>>, pub subscription_payments: Arc>>, + pub ip_range_subscriptions: Arc>>, pub payment_method_configs: Arc>>, pub referrals: Arc>>, pub referral_payouts: Arc>>, @@ -66,7 +65,7 @@ impl MockDb { amount: 132, // 132 cents = €1.32 (in smallest currency units) currency: "EUR".to_string(), // This can be overridden based on company config interval_amount: 1, - interval_type: VmCostPlanIntervalType::Month, + interval_type: IntervalType::Month, } } @@ -105,14 +104,12 @@ impl MockDb { image_id: 1, template_id: Some(template.id), custom_template_id: None, + subscription_line_item_id: 1, ssh_key_id: 1, - created: Utc::now(), - expires: Default::default(), disk_id: 1, mac_address: "ff:ff:ff:ff:ff:ff".to_string(), deleted: false, ref_code: None, - auto_renewal_enabled: false, disabled: false, } } @@ -160,7 +157,7 @@ impl Default for MockDb { 1, VmHost { id: 1, - kind: VmHostKind::Proxmox, + kind: VmHostKind::Dummy, region_id: 1, name: "mock-host".to_string(), ip: "https://localhost".to_string(), @@ -254,9 +251,49 @@ impl Default for MockDb { companies })), vm_history: Arc::new(Default::default()), - subscriptions: Arc::new(Default::default()), - subscription_line_items: Arc::new(Default::default()), + subscriptions: Arc::new(Mutex::new({ + let mut m = HashMap::new(); + m.insert( + 1u64, + Subscription { + id: 1, + user_id: 1, + company_id: 1, + name: "mock subscription".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: false, + is_setup: false, + currency: "BTC".to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: false, + external_id: None, + }, + ); + m + })), + subscription_line_items: Arc::new(Mutex::new({ + let mut m = HashMap::new(); + m.insert( + 1u64, + SubscriptionLineItem { + id: 1, + subscription_id: 1, + subscription_type: lnvps_db::SubscriptionType::Vps, + name: "mock vm renewal".to_string(), + description: None, + amount: 1000, + setup_amount: 0, + configuration: None, + }, + ); + m + })), subscription_payments: Arc::new(Default::default()), + ip_range_subscriptions: Arc::new(Default::default()), payment_method_configs: Arc::new(Default::default()), referrals: Arc::new(Default::default()), referral_payouts: Arc::new(Default::default()), @@ -536,6 +573,23 @@ impl LNVpsDbBase for MockDb { Ok(cost_plans.values().cloned().collect()) } + async fn list_cost_plans_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let cost_plans = self.cost_plans.lock().await; + let mut all: Vec<_> = cost_plans.values().cloned().collect(); + all.sort_by(|a, b| b.id.cmp(&a.id)); + let total = all.len() as u64; + let page = all + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + } + async fn insert_cost_plan(&self, cost_plan: &VmCostPlan) -> DbResult { let mut cost_plans = self.cost_plans.lock().await; let max = *cost_plans.keys().max().unwrap_or(&0); @@ -610,12 +664,29 @@ impl LNVpsDbBase for MockDb { } async fn list_expired_vms(&self) -> DbResult> { - let vms = self.vms.lock().await; - Ok(vms - .values() - .filter(|v| !v.deleted && v.expires >= Utc::now()) - .cloned() - .collect()) + // In the mock, cross-reference subscription expires. + // Collect VM ids and subscription line item ids first. + let vm_list: Vec = { + let vms = self.vms.lock().await; + vms.values().filter(|v| !v.deleted).cloned().collect() + }; + let mut expired = Vec::new(); + for vm in vm_list { + let line_items = self.subscription_line_items.lock().await; + let sub_id = line_items + .get(&vm.subscription_line_item_id) + .map(|li| li.subscription_id); + drop(line_items); + if let Some(sid) = sub_id { + let subs = self.subscriptions.lock().await; + if let Some(sub) = subs.get(&sid) { + if sub.expires.map(|e| e < Utc::now()).unwrap_or(true) { + expired.push(vm); + } + } + } + } + Ok(expired) } async fn list_user_vms(&self, id: u64) -> DbResult> { @@ -671,16 +742,103 @@ impl LNVpsDbBase for MockDb { v.image_id = vm.image_id; v.template_id = vm.template_id; v.custom_template_id = vm.custom_template_id; + v.subscription_line_item_id = vm.subscription_line_item_id; v.ssh_key_id = vm.ssh_key_id; - v.expires = vm.expires; v.disk_id = vm.disk_id; v.mac_address = vm.mac_address.clone(); - v.auto_renewal_enabled = vm.auto_renewal_enabled; v.disabled = vm.disabled; } Ok(()) } + async fn get_vm_by_line_item(&self, line_item_id: u64) -> DbResult { + let vms = self.vms.lock().await; + vms.values() + .find(|v| v.subscription_line_item_id == line_item_id && !v.deleted) + .cloned() + .ok_or_else(|| anyhow!("VM not found for line item {}", line_item_id).into()) + } + + async fn get_vm_by_subscription(&self, subscription_id: u64) -> DbResult { + use lnvps_db::SubscriptionType; + let items = self.subscription_line_items.lock().await; + let line_item_id = items + .values() + .find(|li| { + li.subscription_id == subscription_id + && matches!(li.subscription_type, SubscriptionType::Vps) + }) + .map(|li| li.id) + .ok_or_else(|| { + DbError::Other(anyhow!( + "No VM line item for subscription {}", + subscription_id + )) + })?; + drop(items); + self.get_vm_by_line_item(line_item_id).await + } + + async fn list_vm_subscription_payments( + &self, + vm_id: u64, + ) -> DbResult> { + let vms = self.vms.lock().await; + let vm = vms + .get(&vm_id) + .ok_or_else(|| DbError::Other(anyhow!("VM not found")))?; + let line_item_id = vm.subscription_line_item_id; + drop(vms); + + // resolve subscription_id via line_item + let items = self.subscription_line_items.lock().await; + let subscription_id = items + .get(&line_item_id) + .ok_or_else(|| DbError::Other(anyhow!("Line item {} not found", line_item_id)))? + .subscription_id; + drop(items); + + let payments = self.subscription_payments.lock().await; + let mut result: Vec<_> = payments + .iter() + .filter(|p| p.subscription_id == subscription_id) + .cloned() + .collect(); + result.sort_by(|a, b| b.created.cmp(&a.created)); + Ok(result) + } + + async fn list_pending_vm_subscription_payments( + &self, + vm_id: u64, + ) -> DbResult> { + let all = self.list_vm_subscription_payments(vm_id).await?; + let now = Utc::now(); + Ok(all + .into_iter() + .filter(|p| !p.is_paid && p.expires > now) + .collect()) + } + + async fn list_vm_subscription_payments_paginated( + &self, + vm_id: u64, + limit: u64, + offset: u64, + ) -> DbResult> { + let all = self.list_vm_subscription_payments(vm_id).await?; + Ok(all + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect()) + } + + async fn count_vm_subscription_payments(&self, vm_id: u64) -> DbResult { + let all = self.list_vm_subscription_payments(vm_id).await?; + Ok(all.len() as u64) + } + async fn insert_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> DbResult { let mut ip_assignments = self.ip_assignments.lock().await; let max = *ip_assignments.keys().max().unwrap_or(&0); @@ -828,15 +986,12 @@ impl LNVpsDbBase for MockDb { } async fn vm_payment_paid(&self, payment: &VmPayment) -> DbResult<()> { - let mut v = self.vms.lock().await; let mut p = self.payments.lock().await; if let Some(p) = p.iter_mut().find(|p| p.id == *payment.id) { p.is_paid = true; p.paid_at = Some(Utc::now()); } - if let Some(v) = v.get_mut(&payment.vm_id) { - v.expires = v.expires.add(TimeDelta::seconds(payment.time_value as i64)); - } + // vm.expires removed — expiry is managed exclusively via subscription.expires Ok(()) } @@ -853,6 +1008,30 @@ impl LNVpsDbBase for MockDb { Ok(p.values().cloned().collect()) } + async fn list_custom_pricing_paginated( + &self, + region_id: Option, + enabled: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let p = self.custom_pricing.lock().await; + let mut all: Vec<_> = p + .values() + .filter(|v| region_id.map_or(true, |r| v.region_id == r)) + .filter(|v| enabled.map_or(true, |e| v.enabled == e)) + .cloned() + .collect(); + all.sort_by(|a, b| b.id.cmp(&a.id)); + let total = all.len() as u64; + let page = all + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + } + async fn get_custom_pricing(&self, id: u64) -> DbResult { let p = self.custom_pricing.lock().await; Ok(p.get(&id).cloned().context("no custom pricing")?) @@ -1089,6 +1268,28 @@ impl LNVpsDbBase for MockDb { .collect()) } + async fn list_subscriptions_paginated( + &self, + user_id: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let subscriptions = self.subscriptions.lock().await; + let mut all: Vec<_> = subscriptions + .values() + .filter(|s| user_id.map_or(true, |u| s.user_id == u)) + .cloned() + .collect(); + all.sort_by(|a, b| b.id.cmp(&a.id)); + let total = all.len() as u64; + let page = all + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + } + async fn list_subscriptions_active(&self, user_id: u64) -> DbResult> { let subscriptions = self.subscriptions.lock().await; Ok(subscriptions @@ -1098,6 +1299,65 @@ impl LNVpsDbBase for MockDb { .collect()) } + async fn list_expiring_subscriptions( + &self, + within_seconds: u64, + ) -> DbResult> { + let subscriptions = self.subscriptions.lock().await; + let deadline = Utc::now() + chrono::Duration::seconds(within_seconds as i64); + Ok(subscriptions + .values() + .filter(|s| { + s.is_active + && s.expires + .map(|e| e > Utc::now() && e < deadline) + .unwrap_or(false) + }) + .cloned() + .collect()) + } + + async fn list_expired_subscriptions(&self) -> DbResult> { + let subscriptions = self.subscriptions.lock().await; + Ok(subscriptions + .values() + .filter(|s| s.is_active && s.expires.map(|e| e < Utc::now()).unwrap_or(false)) + .cloned() + .collect()) + } + + async fn list_lifecycle_subscriptions(&self) -> DbResult> { + let subscriptions = self.subscriptions.lock().await; + Ok(subscriptions + .values() + .filter(|s| s.is_active && s.expires.is_some()) + .cloned() + .collect()) + } + + async fn deactivate_subscription(&self, id: u64) -> DbResult<()> { + let mut subscriptions = self.subscriptions.lock().await; + if let Some(sub) = subscriptions.get_mut(&id) { + sub.is_active = false; + } + drop(subscriptions); + let line_items = self.subscription_line_items.lock().await; + let line_item_ids: Vec = line_items + .values() + .filter(|li| li.subscription_id == id) + .map(|li| li.id) + .collect(); + drop(line_items); + let mut ip_subs = self.ip_range_subscriptions.lock().await; + for ips in ip_subs.values_mut() { + if line_item_ids.contains(&ips.subscription_line_item_id) && ips.ended_at.is_none() { + ips.is_active = false; + ips.ended_at = Some(Utc::now()); + } + } + Ok(()) + } + async fn get_subscription(&self, id: u64) -> DbResult { let subscriptions = self.subscriptions.lock().await; Ok(subscriptions @@ -1116,15 +1376,30 @@ impl LNVpsDbBase for MockDb { } async fn insert_subscription(&self, subscription: &Subscription) -> DbResult { - Ok(0) + let mut subscriptions = self.subscriptions.lock().await; + let id = subscriptions.keys().max().copied().unwrap_or(0) + 1; + let mut s = subscription.clone(); + s.id = id; + subscriptions.insert(id, s); + Ok(id) } async fn insert_subscription_with_line_items( &self, - _subscription: &Subscription, - _line_items: Vec, - ) -> DbResult { - Ok(0) + subscription: &Subscription, + line_items: Vec, + ) -> DbResult<(u64, Vec)> { + let subscription_id = self.insert_subscription(subscription).await?; + let mut items = self.subscription_line_items.lock().await; + let mut line_item_ids = Vec::with_capacity(line_items.len()); + for mut item in line_items { + let item_id = items.keys().max().copied().unwrap_or(0) + 1; + item.id = item_id; + item.subscription_id = subscription_id; + items.insert(item_id, item); + line_item_ids.push(item_id); + } + Ok((subscription_id, line_item_ids)) } async fn update_subscription(&self, subscription: &Subscription) -> DbResult<()> { @@ -1176,6 +1451,20 @@ impl LNVpsDbBase for MockDb { .ok_or_else(|| anyhow!("Subscription line item not found: {}", id))?) } + async fn get_subscription_by_line_item_id(&self, line_item_id: u64) -> DbResult { + let line_items = self.subscription_line_items.lock().await; + let sub_id = match line_items.get(&line_item_id) { + Some(li) => li.subscription_id, + None => return Err(DbError::Other(anyhow::anyhow!("subscription not found for line item {}", line_item_id))), + }; + drop(line_items); + let subscriptions = self.subscriptions.lock().await; + subscriptions + .get(&sub_id) + .cloned() + .ok_or_else(|| DbError::Other(anyhow::anyhow!("subscription {} not found", sub_id))) + } + async fn insert_subscription_line_item( &self, line_item: &SubscriptionLineItem, @@ -1221,6 +1510,28 @@ impl LNVpsDbBase for MockDb { .collect()) } + async fn list_subscription_payments_paginated( + &self, + subscription_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let payments = self.subscription_payments.lock().await; + let mut all: Vec<_> = payments + .iter() + .filter(|p| p.subscription_id == subscription_id) + .cloned() + .collect(); + all.sort_by(|a, b| b.created.cmp(&a.created)); + let total = all.len() as u64; + let page = all + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + } + async fn list_subscription_payments_by_user( &self, user_id: u64, @@ -1265,7 +1576,7 @@ impl LNVpsDbBase for MockDb { .cloned() .context("Subscription payment not found")?; - // For mock, we'll just use EUR as the base currency + // For mock, use placeholder company/host/region data Ok(SubscriptionPaymentWithCompany { id: payment.id, subscription_id: payment.subscription_id, @@ -1280,10 +1591,19 @@ impl LNVpsDbBase for MockDb { external_id: payment.external_id, is_paid: payment.is_paid, rate: payment.rate, + time_value: payment.time_value, + metadata: payment.metadata, tax: payment.tax, processing_fee: payment.processing_fee, paid_at: payment.paid_at, + company_id: 0, + company_name: String::new(), company_base_currency: "EUR".to_string(), + vm_id: None, + host_id: None, + host_name: None, + region_id: None, + region_name: None, }) } @@ -1313,17 +1633,33 @@ impl LNVpsDbBase for MockDb { } drop(payments); - // Extend subscription expiration by 30 days (subscriptions are always monthly) let mut subscriptions = self.subscriptions.lock().await; if let Some(subscription) = subscriptions.get_mut(&payment.subscription_id) { - if let Some(expires) = subscription.expires { - subscription.expires = Some(expires.add(TimeDelta::days(30))); + let base = subscription + .expires + .unwrap_or_else(Utc::now) + .max(Utc::now()); + + let new_expires = if let Some(time_value) = payment.time_value { + // VM path: extend by explicit time_value seconds + base.add(TimeDelta::seconds(time_value as i64)) } else { - // If no expiration yet, set it from now - subscription.expires = Some(Utc::now().add(TimeDelta::days(30))); - } + // Regular subscription path: use interval from subscription + match subscription.interval_type { + IntervalType::Day => base.add(Days::new(subscription.interval_amount)), + IntervalType::Month => { + base.add(Months::new(subscription.interval_amount as u32)) + } + IntervalType::Year => { + base.add(Months::new((12 * subscription.interval_amount) as u32)) + } + } + }; + subscription.expires = Some(new_expires); subscription.is_active = true; + subscription.is_setup = true; } + drop(subscriptions); Ok(()) } @@ -1341,6 +1677,17 @@ impl LNVpsDbBase for MockDb { todo!() } + async fn list_available_ip_space_paginated( + &self, + _is_available: Option, + _is_reserved: Option, + _registry: Option, + _limit: u64, + _offset: u64, + ) -> DbResult<(Vec, u64)> { + todo!() + } + async fn get_available_ip_space(&self, id: u64) -> DbResult { todo!() } @@ -1368,6 +1715,15 @@ impl LNVpsDbBase for MockDb { todo!() } + async fn list_ip_space_pricing_by_space_paginated( + &self, + _available_ip_space_id: u64, + _limit: u64, + _offset: u64, + ) -> DbResult<(Vec, u64)> { + todo!() + } + async fn get_ip_space_pricing(&self, id: u64) -> DbResult { todo!() } @@ -1396,47 +1752,155 @@ impl LNVpsDbBase for MockDb { &self, subscription_line_item_id: u64, ) -> DbResult> { - todo!() + let ip_subs = self.ip_range_subscriptions.lock().await; + Ok(ip_subs + .values() + .filter(|s| s.subscription_line_item_id == subscription_line_item_id) + .cloned() + .collect()) } async fn list_ip_range_subscriptions_by_subscription( &self, subscription_id: u64, ) -> DbResult> { - todo!() + let line_items = self.subscription_line_items.lock().await; + let line_item_ids: Vec = line_items + .values() + .filter(|li| li.subscription_id == subscription_id) + .map(|li| li.id) + .collect(); + drop(line_items); + let ip_subs = self.ip_range_subscriptions.lock().await; + Ok(ip_subs + .values() + .filter(|s| line_item_ids.contains(&s.subscription_line_item_id)) + .cloned() + .collect()) } async fn list_ip_range_subscriptions_by_user( &self, user_id: u64, ) -> DbResult> { - todo!() + let subscriptions = self.subscriptions.lock().await; + let sub_ids: Vec = subscriptions + .values() + .filter(|s| s.user_id == user_id) + .map(|s| s.id) + .collect(); + drop(subscriptions); + let line_items = self.subscription_line_items.lock().await; + let line_item_ids: Vec = line_items + .values() + .filter(|li| sub_ids.contains(&li.subscription_id)) + .map(|li| li.id) + .collect(); + drop(line_items); + let ip_subs = self.ip_range_subscriptions.lock().await; + Ok(ip_subs + .values() + .filter(|s| line_item_ids.contains(&s.subscription_line_item_id)) + .cloned() + .collect()) + } + + async fn list_ip_range_subscriptions_by_space_paginated( + &self, + available_ip_space_id: u64, + user_id: Option, + is_active: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let subscriptions = self.subscriptions.lock().await; + let line_items = self.subscription_line_items.lock().await; + let ip_subs = self.ip_range_subscriptions.lock().await; + let mut all: Vec = ip_subs + .values() + .filter(|s| { + if s.available_ip_space_id != available_ip_space_id { + return false; + } + if let Some(active) = is_active { + if s.is_active != active { + return false; + } + } + if let Some(uid) = user_id { + let li_id = s.subscription_line_item_id; + let sub_id = line_items + .values() + .find(|li| li.id == li_id) + .map(|li| li.subscription_id); + if let Some(sid) = sub_id { + if !subscriptions + .get(&sid) + .map(|s| s.user_id == uid) + .unwrap_or(false) + { + return false; + } + } else { + return false; + } + } + true + }) + .cloned() + .collect(); + all.sort_by(|a, b| b.id.cmp(&a.id)); + let total = all.len() as u64; + let page = all + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) } async fn get_ip_range_subscription(&self, id: u64) -> DbResult { - todo!() + let ip_subs = self.ip_range_subscriptions.lock().await; + ip_subs + .get(&id) + .cloned() + .ok_or_else(|| anyhow!("IpRangeSubscription not found: {}", id).into()) } async fn get_ip_range_subscription_by_cidr(&self, cidr: &str) -> DbResult { - todo!() + let ip_subs = self.ip_range_subscriptions.lock().await; + ip_subs + .values() + .find(|s| s.cidr == cidr) + .cloned() + .ok_or_else(|| anyhow!("IpRangeSubscription not found for cidr: {}", cidr).into()) } async fn insert_ip_range_subscription( &self, subscription: &IpRangeSubscription, ) -> DbResult { - todo!() + let mut ip_subs = self.ip_range_subscriptions.lock().await; + let id = ip_subs.len() as u64 + 1; + let mut new = subscription.clone(); + new.id = id; + ip_subs.insert(id, new); + Ok(id) } async fn update_ip_range_subscription( &self, subscription: &IpRangeSubscription, ) -> DbResult<()> { - todo!() + let mut ip_subs = self.ip_range_subscriptions.lock().await; + ip_subs.insert(subscription.id, subscription.clone()); + Ok(()) } async fn delete_ip_range_subscription(&self, id: u64) -> DbResult<()> { - todo!() + let mut ip_subs = self.ip_range_subscriptions.lock().await; + ip_subs.remove(&id); + Ok(()) } // Payment Method Config @@ -1445,6 +1909,23 @@ impl LNVpsDbBase for MockDb { Ok(configs.values().cloned().collect()) } + async fn list_payment_method_configs_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let configs = self.payment_method_configs.lock().await; + let mut all: Vec<_> = configs.values().cloned().collect(); + all.sort_by(|a, b| a.company_id.cmp(&b.company_id).then(a.id.cmp(&b.id))); + let total = all.len() as u64; + let page = all + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + } + async fn list_payment_method_configs_for_company( &self, company_id: u64, @@ -1593,24 +2074,30 @@ impl LNVpsDbBase for MockDb { async fn list_referral_usage(&self, code: &str) -> DbResult> { let vms = self.vms.lock().await; - let payments = self.payments.lock().await; + let line_items = self.subscription_line_items.lock().await; + let sub_payments = self.subscription_payments.lock().await; let mut result = Vec::new(); for vm in vms.values().filter(|v| v.ref_code.as_deref() == Some(code)) { - let mut vm_payments: Vec<&VmPayment> = payments - .iter() - .filter(|p| p.vm_id == vm.id && p.is_paid) - .collect(); - vm_payments.sort_by_key(|p| p.created); - if let Some(first) = vm_payments.first() { - result.push(ReferralCostUsage { - vm_id: vm.id, - ref_code: code.to_string(), - created: first.created, - amount: first.amount, - currency: first.currency.clone(), - rate: first.rate, - base_currency: "EUR".to_string(), - }); + let subscription_id = line_items + .get(&vm.subscription_line_item_id) + .map(|sli| sli.subscription_id); + if let Some(sid) = subscription_id { + let mut vm_payments: Vec<&SubscriptionPayment> = sub_payments + .iter() + .filter(|p| p.subscription_id == sid && p.is_paid) + .collect(); + vm_payments.sort_by_key(|p| p.created); + if let Some(first) = vm_payments.first() { + result.push(ReferralCostUsage { + vm_id: vm.id, + ref_code: code.to_string(), + created: first.created, + amount: first.amount, + currency: first.currency.clone(), + rate: first.rate, + base_currency: "EUR".to_string(), + }); + } } } result.sort_by(|a, b| b.created.cmp(&a.created)); @@ -1619,11 +2106,22 @@ impl LNVpsDbBase for MockDb { async fn count_failed_referrals(&self, code: &str) -> DbResult { let vms = self.vms.lock().await; - let payments = self.payments.lock().await; + let line_items = self.subscription_line_items.lock().await; + let sub_payments = self.subscription_payments.lock().await; Ok(vms .values() .filter(|v| v.ref_code.as_deref() == Some(code)) - .filter(|v| !payments.iter().any(|p| p.vm_id == v.id && p.is_paid)) + .filter(|v| { + let sid = line_items + .get(&v.subscription_line_item_id) + .map(|sli| sli.subscription_id); + !sid.map(|s| { + sub_payments + .iter() + .any(|p| p.subscription_id == s && p.is_paid) + }) + .unwrap_or(false) + }) .count() as u64) } } @@ -1725,6 +2223,19 @@ impl lnvps_db::AdminDb for MockDb { Ok(vec![]) } + async fn list_roles_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let page: Vec = vec![] + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, 0)) + } + async fn update_role(&self, _role: &AdminRole) -> DbResult<()> { Ok(()) } @@ -2018,120 +2529,108 @@ impl lnvps_db::AdminDb for MockDb { async fn admin_count_company_regions(&self, _company_id: u64) -> DbResult { Ok(0) } - async fn admin_get_payments_by_date_range( - &self, - start_date: chrono::DateTime, - end_date: chrono::DateTime, - ) -> DbResult> { - let p = self.payments.lock().await; - Ok(p.iter() - .filter(|p| p.is_paid && p.created >= start_date && p.created < end_date) - .cloned() - .collect()) - } - async fn admin_get_payments_by_date_range_and_company( - &self, - start_date: chrono::DateTime, - end_date: chrono::DateTime, - company_id: u64, - ) -> DbResult> { - let p = self.payments.lock().await; - let vms = self.vms.lock().await; - let hosts = self.hosts.lock().await; - let regions = self.regions.lock().await; - - Ok(p.iter() - .filter(|payment| { - if !payment.is_paid || payment.created < start_date || payment.created >= end_date { - return false; - } - - // Follow VM -> Host -> Region -> Company chain - if let Some(vm) = vms.get(&payment.vm_id) { - if let Some(host) = hosts.get(&vm.host_id) { - if let Some(region) = regions.get(&host.region_id) { - return region.company_id == company_id; - } - } - } - false - }) - .cloned() - .collect()) - } async fn admin_get_payments_with_company_info( &self, start_date: chrono::DateTime, end_date: chrono::DateTime, company_id: u64, currency: Option<&str>, - ) -> DbResult> { - let p = self.payments.lock().await; + ) -> DbResult> { + let sub_payments = self.subscription_payments.lock().await; let vms = self.vms.lock().await; + let line_items = self.subscription_line_items.lock().await; let hosts = self.hosts.lock().await; let regions = self.regions.lock().await; let companies = self.companies.lock().await; let mut result = Vec::new(); - for payment in p.iter() { + for payment in sub_payments.iter() { if !payment.is_paid || payment.created < start_date || payment.created >= end_date { continue; } - // Filter by currency if specified if let Some(filter_currency) = currency { if payment.currency != filter_currency { continue; } } - // Follow VM -> Host -> Region -> Company chain - if let Some(vm) = vms.get(&payment.vm_id) { - if let Some(host) = hosts.get(&vm.host_id) { - if let Some(region) = regions.get(&host.region_id) { - let region_company_id = region.company_id; - // Filter by company (always required) - if region_company_id != company_id { - continue; - } + // Find VM via subscription → line_item (VmRenewal/VmUpgrade) → vm + let vm = vms.values().find(|v| { + line_items + .get(&v.subscription_line_item_id) + .map(|sli| sli.subscription_id == payment.subscription_id) + .unwrap_or(false) + }); - if let Some(company) = companies.get(®ion_company_id) { - result.push(VmPaymentWithCompany { - id: payment.id.clone(), - vm_id: payment.vm_id, - created: payment.created, - expires: payment.expires, - amount: payment.amount, - currency: payment.currency.clone(), - payment_method: payment.payment_method, - payment_type: payment.payment_type, - external_data: payment.external_data.clone(), - external_id: payment.external_id.clone(), - is_paid: payment.is_paid, - rate: payment.rate, - time_value: payment.time_value, - tax: payment.tax, - processing_fee: payment.processing_fee, - upgrade_params: payment.upgrade_params.clone(), - company_id: region_company_id, - company_name: company.name.clone(), - company_base_currency: company.base_currency.clone(), - user_id: vm.user_id, - host_id: host.id, - host_name: host.name.clone(), - region_id: region.id, - region_name: region.name.clone(), - }); + let (vm_id, host_id, host_name, region_id, region_name, region_company_id) = + if let Some(vm) = vm { + if let Some(host) = hosts.get(&vm.host_id) { + if let Some(region) = regions.get(&host.region_id) { + ( + Some(vm.id), + Some(host.id), + Some(host.name.clone()), + Some(region.id), + Some(region.name.clone()), + Some(region.company_id), + ) + } else { + ( + Some(vm.id), + Some(host.id), + Some(host.name.clone()), + None, + None, + None, + ) } + } else { + (Some(vm.id), None, None, None, None, None) } - } + } else { + (None, None, None, None, None, None) + }; + + // Resolve company + let cid = region_company_id.unwrap_or(0); + if cid != company_id { + continue; + } + if let Some(company) = companies.get(&cid) { + result.push(SubscriptionPaymentWithCompany { + id: payment.id.clone(), + subscription_id: payment.subscription_id, + user_id: payment.user_id, + created: payment.created, + expires: payment.expires, + amount: payment.amount, + currency: payment.currency.clone(), + payment_method: payment.payment_method, + payment_type: payment.payment_type, + external_data: payment.external_data.clone(), + external_id: payment.external_id.clone(), + is_paid: payment.is_paid, + rate: payment.rate, + time_value: payment.time_value, + metadata: payment.metadata.clone(), + tax: payment.tax, + processing_fee: payment.processing_fee, + paid_at: payment.paid_at, + company_id: cid, + company_name: company.name.clone(), + company_base_currency: company.base_currency.clone(), + vm_id, + host_id, + host_name, + region_id, + region_name, + }); } } - // Sort by created timestamp result.sort_by(|a, b| a.created.cmp(&b.created)); - Ok(result) } async fn admin_get_referral_usage_by_date_range( @@ -2445,3 +2944,351 @@ impl LNVPSNostrDb for MockDb { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use lnvps_db::{IntervalType, LNVpsDbBase, SubscriptionPaymentType}; + + /// Build a minimal SubscriptionPayment for the default mock subscription (id=1). + fn make_payment(subscription_id: u64, time_value: Option) -> SubscriptionPayment { + SubscriptionPayment { + id: vec![1u8; 16], + subscription_id, + user_id: 1, + created: Utc::now(), + expires: Utc::now() + chrono::Duration::hours(1), + amount: 1000, + currency: "BTC".to_string(), + payment_method: lnvps_db::PaymentMethod::Lightning, + payment_type: SubscriptionPaymentType::Renewal, + external_data: "".to_string().into(), + external_id: None, + is_paid: false, + rate: 1.0, + time_value, + metadata: None, + tax: 0, + processing_fee: 0, + paid_at: None, + } + } + + /// subscription_payment_paid marks the payment as paid and sets paid_at. + #[tokio::test] + async fn test_subscription_payment_paid_marks_payment() { + let db = MockDb::default(); + let payment = make_payment(1, Some(86400)); + db.insert_subscription_payment(&payment).await.unwrap(); + + db.subscription_payment_paid(&payment).await.unwrap(); + + let payments = db.subscription_payments.lock().await; + let p = payments.iter().find(|p| p.id == payment.id).unwrap(); + assert!(p.is_paid); + assert!(p.paid_at.is_some()); + } + + /// VM path: time_value is set — subscription expires extended by that many seconds. + #[tokio::test] + async fn test_subscription_payment_paid_vm_extends_by_time_value() { + let db = MockDb::default(); + db.vms.lock().await.insert(1, MockDb::mock_vm()); + + let time_value_secs = 30 * 24 * 3600u64; // 30 days + let payment = make_payment(1, Some(time_value_secs)); + db.insert_subscription_payment(&payment).await.unwrap(); + + let before = Utc::now(); + db.subscription_payment_paid(&payment).await.unwrap(); + + let expected_min = before + chrono::Duration::seconds(time_value_secs as i64 - 5); + let expected_max = before + chrono::Duration::seconds(time_value_secs as i64 + 5); + + // Subscription expires must be extended + let subs = db.subscriptions.lock().await; + let sub = subs.get(&1).unwrap(); + let sub_expires = sub.expires.unwrap(); + assert!( + sub_expires >= expected_min && sub_expires <= expected_max, + "subscription expires {} not in expected range", + sub_expires + ); + assert!(sub.is_active); + assert!(sub.is_setup); + drop(subs); + } + + /// Regular subscription path: time_value is None — expires extended by subscription interval. + #[tokio::test] + async fn test_subscription_payment_paid_interval_month() { + let db = MockDb::default(); + // Default subscription has interval_amount=1, interval_type=Month + let payment = make_payment(1, None); + db.insert_subscription_payment(&payment).await.unwrap(); + + let before = Utc::now(); + db.subscription_payment_paid(&payment).await.unwrap(); + + let subs = db.subscriptions.lock().await; + let sub = subs.get(&1).unwrap(); + let expires = sub.expires.unwrap(); + // Should be approximately 1 month from now + let expected_min = before + chrono::Duration::days(28); + let expected_max = before + chrono::Duration::days(32); + assert!( + expires >= expected_min && expires <= expected_max, + "expires {} not in expected range for 1-month interval", + expires + ); + } + + /// Regular subscription path: year interval extends by 12 months. + #[tokio::test] + async fn test_subscription_payment_paid_interval_year() { + let db = MockDb::default(); + // Update subscription to use 1-year interval + { + let mut subs = db.subscriptions.lock().await; + let sub = subs.get_mut(&1).unwrap(); + sub.interval_amount = 1; + sub.interval_type = IntervalType::Year; + } + let payment = make_payment(1, None); + db.insert_subscription_payment(&payment).await.unwrap(); + + let before = Utc::now(); + db.subscription_payment_paid(&payment).await.unwrap(); + + let subs = db.subscriptions.lock().await; + let sub = subs.get(&1).unwrap(); + let expires = sub.expires.unwrap(); + // Should be approximately 12 months from now + let expected_min = before + chrono::Duration::days(364); + let expected_max = before + chrono::Duration::days(367); + assert!( + expires >= expected_min && expires <= expected_max, + "expires {} not in expected range for 1-year interval", + expires + ); + } + + /// Regular subscription path: day interval extends by N days. + #[tokio::test] + async fn test_subscription_payment_paid_interval_day() { + let db = MockDb::default(); + { + let mut subs = db.subscriptions.lock().await; + let sub = subs.get_mut(&1).unwrap(); + sub.interval_amount = 7; + sub.interval_type = IntervalType::Day; + } + let payment = make_payment(1, None); + db.insert_subscription_payment(&payment).await.unwrap(); + + let before = Utc::now(); + db.subscription_payment_paid(&payment).await.unwrap(); + + let subs = db.subscriptions.lock().await; + let sub = subs.get(&1).unwrap(); + let expires = sub.expires.unwrap(); + let expected_min = before + chrono::Duration::days(6); + let expected_max = before + chrono::Duration::days(8); + assert!( + expires >= expected_min && expires <= expected_max, + "expires {} not in expected range for 7-day interval", + expires + ); + } + + /// Consecutive payments stack: second payment extends from the first expiry. + #[tokio::test] + async fn test_subscription_payment_paid_stacks_from_previous_expiry() { + let db = MockDb::default(); + let p1 = make_payment(1, Some(86400)); + let mut p2 = make_payment(1, Some(86400)); + p2.id = vec![2u8; 16]; // different id + + db.insert_subscription_payment(&p1).await.unwrap(); + db.insert_subscription_payment(&p2).await.unwrap(); + + db.subscription_payment_paid(&p1).await.unwrap(); + let expires_after_first = { + let subs = db.subscriptions.lock().await; + subs.get(&1).unwrap().expires.unwrap() + }; + + db.subscription_payment_paid(&p2).await.unwrap(); + let expires_after_second = { + let subs = db.subscriptions.lock().await; + subs.get(&1).unwrap().expires.unwrap() + }; + + // Second payment adds another 86400s on top of the first expiry + let diff = (expires_after_second - expires_after_first).num_seconds(); + assert!( + (diff - 86400).abs() < 5, + "Second payment should add ~86400s from first expiry, but diff was {}s", + diff + ); + } + + /// list_vm_subscription_payments_paginated returns the correct window. + #[tokio::test] + async fn test_list_vm_subscription_payments_paginated() { + let db = MockDb::default(); + // Insert default VM (id=1) which uses subscription_id=1 + { + let mut vms = db.vms.lock().await; + vms.insert(1, MockDb::mock_vm()); + } + + // Insert 5 payments for subscription_id=1 + for i in 0u8..5 { + let mut p = make_payment(1, Some(86400)); + p.id = vec![i; 16]; + p.created = Utc::now() + chrono::Duration::seconds(i as i64); + db.insert_subscription_payment(&p).await.unwrap(); + } + + // Page 0: first 2 + let page0 = db + .list_vm_subscription_payments_paginated(1, 2, 0) + .await + .unwrap(); + assert_eq!(page0.len(), 2); + + // Page 1: next 2 + let page1 = db + .list_vm_subscription_payments_paginated(1, 2, 2) + .await + .unwrap(); + assert_eq!(page1.len(), 2); + + // Page 2: last 1 + let page2 = db + .list_vm_subscription_payments_paginated(1, 2, 4) + .await + .unwrap(); + assert_eq!(page2.len(), 1); + + // Pages do not overlap + assert_ne!(page0[0].id, page1[0].id); + assert_ne!(page1[0].id, page2[0].id); + } + + // ========================================================================= + // Subscription lifecycle DB tests (Increment 15) + // ========================================================================= + + /// list_expiring_subscriptions returns active subscriptions expiring within window. + #[tokio::test] + async fn test_list_expiring_subscriptions_returns_soon_expiring() { + let db = MockDb::default(); + // Set subscription id=1 to expire 30 minutes from now (within 1-day window) + { + let mut subs = db.subscriptions.lock().await; + let sub = subs.get_mut(&1).unwrap(); + sub.is_active = true; + sub.expires = Some(Utc::now() + chrono::Duration::minutes(30)); + } + + let result = db.list_expiring_subscriptions(86400).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, 1); + } + + /// list_expiring_subscriptions excludes subscriptions expiring outside the window. + #[tokio::test] + async fn test_list_expiring_subscriptions_excludes_far_future() { + let db = MockDb::default(); + { + let mut subs = db.subscriptions.lock().await; + let sub = subs.get_mut(&1).unwrap(); + sub.is_active = true; + sub.expires = Some(Utc::now() + chrono::Duration::days(10)); + } + + let result = db.list_expiring_subscriptions(86400).await.unwrap(); + assert!(result.is_empty()); + } + + /// list_expired_subscriptions returns active subscriptions whose expiry is in the past. + #[tokio::test] + async fn test_list_expired_subscriptions_returns_past_expiry() { + let db = MockDb::default(); + { + let mut subs = db.subscriptions.lock().await; + let sub = subs.get_mut(&1).unwrap(); + sub.is_active = true; + sub.expires = Some(Utc::now() - chrono::Duration::hours(1)); + } + + let result = db.list_expired_subscriptions().await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, 1); + } + + /// list_expired_subscriptions excludes subscriptions not yet expired. + #[tokio::test] + async fn test_list_expired_subscriptions_excludes_active() { + let db = MockDb::default(); + { + let mut subs = db.subscriptions.lock().await; + let sub = subs.get_mut(&1).unwrap(); + sub.is_active = true; + sub.expires = Some(Utc::now() + chrono::Duration::hours(1)); + } + + let result = db.list_expired_subscriptions().await.unwrap(); + assert!(result.is_empty()); + } + + /// deactivate_subscription sets is_active=false on the subscription. + #[tokio::test] + async fn test_deactivate_subscription_flips_is_active() { + let db = MockDb::default(); + { + let mut subs = db.subscriptions.lock().await; + let sub = subs.get_mut(&1).unwrap(); + sub.is_active = true; + } + + db.deactivate_subscription(1).await.unwrap(); + + let subs = db.subscriptions.lock().await; + assert!(!subs[&1].is_active); + } + + /// deactivate_subscription sets ended_at and is_active=false on linked ip_range_subscription rows. + #[tokio::test] + async fn test_deactivate_subscription_ends_ip_range_subscriptions() { + let db = MockDb::default(); + { + let mut subs = db.subscriptions.lock().await; + let sub = subs.get_mut(&1).unwrap(); + sub.is_active = true; + } + + // Insert an ip_range_subscription linked to line_item id=1 (which belongs to subscription id=1) + let ip_sub = IpRangeSubscription { + id: 0, + subscription_line_item_id: 1, + available_ip_space_id: 1, + created: Utc::now(), + cidr: "192.0.2.0/24".to_string(), + is_active: true, + started_at: Utc::now(), + ended_at: None, + metadata: None, + }; + let inserted_id = db.insert_ip_range_subscription(&ip_sub).await.unwrap(); + + db.deactivate_subscription(1).await.unwrap(); + + let ip_subs = db.ip_range_subscriptions.lock().await; + let updated = ip_subs.get(&inserted_id).unwrap(); + assert!(!updated.is_active); + assert!(updated.ended_at.is_some()); + } +} diff --git a/lnvps_api_common/src/model.rs b/lnvps_api_common/src/model.rs index 041e3a2..7bb49ef 100644 --- a/lnvps_api_common/src/model.rs +++ b/lnvps_api_common/src/model.rs @@ -141,7 +141,7 @@ impl ApiVmTemplate { currency: price.currency.into(), other_price: vec![], // filled externally interval_amount: 1, - interval_type: ApiVmCostPlanIntervalType::Month, + interval_type: ApiIntervalType::Month, }, region: ApiVmHostRegion { id: region.id, @@ -214,10 +214,10 @@ impl ApiVmTemplate { pub struct ApiVmStatus { /// Unique VM ID (Same in proxmox) pub id: u64, - /// When the VM was created + /// When the subscription was created (i.e. when the VM was ordered) pub created: DateTime, - /// When the VM expires - pub expires: DateTime, + /// When the VM's subscription expires (None = never paid) + pub expires: Option>, /// Network MAC address pub mac_address: String, /// OS Image in use @@ -230,7 +230,7 @@ pub struct ApiVmStatus { pub ip_assignments: Vec, /// Current running state of the VM pub status: VmRunningState, - /// Enable automatic renewal via NWC for this VM + /// Enable automatic renewal (from subscription) pub auto_renewal_enabled: bool, } @@ -253,10 +253,17 @@ pub async fn vm_to_status( .collect(); let template = ApiVmTemplate::from_vm(db, &vm).await?; + // Load subscription for created + expiry + auto_renewal + let (sub_created, sub_expires, sub_auto_renewal) = + match db.get_subscription_by_line_item_id(vm.subscription_line_item_id).await { + Ok(sub) => (sub.created, sub.expires, sub.auto_renewal_enabled), + Err(_) => (Utc::now(), None, false), + }; + Ok(ApiVmStatus { id: vm.id, - created: vm.created, - expires: vm.expires, + created: sub_created, + expires: sub_expires, mac_address: vm.mac_address, image: image.into(), template, @@ -271,30 +278,10 @@ pub async fn vm_to_status( ApiVmIpAssignment::from(&i, range) }) .collect(), - auto_renewal_enabled: vm.auto_renewal_enabled, + auto_renewal_enabled: sub_auto_renewal, }) } -#[derive(Clone, Debug, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum VmState { - Pending, - Running, - #[default] - Stopped, - Failed, -} - -impl From for VmState { - fn from(running_state: crate::status::VmRunningStates) -> Self { - match running_state { - crate::status::VmRunningStates::Running => VmState::Running, - crate::status::VmRunningStates::Stopped => VmState::Stopped, - crate::status::VmRunningStates::Starting => VmState::Pending, - crate::status::VmRunningStates::Deleting => VmState::Failed, - } - } -} #[derive(Serialize)] pub struct ApiVmIpAssignment { @@ -399,28 +386,28 @@ pub struct ApiVmTemplate { #[derive(Serialize, Deserialize, Clone, Copy)] #[serde(rename_all = "lowercase")] -pub enum ApiVmCostPlanIntervalType { +pub enum ApiIntervalType { Day = 0, Month = 1, Year = 2, } -impl From for ApiVmCostPlanIntervalType { - fn from(value: lnvps_db::VmCostPlanIntervalType) -> Self { +impl From for ApiIntervalType { + fn from(value: lnvps_db::IntervalType) -> Self { match value { - lnvps_db::VmCostPlanIntervalType::Day => Self::Day, - lnvps_db::VmCostPlanIntervalType::Month => Self::Month, - lnvps_db::VmCostPlanIntervalType::Year => Self::Year, + lnvps_db::IntervalType::Day => Self::Day, + lnvps_db::IntervalType::Month => Self::Month, + lnvps_db::IntervalType::Year => Self::Year, } } } -impl From for lnvps_db::VmCostPlanIntervalType { - fn from(value: ApiVmCostPlanIntervalType) -> Self { +impl From for lnvps_db::IntervalType { + fn from(value: ApiIntervalType) -> Self { match value { - ApiVmCostPlanIntervalType::Day => Self::Day, - ApiVmCostPlanIntervalType::Month => Self::Month, - ApiVmCostPlanIntervalType::Year => Self::Year, + ApiIntervalType::Day => Self::Day, + ApiIntervalType::Month => Self::Month, + ApiIntervalType::Year => Self::Year, } } } @@ -476,7 +463,7 @@ pub struct ApiVmCostPlan { pub amount: u64, pub other_price: Vec, pub interval_amount: u64, - pub interval_type: ApiVmCostPlanIntervalType, + pub interval_type: ApiIntervalType, } #[derive(Serialize, Deserialize, Clone)] diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index 8f3f3da..9ddec16 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -4,8 +4,9 @@ use chrono::{DateTime, Days, Months, TimeDelta, Utc}; use ipnetwork::IpNetwork; use isocountry::CountryCode; use lnvps_db::{ - CpuArch, CpuFeature, CpuMfg, DiskInterface, DiskType, LNVpsDb, PaymentMethod, PaymentType, Vm, - VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomTemplate, VmPayment, + CpuArch, CpuFeature, CpuMfg, DiskInterface, DiskType, IntervalType, LNVpsDb, PaymentMethod, + PaymentType, SubscriptionPayment, SubscriptionPaymentType, Vm, VmCostPlan, VmCustomPricing, + VmCustomTemplate, VmPayment, }; use payments_rs::currency::{Currency, CurrencyAmount}; use std::collections::HashMap; @@ -59,23 +60,41 @@ pub struct PricingEngine { db: Arc, rates: Arc, tax_rates: HashMap, - base_currency: Currency, } impl PricingEngine { + pub fn new( + db: Arc, + rates: Arc, + tax_rates: HashMap, + ) -> Self { + Self { + db, + rates, + tax_rates, + } + } + /// Convert cost plan interval to seconds - fn cost_plan_interval_to_seconds( - interval_type: VmCostPlanIntervalType, - interval_amount: u64, - ) -> i64 { + fn cost_plan_interval_to_seconds(interval_type: IntervalType, interval_amount: u64) -> i64 { let base_seconds = match interval_type { - VmCostPlanIntervalType::Day => 24 * 60 * 60, // 86,400 seconds per day - VmCostPlanIntervalType::Month => 30 * 24 * 60 * 60, // 2,592,000 seconds per month (30 days) - VmCostPlanIntervalType::Year => 365 * 24 * 60 * 60, // 31,536,000 seconds per year (365 days) + IntervalType::Day => 24 * 60 * 60, // 86,400 seconds per day + IntervalType::Month => 30 * 24 * 60 * 60, // 2,592,000 seconds per month (30 days) + IntervalType::Year => 365 * 24 * 60 * 60, // 31,536,000 seconds per year (365 days) }; base_seconds * interval_amount as i64 } + /// Get the authoritative expiry for a VM from its subscription. + /// Returns `None` if the subscription has never been paid. + async fn vm_subscription_expires(&self, vm: &Vm) -> Option> { + self.db + .get_subscription_by_line_item_id(vm.subscription_line_item_id) + .await + .ok()? + .expires + } + /// Calculate processing fee for a payment based on payment method and amount /// Returns the processing fee in the same currency as the amount /// Queries the database for fee configuration @@ -161,35 +180,6 @@ impl PricingEngine { percentage_fee + base_fee } - pub fn new( - db: Arc, - rates: Arc, - tax_rates: HashMap, - base_currency: Currency, - ) -> Self { - Self { - db, - rates, - tax_rates, - base_currency, - } - } - - /// Create a new pricing engine for a specific VM, automatically looking up the company's base currency - pub async fn new_for_vm( - db: Arc, - rates: Arc, - tax_rates: HashMap, - vm_id: u64, - ) -> Result { - let base_currency_str = db.get_vm_base_currency(vm_id).await?; - let base_currency: Currency = base_currency_str - .parse() - .map_err(|_| anyhow::anyhow!("Invalid base currency: {}", base_currency_str))?; - - Ok(Self::new(db, rates, tax_rates, base_currency)) - } - /// Get amount of time a certain currency amount will extend a vm in seconds pub async fn get_cost_by_amount( &self, @@ -213,11 +203,15 @@ impl PricingEngine { let new_time = (cost.time_value as f64 * scale).floor() as u64; ensure!(new_time > 0, "Extend time is less than 1 second"); + let vm_expires = self + .vm_subscription_expires(&vm) + .await + .unwrap_or_else(Utc::now); Ok(CostResult::New(NewPaymentInfo { amount: input.value(), currency: cost.currency, time_value: new_time, - new_expiry: vm.expires.add(TimeDelta::seconds(new_time as i64)), + new_expiry: vm_expires.add(TimeDelta::seconds(new_time as i64)), rate: cost.rate, tax: self.get_tax_for_user(vm.user_id, input.value()).await?, processing_fee: self @@ -249,26 +243,28 @@ impl PricingEngine { self.get_custom_vm_cost(&vm, method, company_id).await? }; - let expected_time_value = base_cost.time_value * intervals as u64; - - // Check for existing payment with matching time value - let payments = self - .db - .list_vm_payment_by_method_and_type(vm.id, method, PaymentType::Renewal) - .await?; - if let Some(px) = payments - .into_iter() - .find(|p| p.time_value == expected_time_value) - { + // Check for an existing pending (unpaid, non-expired) renewal payment. + // We match on payment_method + payment_type only — matching on time_value is + // unreliable because time_value is computed from vm.expires, which advances + // after each confirmed payment. + let pending = self.db.list_pending_vm_subscription_payments(vm.id).await?; + if let Some(px) = pending.into_iter().find(|p| { + p.payment_method == method && p.payment_type == SubscriptionPaymentType::Renewal + }) { return Ok(CostResult::Existing(px)); } // Scale the cost by number of intervals + let base = self + .vm_subscription_expires(&vm) + .await + .unwrap_or_else(Utc::now) + .max(Utc::now()); if intervals == 1 { Ok(CostResult::New(base_cost)) } else { let scaled_amount = base_cost.amount * intervals as u64; - let scaled_time = expected_time_value; + let scaled_time = base_cost.time_value * intervals as u64; let scaled_tax = self.get_tax_for_user(vm.user_id, scaled_amount).await?; let processing_fee = self .calculate_processing_fee(company_id, method, base_cost.currency, scaled_amount) @@ -280,7 +276,7 @@ impl PricingEngine { currency: base_cost.currency, rate: base_cost.rate, time_value: scaled_time, - new_expiry: vm.expires.add(TimeDelta::seconds(scaled_time as i64)), + new_expiry: base.add(TimeDelta::seconds(scaled_time as i64)), })) } } @@ -359,8 +355,13 @@ impl PricingEngine { let template = self.db.get_custom_vm_template(template_id).await?; let price = Self::get_custom_vm_cost_amount(&self.db, vm.id, &template).await?; - // custom templates are always 1-month intervals - let time_value = (vm.expires.add(Months::new(1)) - vm.expires).num_seconds() as u64; + // custom templates are always 1-month intervals; clamp base to now for expired VMs + let base = self + .vm_subscription_expires(vm) + .await + .unwrap_or_else(Utc::now) + .max(Utc::now()); + let time_value = (base.add(Months::new(1)) - base).num_seconds() as u64; let converted_amount = self .get_amount_and_rate( CurrencyAmount::from_u64(price.currency, price.total()), @@ -383,7 +384,7 @@ impl PricingEngine { currency: converted_amount.amount.currency(), rate: converted_amount.rate, time_value, - new_expiry: vm.expires.add(TimeDelta::seconds(time_value as i64)), + new_expiry: base.add(TimeDelta::seconds(time_value as i64)), }) } @@ -419,18 +420,16 @@ impl PricingEngine { } } - pub fn next_template_expire(vm: &Vm, cost_plan: &VmCostPlan) -> u64 { + pub fn next_template_expire(base_expiry: DateTime, cost_plan: &VmCostPlan) -> u64 { + // Clamp the base to now so expired VMs get a sensible time_value + let base = base_expiry.max(Utc::now()); let next_expire = match cost_plan.interval_type { - VmCostPlanIntervalType::Day => vm.expires.add(Days::new(cost_plan.interval_amount)), - VmCostPlanIntervalType::Month => vm - .expires - .add(Months::new(cost_plan.interval_amount as u32)), - VmCostPlanIntervalType::Year => vm - .expires - .add(Months::new((12 * cost_plan.interval_amount) as u32)), + IntervalType::Day => base.add(Days::new(cost_plan.interval_amount)), + IntervalType::Month => base.add(Months::new(cost_plan.interval_amount as u32)), + IntervalType::Year => base.add(Months::new((12 * cost_plan.interval_amount) as u32)), }; - (next_expire - vm.expires).num_seconds() as u64 + (next_expire - base).num_seconds() as u64 } /// Gets the renewal cost of a standard VM @@ -452,7 +451,12 @@ impl PricingEngine { let converted_amount = self .get_amount_and_rate(CurrencyAmount::from_u64(currency, cost_plan.amount), method) .await?; - let time_value = Self::next_template_expire(vm, &cost_plan); + let vm_expires = self + .vm_subscription_expires(vm) + .await + .unwrap_or_else(Utc::now); + let time_value = Self::next_template_expire(vm_expires, &cost_plan); + let base = vm_expires.max(Utc::now()); Ok(NewPaymentInfo { amount: converted_amount.amount.value(), tax: self @@ -469,7 +473,7 @@ impl PricingEngine { currency: converted_amount.amount.currency(), rate: converted_amount.rate, time_value, - new_expiry: vm.expires.add(TimeDelta::seconds(time_value as i64)), + new_expiry: base.add(TimeDelta::seconds(time_value as i64)), }) } @@ -632,8 +636,12 @@ impl PricingEngine { let vm = self.db.get_vm(vm_id).await?; ensure!(!vm.deleted, "Can't calculate for deleted VM"); + let vm_expires = self + .vm_subscription_expires(&vm) + .await + .ok_or_else(|| anyhow!("VM subscription has no expiry date"))?; ensure!( - vm.expires > from_date, + vm_expires > from_date, "Can't calculate for expired VM from the specified date" ); @@ -658,7 +666,7 @@ impl PricingEngine { } else if let Some(cid) = vm.custom_template_id { let template = self.db.get_custom_vm_template(cid).await?; let price = Self::get_custom_vm_cost_amount(&self.db, vm.id, &template).await?; - let time_value = Self::cost_plan_interval_to_seconds(VmCostPlanIntervalType::Month, 1); + let time_value = Self::cost_plan_interval_to_seconds(IntervalType::Month, 1); ( CurrencyAmount::from_u64(price.currency, price.total()), time_value, @@ -667,7 +675,7 @@ impl PricingEngine { bail!("VM must have either a standard template or custom template"); }; - let seconds_remaining = (vm.expires - from_date).num_seconds(); + let seconds_remaining = (vm_expires - from_date).num_seconds(); let cost_per_second = current_cost.value() as f64 / current_time_value as f64; let prorated_amount = seconds_remaining as f64 * cost_per_second; let prorated_cost = @@ -683,7 +691,7 @@ impl PricingEngine { } /// Calculate pro-rated refund amount for a VM from a specific date - pub async fn calculate_refund_amount_from_date( + pub async fn calculate_vm_refund_amount_from_date( &self, vm_id: u64, method: PaymentMethod, @@ -699,7 +707,7 @@ impl PricingEngine { } /// Calculate both the upgrade cost and new renewal cost for a VM - pub async fn calculate_upgrade_cost( + pub async fn calculate_vm_upgrade_cost( &self, vm_id: u64, cfg: &UpgradeConfig, @@ -708,7 +716,11 @@ impl PricingEngine { let vm = self.db.get_vm(vm_id).await?; ensure!(!vm.deleted, "Can't upgrade deleted VM"); - ensure!(vm.expires > Utc::now(), "Can't upgrade an expired VM"); + let vm_expires = self + .vm_subscription_expires(&vm) + .await + .ok_or_else(|| anyhow!("VM subscription has no expiry date"))?; + ensure!(vm_expires > Utc::now(), "Can't upgrade an expired VM"); // Get remaining time info for current VM let remaining_info = self.get_remaining_time_info(vm_id).await?; @@ -722,8 +734,7 @@ impl PricingEngine { let new_price = CurrencyAmount::from_u64(new_price.currency, new_price.total()); // Get the time value for the custom template - let custom_plan_seconds = - Self::cost_plan_interval_to_seconds(VmCostPlanIntervalType::Month, 1); + let custom_plan_seconds = Self::cost_plan_interval_to_seconds(IntervalType::Month, 1); let new_cost_per_second = new_price.value() as f64 / custom_plan_seconds as f64; // calculate the cost based on the time until the vm expires @@ -783,8 +794,8 @@ impl PricingEngine { #[derive(Clone)] pub enum CostResult { - /// An existing payment already exists and should be used - Existing(VmPayment), + /// An existing unpaid subscription payment already exists and should be reused + Existing(SubscriptionPayment), /// A new payment can be created with the specified amount New(NewPaymentInfo), } @@ -974,7 +985,7 @@ mod tests { let db: Arc = Arc::new(db); let rates = Arc::new(MockExchangeRate::new()); - let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + let pe = PricingEngine::new(db, rates, HashMap::new()); // Test a range of amounts to ensure gross-up always holds for base in [100u64, 345, 1000, 9999, 50000] { @@ -1043,7 +1054,7 @@ mod tests { let db: Arc = Arc::new(db); let rates = Arc::new(MockExchangeRate::new()); - let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + let pe = PricingEngine::new(db, rates, HashMap::new()); let amount = 990u64; // €9.90 in cents let fee = pe @@ -1133,7 +1144,7 @@ mod tests { let taxes = HashMap::from([(CountryCode::IRL, 23.0)]); - let pe = PricingEngine::new(db.clone(), rates, taxes, Currency::EUR); + let pe = PricingEngine::new(db.clone(), rates, taxes); let plan = MockDb::mock_cost_plan(); let price = pe.get_vm_cost(1, PaymentMethod::Lightning).await?; @@ -1174,8 +1185,7 @@ mod tests { let amount_eur = plan.amount as f64 / 100.0; // Convert cents to EUR let mo_price = (amount_eur / MOCK_RATE as f64 * 1.0e11) as u64; let time_scale = 1000f64 / mo_price as f64; - let vm = db.get_vm(1).await?; - let next_expire = PricingEngine::next_template_expire(&vm, &plan); + let next_expire = PricingEngine::next_template_expire(Utc::now(), &plan); match price { CostResult::New(payment_info) => { let expect_time = (next_expire as f64 * time_scale) as u64; @@ -1190,58 +1200,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn test_pricing_engine_with_different_currencies() -> Result<()> { - let db = MockDb::default(); - let rates = Arc::new(MockExchangeRate::new()); - - // Set up rates for different currencies - rates.set_rate(Ticker::btc_rate("EUR")?, 95_000.0).await; - rates.set_rate(Ticker::btc_rate("USD")?, 100_000.0).await; - - let taxes = HashMap::new(); - let db_arc: Arc = Arc::new(db); - - // Test EUR pricing engine - let pe_eur = - PricingEngine::new(db_arc.clone(), rates.clone(), taxes.clone(), Currency::EUR); - - // Test USD pricing engine - let pe_usd = PricingEngine::new(db_arc.clone(), rates.clone(), taxes, Currency::USD); - - // Both should work with their respective base currencies - // The base currency is now stored in the pricing engine itself - assert_eq!(pe_eur.base_currency, Currency::EUR); - assert_eq!(pe_usd.base_currency, Currency::USD); - - Ok(()) - } - - #[tokio::test] - async fn test_new_for_vm() -> Result<()> { - let db = MockDb::default(); - let rates = Arc::new(MockExchangeRate::new()); - - // Set up rates - rates.set_rate(Ticker::btc_rate("EUR")?, 95_000.0).await; - - let taxes = HashMap::new(); - - // Add a VM - { - let mut vms = db.vms.lock().await; - vms.insert(1, MockDb::mock_vm()); - } - - let db_arc: Arc = Arc::new(db); - - // Test creating pricing engine for VM (should use EUR from default company) - let pe = PricingEngine::new_for_vm(db_arc.clone(), rates.clone(), taxes.clone(), 1).await?; - assert_eq!(pe.base_currency, Currency::EUR); - - Ok(()) - } - async fn setup_upgrade_test_data(db: &MockDb) -> Result<()> { db.upsert_user(&[0; 32]).await?; // Add custom pricing for region 1 that supports SSD PCIe disks @@ -1304,7 +1262,14 @@ mod tests { // Setup test data setup_upgrade_test_data(&db).await?; - // Create a VM with a standard template + // Create a VM with a standard template; set subscription expiry to 15 days + { + let mut subs = db.subscriptions.lock().await; + if let Some(s) = subs.get_mut(&1) { + s.expires = Some(Utc::now() + chrono::Duration::days(15)); + s.is_setup = true; + } + } { let mut vms = db.vms.lock().await; vms.insert( @@ -1312,7 +1277,6 @@ mod tests { Vm { id: 1, user_id: 1, - expires: Utc::now() + chrono::Duration::days(15), // 15 days remaining template_id: Some(1), custom_template_id: None, deleted: false, @@ -1323,7 +1287,7 @@ mod tests { let db_arc: Arc = Arc::new(db); let taxes = HashMap::new(); - let pe = PricingEngine::new(db_arc.clone(), rates, taxes, Currency::EUR); + let pe = PricingEngine::new(db_arc.clone(), rates, taxes); // Test upgrade configuration - increase CPU from 1 to 2 let upgrade_config = UpgradeConfig { @@ -1333,7 +1297,7 @@ mod tests { }; let quote = pe - .calculate_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) + .calculate_vm_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) .await?; // Verify that we got a valid quote @@ -1356,6 +1320,13 @@ mod tests { setup_upgrade_test_data(&db).await?; // Create an expired VM + { + let mut subs = db.subscriptions.lock().await; + if let Some(s) = subs.get_mut(&1) { + s.expires = Some(Utc::now() - chrono::Duration::days(1)); // Expired + s.is_setup = true; + } + } { let mut vms = db.vms.lock().await; vms.insert( @@ -1363,7 +1334,6 @@ mod tests { Vm { id: 1, user_id: 1, - expires: Utc::now() - chrono::Duration::days(1), // Expired template_id: Some(1), custom_template_id: None, deleted: false, @@ -1374,7 +1344,7 @@ mod tests { let db_arc: Arc = Arc::new(db); let taxes = HashMap::new(); - let pe = PricingEngine::new(db_arc.clone(), rates, taxes, Currency::EUR); + let pe = PricingEngine::new(db_arc.clone(), rates, taxes); let upgrade_config = UpgradeConfig { new_cpu: Some(2), @@ -1384,7 +1354,7 @@ mod tests { // Should fail for expired VM let result = pe - .calculate_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) + .calculate_vm_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) .await; assert!(result.is_err()); @@ -1399,7 +1369,14 @@ mod tests { setup_upgrade_test_data(&db).await?; - // Create a deleted VM + // Create a deleted VM; set subscription expiry + { + let mut subs = db.subscriptions.lock().await; + if let Some(s) = subs.get_mut(&1) { + s.expires = Some(Utc::now() + chrono::Duration::days(15)); + s.is_setup = true; + } + } { let mut vms = db.vms.lock().await; vms.insert( @@ -1407,7 +1384,6 @@ mod tests { Vm { id: 1, user_id: 1, - expires: Utc::now() + chrono::Duration::days(15), template_id: Some(1), custom_template_id: None, deleted: true, // Deleted @@ -1418,7 +1394,7 @@ mod tests { let db_arc: Arc = Arc::new(db); let taxes = HashMap::new(); - let pe = PricingEngine::new(db_arc.clone(), rates, taxes, Currency::EUR); + let pe = PricingEngine::new(db_arc.clone(), rates, taxes); let upgrade_config = UpgradeConfig { new_cpu: Some(2), @@ -1428,7 +1404,7 @@ mod tests { // Should fail for deleted VM let result = pe - .calculate_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) + .calculate_vm_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) .await; assert!(result.is_err()); @@ -1444,7 +1420,14 @@ mod tests { setup_upgrade_test_data(&db).await?; add_custom_pricing(&db).await; - // Create a VM with a custom template + // Create a VM with a custom template; set subscription expiry to 10 days + { + let mut subs = db.subscriptions.lock().await; + if let Some(s) = subs.get_mut(&1) { + s.expires = Some(Utc::now() + chrono::Duration::days(10)); + s.is_setup = true; + } + } { let mut vms = db.vms.lock().await; vms.insert( @@ -1452,7 +1435,6 @@ mod tests { Vm { id: 1, user_id: 1, - expires: Utc::now() + chrono::Duration::days(10), template_id: None, custom_template_id: Some(1), deleted: false, @@ -1463,7 +1445,7 @@ mod tests { let db_arc: Arc = Arc::new(db); let taxes = HashMap::new(); - let pe = PricingEngine::new(db_arc.clone(), rates, taxes, Currency::EUR); + let pe = PricingEngine::new(db_arc.clone(), rates, taxes); let upgrade_config = UpgradeConfig { new_cpu: Some(4), // Upgrade from 2 to 4 CPUs @@ -1472,7 +1454,7 @@ mod tests { }; let quote = pe - .calculate_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) + .calculate_vm_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) .await?; // Verify that we got a valid quote for custom template upgrade @@ -1500,6 +1482,13 @@ mod tests { // Create a VM with exactly 1 day (86400 seconds) remaining let seconds_remaining = 86400i64; // 1 day let expiry_time = Utc::now() + chrono::Duration::seconds(seconds_remaining); + { + let mut subs = db.subscriptions.lock().await; + if let Some(s) = subs.get_mut(&1) { + s.expires = Some(expiry_time); + s.is_setup = true; + } + } { let mut vms = db.vms.lock().await; vms.insert( @@ -1507,7 +1496,6 @@ mod tests { Vm { id: 1, user_id: 1, - expires: expiry_time, template_id: Some(1), custom_template_id: None, deleted: false, @@ -1518,7 +1506,7 @@ mod tests { let db_arc: Arc = Arc::new(db); let taxes = HashMap::new(); - let pe = PricingEngine::new(db_arc.clone(), rates, taxes, Currency::EUR); + let pe = PricingEngine::new(db_arc.clone(), rates, taxes); // Test upgrade - increase CPU from 2 to 4 (double the CPU) let upgrade_config = UpgradeConfig { @@ -1536,7 +1524,7 @@ mod tests { let old_cost_per_second = old_cost_info.cost_per_second(); let quote = pe - .calculate_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) + .calculate_vm_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) .await?; // Calculate expected values based on the algorithm: @@ -1665,6 +1653,13 @@ mod tests { // Create a VM with 2 weeks remaining let seconds_remaining = 14 * 24 * 60 * 60i64; // 14 days = 1,209,600 seconds let expiry_time = Utc::now() + chrono::Duration::seconds(seconds_remaining); + { + let mut subs = db.subscriptions.lock().await; + if let Some(s) = subs.get_mut(&1) { + s.expires = Some(expiry_time); + s.is_setup = true; + } + } { let mut vms = db.vms.lock().await; vms.insert( @@ -1672,7 +1667,6 @@ mod tests { Vm { id: 1, user_id: 1, - expires: expiry_time, template_id: Some(1), custom_template_id: None, deleted: false, @@ -1683,7 +1677,7 @@ mod tests { let db_arc: Arc = Arc::new(db); let taxes = HashMap::new(); - let pe = PricingEngine::new(db_arc.clone(), rates, taxes, Currency::EUR); + let pe = PricingEngine::new(db_arc.clone(), rates, taxes); // Test large upgrade - significantly increase all resources let upgrade_config = UpgradeConfig { @@ -1693,7 +1687,7 @@ mod tests { }; let quote = pe - .calculate_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) + .calculate_vm_upgrade_cost(1, &upgrade_config, PaymentMethod::Lightning) .await?; // This should result in a significant positive upgrade cost since we're upgrading to much higher specs @@ -1840,7 +1834,7 @@ mod tests { let db: Arc = Arc::new(db); let taxes = HashMap::new(); - let pe = PricingEngine::new(db.clone(), rates, taxes.clone(), Currency::EUR); + let pe = PricingEngine::new(db.clone(), rates, taxes.clone()); // Test Lightning payment (no processing fee) let price_lightning = pe.get_vm_cost(1, PaymentMethod::Lightning).await?; @@ -1977,10 +1971,16 @@ mod tests { ssh_key_id: 1, disk_id: 1, mac_address: "aa:bb:cc:dd:ee:ff".to_string(), - expires: Utc::now() + TimeDelta::days(30), ..Default::default() }, ); + drop(vms); + // Set subscription expiry to 30 days + let mut subs = db.subscriptions.lock().await; + if let Some(s) = subs.get_mut(&1) { + s.expires = Some(Utc::now() + TimeDelta::days(30)); + s.is_setup = true; + } vm_id } @@ -1994,7 +1994,7 @@ mod tests { let db: Arc = Arc::new(db); let rates = Arc::new(MockExchangeRate::new()); - let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + let pe = PricingEngine::new(db, rates, HashMap::new()); let cfg = crate::UpgradeConfig { new_cpu: Some(4), @@ -2017,7 +2017,7 @@ mod tests { let db: Arc = Arc::new(db); let rates = Arc::new(MockExchangeRate::new()); - let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + let pe = PricingEngine::new(db, rates, HashMap::new()); let cfg = crate::UpgradeConfig { new_cpu: Some(4), @@ -2044,7 +2044,7 @@ mod tests { let db: Arc = Arc::new(db); let rates = Arc::new(MockExchangeRate::new()); - let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + let pe = PricingEngine::new(db, rates, HashMap::new()); let cfg = crate::UpgradeConfig { new_cpu: Some(4), @@ -2072,7 +2072,7 @@ mod tests { let db: Arc = Arc::new(db); let rates = Arc::new(MockExchangeRate::new()); - let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + let pe = PricingEngine::new(db, rates, HashMap::new()); let cfg = crate::UpgradeConfig { new_cpu: Some(4), @@ -2096,7 +2096,7 @@ mod tests { let db: Arc = Arc::new(db); let rates = Arc::new(MockExchangeRate::new()); - let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + let pe = PricingEngine::new(db, rates, HashMap::new()); let cfg = crate::UpgradeConfig { new_cpu: Some(4), @@ -2135,7 +2135,7 @@ mod tests { let db: Arc = Arc::new(db); let rates = Arc::new(MockExchangeRate::new()); - let pe = PricingEngine::new(db, rates, HashMap::new(), Currency::EUR); + let pe = PricingEngine::new(db, rates, HashMap::new()); let cfg = crate::UpgradeConfig { new_cpu: Some(4), @@ -2149,4 +2149,125 @@ mod tests { ); Ok(()) } + + /// Build a minimal PricingEngine backed by MockDb with the BTC/EUR rate set. + async fn make_pe(db: Arc) -> PricingEngine { + let rates = Arc::new(MockExchangeRate::new()); + rates + .set_rate(Ticker::btc_rate("EUR").unwrap(), MOCK_RATE) + .await; + PricingEngine::new(db, rates as Arc, HashMap::new()) + } + + /// get_vm_cost_for_intervals returns CostResult::Existing when a valid (non-expired) + /// unpaid renewal payment already exists for the VM. + #[tokio::test] + async fn test_get_vm_cost_dedup_reuses_valid_unpaid_payment() -> Result<()> { + let db = Arc::new(MockDb::default()); + db.vms.lock().await.insert(1, MockDb::mock_vm()); + db.users.lock().await.insert( + 1, + User { + id: 1, + pubkey: vec![], + ..Default::default() + }, + ); + + // Insert an existing unpaid renewal payment that has not yet expired + let existing = SubscriptionPayment { + id: vec![0xabu8; 16], + subscription_id: 1, + user_id: 1, + created: Utc::now(), + expires: Utc::now() + chrono::Duration::minutes(10), // still valid + amount: 9999, + currency: "BTC".to_string(), + payment_method: PaymentMethod::Lightning, + payment_type: SubscriptionPaymentType::Renewal, + external_data: "lnbc_test".to_string().into(), + external_id: None, + is_paid: false, + rate: MOCK_RATE, + time_value: Some(86400), + metadata: None, + tax: 0, + processing_fee: 0, + paid_at: None, + }; + db.insert_subscription_payment(&existing).await?; + + let db_arc: Arc = db; + let pe = make_pe(db_arc).await; + let result = pe + .get_vm_cost_for_intervals(1, PaymentMethod::Lightning, 1) + .await?; + + match result { + CostResult::Existing(p) => { + assert_eq!(p.id, existing.id, "should return the pre-existing payment"); + } + CostResult::New(_) => bail!("expected Existing, got New"), + } + Ok(()) + } + + /// get_vm_cost_for_intervals returns CostResult::New when the only existing unpaid + /// renewal payment has an expired invoice, rather than returning the stale payment. + #[tokio::test] + async fn test_get_vm_cost_dedup_ignores_expired_unpaid_payment() -> Result<()> { + let db = Arc::new(MockDb::default()); + db.vms.lock().await.insert(1, MockDb::mock_vm()); + db.users.lock().await.insert( + 1, + User { + id: 1, + pubkey: vec![], + ..Default::default() + }, + ); + + // Insert an unpaid renewal payment whose invoice has already expired + let expired = SubscriptionPayment { + id: vec![0xddu8; 16], + subscription_id: 1, + user_id: 1, + created: Utc::now() - chrono::Duration::hours(1), + expires: Utc::now() - chrono::Duration::minutes(1), // expired + amount: 9999, + currency: "BTC".to_string(), + payment_method: PaymentMethod::Lightning, + payment_type: SubscriptionPaymentType::Renewal, + external_data: "lnbc_expired".to_string().into(), + external_id: None, + is_paid: false, + rate: MOCK_RATE, + time_value: Some(86400), + metadata: None, + tax: 0, + processing_fee: 0, + paid_at: None, + }; + db.insert_subscription_payment(&expired).await?; + + let db_arc: Arc = db; + let pe = make_pe(db_arc).await; + let result = pe + .get_vm_cost_for_intervals(1, PaymentMethod::Lightning, 1) + .await?; + + match result { + CostResult::New(p) => { + assert_ne!( + p.amount, 9999, + "should compute a fresh amount, not the expired one" + ); + assert!(p.time_value > 0, "fresh payment must have a time_value"); + } + CostResult::Existing(_) => { + bail!("expected New, got Existing — expired invoice was reused") + } + } + Ok(()) + } } diff --git a/lnvps_api_common/src/status.rs b/lnvps_api_common/src/status.rs index 343034e..a018a0b 100644 --- a/lnvps_api_common/src/status.rs +++ b/lnvps_api_common/src/status.rs @@ -12,11 +12,12 @@ use tokio::sync::RwLock; #[derive(Clone, Serialize, Deserialize, Default, PartialEq, Debug)] #[serde(rename_all = "lowercase")] pub enum VmRunningStates { - Running, #[default] + Unknown, + Running, Stopped, - Starting, - Deleting, + /// Payment received; VM is being provisioned on the host for the first time. + Creating, } #[derive(Clone, Serialize, Deserialize, Default)] diff --git a/lnvps_api_common/src/vm_history.rs b/lnvps_api_common/src/vm_history.rs index 8f31ddf..f774af1 100644 --- a/lnvps_api_common/src/vm_history.rs +++ b/lnvps_api_common/src/vm_history.rs @@ -33,8 +33,6 @@ impl VmHistoryLogger { "template_id": vm.template_id, "custom_template_id": vm.custom_template_id, "ssh_key_id": vm.ssh_key_id, - "created": vm.created, - "expires": vm.expires, "disk_id": vm.disk_id, "mac_address": vm.mac_address, "ref_code": vm.ref_code @@ -353,7 +351,6 @@ impl VmHistoryLogger { "template_id": old_vm.template_id, "custom_template_id": old_vm.custom_template_id, "ssh_key_id": old_vm.ssh_key_id, - "expires": old_vm.expires, "disk_id": old_vm.disk_id, "mac_address": old_vm.mac_address }); @@ -364,7 +361,6 @@ impl VmHistoryLogger { "template_id": new_vm.template_id, "custom_template_id": new_vm.custom_template_id, "ssh_key_id": new_vm.ssh_key_id, - "expires": new_vm.expires, "disk_id": new_vm.disk_id, "mac_address": new_vm.mac_address }); @@ -468,14 +464,12 @@ mod tests { image_id: 0, template_id: None, custom_template_id: None, + subscription_line_item_id: 0, ssh_key_id: 0, - created: chrono::Utc::now(), - expires: chrono::Utc::now(), disk_id: 0, mac_address: "aa:bb:cc:dd:ee:ff".to_string(), deleted: false, ref_code: None, - auto_renewal_enabled: false, disabled: false, }; logger.log_vm_created(&vm, Some(1), None).await.unwrap(); @@ -648,7 +642,6 @@ mod tests { #[tokio::test] async fn test_log_vm_configuration_changed() { let logger = make_logger(); - let now = chrono::Utc::now(); let old_vm = lnvps_db::Vm { id: 18, host_id: 1, @@ -656,14 +649,12 @@ mod tests { image_id: 1, template_id: Some(1), custom_template_id: None, + subscription_line_item_id: 0, ssh_key_id: 1, - created: now, - expires: now, disk_id: 1, mac_address: "aa:bb:cc:dd:ee:ff".to_string(), deleted: false, ref_code: None, - auto_renewal_enabled: false, disabled: false, }; let mut new_vm = old_vm.clone(); diff --git a/lnvps_api_common/src/work/mod.rs b/lnvps_api_common/src/work/mod.rs index 562002e..262f06a 100644 --- a/lnvps_api_common/src/work/mod.rs +++ b/lnvps_api_common/src/work/mod.rs @@ -35,6 +35,11 @@ pub enum WorkJob { /// /// This job starts a vm if stopped and also creates the vm if it doesn't exist yet CheckVm { vm_id: u64 }, + /// Unconditionally provision and spawn a VM onto the host. + /// + /// Used after a first (Purchase) payment is confirmed so the VM is created + /// immediately without relying on `get_vm_state` to detect its absence. + SpawnVm { vm_id: u64 }, /// Send a notification to the users chosen contact preferences SendNotification { user_id: u64, @@ -119,6 +124,8 @@ pub enum WorkJob { /// Download OS images to all hosts, verifying checksums and re-downloading if stale. /// If `image_id` is Some, only that image is processed; otherwise all images are checked. DownloadOsImages { image_id: Option }, + /// Check all active subscriptions for expiry, auto-renewal, and deactivation. + CheckSubscriptions, } impl WorkJob { @@ -130,6 +137,7 @@ impl WorkJob { Self::StartVm { .. } => true, Self::CheckVm { .. } => true, Self::CheckVms => true, + Self::CheckSubscriptions => true, _ => false, } } @@ -157,6 +165,8 @@ impl fmt::Display for WorkJob { WorkJob::CreateVm { .. } => write!(f, "CreateVm"), WorkJob::SendEmailVerification { .. } => write!(f, "SendEmailVerification"), WorkJob::DownloadOsImages { .. } => write!(f, "DownloadOsImages"), + WorkJob::CheckSubscriptions => write!(f, "CheckSubscriptions"), + WorkJob::SpawnVm { .. } => write!(f, "SpawnVm"), } } } diff --git a/lnvps_db/migrations/20260302151134_vm_subscription_link.sql b/lnvps_db/migrations/20260302151134_vm_subscription_link.sql new file mode 100644 index 0000000..be389ea --- /dev/null +++ b/lnvps_db/migrations/20260302151134_vm_subscription_link.sql @@ -0,0 +1,29 @@ +-- Re-add interval columns to subscription (were dropped in 20260130000003) +-- Needed so VMs can use subscriptions with configurable billing intervals. +-- Add is_setup flag: true once the first (purchase) payment has been confirmed. +-- Replaces scanning payment history to determine whether setup fees apply. +ALTER TABLE subscription + ADD COLUMN interval_amount INTEGER UNSIGNED NOT NULL DEFAULT 1 AFTER currency, + ADD COLUMN interval_type SMALLINT UNSIGNED NOT NULL DEFAULT 1 AFTER interval_amount, + ADD COLUMN is_setup BIT(1) NOT NULL DEFAULT 0 AFTER is_active; +-- interval_type: 0=Day, 1=Month, 2=Year (default Month) + +-- Re-add time_value and add metadata to subscription_payment +-- time_value: seconds this payment adds to expiry (was dropped in 20260130000003) +-- metadata: JSON for upgrade params, etc. +ALTER TABLE subscription_payment + ADD COLUMN time_value BIGINT UNSIGNED AFTER rate, + ADD COLUMN metadata JSON AFTER time_value; + +-- Link VMs to their subscription line item (mirrors ip_range_subscription pattern). +-- A VM belongs to exactly one line item, and the subscription is found via the line item. +-- Run migrate_vm_subscriptions binary to backfill existing rows before applying NOT NULL. +ALTER TABLE vm + ADD COLUMN subscription_line_item_id INTEGER UNSIGNED AFTER custom_template_id, + ADD CONSTRAINT fk_vm_subscription_line_item + FOREIGN KEY (subscription_line_item_id) REFERENCES subscription_line_item (id); +CREATE INDEX idx_vm_subscription_line_item ON vm (subscription_line_item_id); + +-- Add VmRenewal(3) and VmUpgrade(4) to the subscription_type enum stored in +-- subscription_line_item.subscription_type. No DDL change needed — the column +-- is SMALLINT UNSIGNED, so the new values are valid immediately. diff --git a/lnvps_db/migrations/20260304000000_drop_vm_expires.sql b/lnvps_db/migrations/20260304000000_drop_vm_expires.sql new file mode 100644 index 0000000..cc29564 --- /dev/null +++ b/lnvps_db/migrations/20260304000000_drop_vm_expires.sql @@ -0,0 +1,7 @@ +-- Remove vm.expires and vm.auto_renewal_enabled from the vm table. +-- Expiry is now read exclusively from subscription.expires. +-- Auto-renewal is managed via subscription.auto_renewal_enabled. + +ALTER TABLE vm + DROP COLUMN expires, + DROP COLUMN auto_renewal_enabled; diff --git a/lnvps_db/migrations/20260310000000_drop_vm_created.sql b/lnvps_db/migrations/20260310000000_drop_vm_created.sql new file mode 100644 index 0000000..54f5684 --- /dev/null +++ b/lnvps_db/migrations/20260310000000_drop_vm_created.sql @@ -0,0 +1 @@ +alter table vm drop column created; diff --git a/lnvps_db/src/admin.rs b/lnvps_db/src/admin.rs index abc89da..b26a7ae 100644 --- a/lnvps_db/src/admin.rs +++ b/lnvps_db/src/admin.rs @@ -33,6 +33,13 @@ pub trait AdminDb: Send + Sync { /// List all roles async fn list_roles(&self) -> DbResult>; + /// List roles with database-level pagination. Returns (rows, total_count). + async fn list_roles_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; + /// Update role information async fn update_role(&self, role: &AdminRole) -> DbResult<()>; @@ -203,29 +210,14 @@ pub trait AdminDb: Send + Sync { /// Count regions assigned to a company async fn admin_count_company_regions(&self, company_id: u64) -> DbResult; - /// Get payments within a date range (admin only) - async fn admin_get_payments_by_date_range( - &self, - start_date: chrono::DateTime, - end_date: chrono::DateTime, - ) -> DbResult>; - - /// Get payments within a date range for a specific company (admin only) - async fn admin_get_payments_by_date_range_and_company( - &self, - start_date: chrono::DateTime, - end_date: chrono::DateTime, - company_id: u64, - ) -> DbResult>; - - /// Get payments with company and currency info for time-series reporting + /// Get subscription payments with company and currency info for time-series reporting async fn admin_get_payments_with_company_info( &self, start_date: chrono::DateTime, end_date: chrono::DateTime, company_id: u64, currency: Option<&str>, - ) -> DbResult>; + ) -> DbResult>; /// Get referral cost usage report within date range for a specific company async fn admin_get_referral_usage_by_date_range( diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 4ebc226..5cc7854 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -182,6 +182,13 @@ pub trait LNVpsDbBase: Send + Sync { /// List all VM cost plans async fn list_cost_plans(&self) -> DbResult>; + /// List VM cost plans with database-level pagination. Returns (rows, total_count). + async fn list_cost_plans_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; + /// Insert a new VM cost plan async fn insert_cost_plan(&self, cost_plan: &VmCostPlan) -> DbResult; @@ -227,6 +234,33 @@ pub trait LNVpsDbBase: Send + Sync { /// Update a VM async fn update_vm(&self, vm: &Vm) -> DbResult<()>; + /// Get a VM by its subscription line item ID + async fn get_vm_by_line_item(&self, line_item_id: u64) -> DbResult; + + /// Get a VM by subscription ID — finds the VM(Renewal/Upgrade) line item for the subscription + async fn get_vm_by_subscription(&self, subscription_id: u64) -> DbResult; + + /// List subscription payments for a VM (via vm → line_item → subscription) + async fn list_vm_subscription_payments(&self, vm_id: u64) + -> DbResult>; + + /// List unpaid, non-expired subscription payments for a VM + async fn list_pending_vm_subscription_payments( + &self, + vm_id: u64, + ) -> DbResult>; + + /// List subscription payments for a VM with pagination + async fn list_vm_subscription_payments_paginated( + &self, + vm_id: u64, + limit: u64, + offset: u64, + ) -> DbResult>; + + /// Count total subscription payments for a VM (for pagination metadata) + async fn count_vm_subscription_payments(&self, vm_id: u64) -> DbResult; + /// List VM ip assignments async fn insert_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> DbResult; @@ -289,6 +323,17 @@ pub trait LNVpsDbBase: Send + Sync { /// Return the list of active custom pricing models for a given region async fn list_custom_pricing(&self, region_id: u64) -> DbResult>; + /// List all custom pricing models with optional filters and database-level pagination. + /// `region_id = None` returns all regions. `enabled = None` returns all. + /// Returns (rows, total_count). + async fn list_custom_pricing_paginated( + &self, + region_id: Option, + enabled: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; + /// Get a custom pricing model async fn get_custom_pricing(&self, id: u64) -> DbResult; @@ -376,15 +421,41 @@ pub trait LNVpsDbBase: Send + Sync { // Subscriptions async fn list_subscriptions(&self) -> DbResult>; async fn list_subscriptions_by_user(&self, user_id: u64) -> DbResult>; + + /// List all active subscriptions expiring within `within_seconds` seconds from now. + async fn list_expiring_subscriptions(&self, within_seconds: u64) + -> DbResult>; + + /// List all active subscriptions that have already expired. + async fn list_expired_subscriptions(&self) -> DbResult>; + + /// List subscriptions that need lifecycle management (active with expires set). + /// This filters out: inactive subscriptions, subscriptions never paid (expires IS NULL). + async fn list_lifecycle_subscriptions(&self) -> DbResult>; + + /// Deactivate a subscription: set `is_active = false`. + /// Also sets `ended_at = NOW()` on all linked `ip_range_subscription` rows. + async fn deactivate_subscription(&self, id: u64) -> DbResult<()>; + + /// List subscriptions with database-level pagination. `user_id = None` returns all users. + /// Returns (rows, total_count). + async fn list_subscriptions_paginated( + &self, + user_id: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; async fn list_subscriptions_active(&self, user_id: u64) -> DbResult>; async fn get_subscription(&self, id: u64) -> DbResult; async fn get_subscription_by_ext_id(&self, external_id: &str) -> DbResult; async fn insert_subscription(&self, subscription: &Subscription) -> DbResult; + /// Insert a subscription with its line items. + /// Returns `(subscription_id, line_item_ids)`. async fn insert_subscription_with_line_items( &self, subscription: &Subscription, line_items: Vec, - ) -> DbResult; + ) -> DbResult<(u64, Vec)>; async fn update_subscription(&self, subscription: &Subscription) -> DbResult<()>; async fn delete_subscription(&self, id: u64) -> DbResult<()>; async fn get_subscription_base_currency(&self, subscription_id: u64) -> DbResult; @@ -395,6 +466,10 @@ pub trait LNVpsDbBase: Send + Sync { subscription_id: u64, ) -> DbResult>; async fn get_subscription_line_item(&self, id: u64) -> DbResult; + + /// Get subscription directly from line item ID (avoids doing two lookups) + async fn get_subscription_by_line_item_id(&self, line_item_id: u64) -> DbResult; + async fn insert_subscription_line_item( &self, line_item: &SubscriptionLineItem, @@ -408,6 +483,15 @@ pub trait LNVpsDbBase: Send + Sync { &self, subscription_id: u64, ) -> DbResult>; + + /// List subscription payments with database-level pagination. Returns (rows, total_count). + async fn list_subscription_payments_paginated( + &self, + subscription_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; + async fn list_subscription_payments_by_user( &self, user_id: u64, @@ -428,6 +512,17 @@ pub trait LNVpsDbBase: Send + Sync { // Available IP Space async fn list_available_ip_space(&self) -> DbResult>; + + /// List available IP spaces with optional filters and database-level pagination. + /// Returns (rows, total_count). + async fn list_available_ip_space_paginated( + &self, + is_available: Option, + is_reserved: Option, + registry: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; async fn get_available_ip_space(&self, id: u64) -> DbResult; async fn get_available_ip_space_by_cidr(&self, cidr: &str) -> DbResult; async fn insert_available_ip_space(&self, space: &AvailableIpSpace) -> DbResult; @@ -439,6 +534,14 @@ pub trait LNVpsDbBase: Send + Sync { &self, available_ip_space_id: u64, ) -> DbResult>; + + /// List pricing for an IP space with database-level pagination. Returns (rows, total_count). + async fn list_ip_space_pricing_by_space_paginated( + &self, + available_ip_space_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; async fn get_ip_space_pricing(&self, id: u64) -> DbResult; async fn get_ip_space_pricing_by_prefix( &self, @@ -462,6 +565,17 @@ pub trait LNVpsDbBase: Send + Sync { &self, user_id: u64, ) -> DbResult>; + + /// List IP range subscriptions for a given space with optional filters and DB-level pagination. + /// Returns (rows, total_count). + async fn list_ip_range_subscriptions_by_space_paginated( + &self, + available_ip_space_id: u64, + user_id: Option, + is_active: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; async fn get_ip_range_subscription(&self, id: u64) -> DbResult; async fn get_ip_range_subscription_by_cidr(&self, cidr: &str) -> DbResult; async fn insert_ip_range_subscription( @@ -481,6 +595,13 @@ pub trait LNVpsDbBase: Send + Sync { /// List all payment method configurations async fn list_payment_method_configs(&self) -> DbResult>; + /// List payment method configurations with database-level pagination. Returns (rows, total_count). + async fn list_payment_method_configs_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; + /// List payment method configurations for a company async fn list_payment_method_configs_for_company( &self, @@ -538,11 +659,11 @@ pub trait LNVpsDbBase: Send + Sync { /// List all payout records for a referral async fn list_referral_payouts(&self, referral_id: u64) -> DbResult>; - /// List the first paid VM payment per VM that used this referral code. + /// List the first paid subscription payment per VM that used this referral code. /// This is the basis for computing earned amounts (per currency). async fn list_referral_usage(&self, code: &str) -> DbResult>; - /// Count VMs that used this referral code but have never made a paid payment. + /// Count VMs that used this referral code but have never made a paid subscription payment. async fn count_failed_referrals(&self, code: &str) -> DbResult; } diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index ad2d929..9fe0adb 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -1,6 +1,6 @@ use crate::comma_separated::CommaSeparated; use crate::encrypted_string::EncryptedString; -use anyhow::{Result, anyhow, bail}; +use anyhow::{anyhow, bail, Result}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Type}; @@ -73,6 +73,8 @@ pub enum VmHostKind { #[default] Proxmox = 0, LibVirt = 1, + + Dummy = u16::MAX, } impl Display for VmHostKind { @@ -80,6 +82,7 @@ impl Display for VmHostKind { match self { VmHostKind::Proxmox => write!(f, "proxmox"), VmHostKind::LibVirt => write!(f, "libvirt"), + VmHostKind::Dummy => write!(f, "dummy"), } } } @@ -730,7 +733,7 @@ pub enum NetworkAccessPolicy { #[derive(Clone, Copy, Debug, sqlx::Type, Serialize, Deserialize)] #[repr(u16)] -pub enum VmCostPlanIntervalType { +pub enum IntervalType { Day = 0, Month = 1, Year = 2, @@ -745,7 +748,7 @@ pub struct VmCostPlan { pub amount: u64, pub currency: String, pub interval_amount: u64, - pub interval_type: VmCostPlanIntervalType, + pub interval_type: IntervalType, } /// Offers. @@ -881,12 +884,10 @@ pub struct Vm { pub template_id: Option, /// Custom pricing specification used for this vm [VmCustomTemplate] pub custom_template_id: Option, + /// The subscription line item managing billing for this VM (mirrors ip_range_subscription pattern) + pub subscription_line_item_id: u64, /// Users ssh-key assigned to this VM pub ssh_key_id: u64, - /// When the VM was created - pub created: DateTime, - /// When the VM expires - pub expires: DateTime, /// The [VmHostDisk] this VM is on pub disk_id: u64, /// Network MAC address @@ -895,12 +896,47 @@ pub struct Vm { pub deleted: bool, /// Referral code (recorded during ordering) pub ref_code: Option, - /// Enable automatic renewal - pub auto_renewal_enabled: bool, /// Whether the VM is disabled by admin pub disabled: bool, } +/// Raw vm_payment row with external_data as a plain String (not decrypted). +/// Used by the data migration tool to copy rows without needing the encryption key. +#[derive(FromRow, Clone, Debug)] +pub struct VmPaymentRaw { + pub id: Vec, + pub vm_id: u64, + pub created: DateTime, + pub expires: DateTime, + pub amount: u64, + pub currency: String, + pub payment_method: PaymentMethod, + pub payment_type: PaymentType, + pub external_data: String, + pub external_id: Option, + pub is_paid: bool, + pub rate: f32, + pub time_value: u64, + pub tax: u64, + pub upgrade_params: Option, + pub processing_fee: u64, + pub paid_at: Option>, +} + +/// Minimal VM projection used by the data migration tool where +/// `subscription_line_item_id` may still be NULL for pre-migration rows. +#[derive(FromRow, Clone, Debug)] +pub struct VmForMigration { + pub id: u64, + pub user_id: u64, + pub template_id: Option, + pub custom_template_id: Option, + pub expires: DateTime, + pub auto_renewal_enabled: bool, + pub subscription_line_item_id: Option, + pub deleted: bool, +} + #[derive(FromRow, Clone, Debug, Default)] pub struct VmIpAssignment { /// Unique id of this assignment @@ -1488,6 +1524,8 @@ pub enum SubscriptionPaymentType { Purchase = 0, /// Recurring renewal payment Renewal = 1, + /// VM upgrade payment + Upgrade = 2, } impl Display for SubscriptionPaymentType { @@ -1495,11 +1533,12 @@ impl Display for SubscriptionPaymentType { match self { SubscriptionPaymentType::Purchase => write!(f, "Purchase"), SubscriptionPaymentType::Renewal => write!(f, "Renewal"), + SubscriptionPaymentType::Upgrade => write!(f, "Upgrade"), } } } -/// Subscription for a recurring service (always monthly billing) +/// Subscription for a recurring service #[derive(FromRow, Clone, Debug, Serialize, Deserialize)] pub struct Subscription { pub id: u64, @@ -1510,7 +1549,14 @@ pub struct Subscription { pub created: DateTime, pub expires: Option>, pub is_active: bool, + /// Whether the initial setup (purchase) payment has been confirmed. + /// Used to determine if setup fees apply on the next renewal invoice. + pub is_setup: bool, pub currency: String, + /// Number of intervals per billing cycle (e.g. 1 for "every 1 month") + pub interval_amount: u64, + /// Interval unit (Day, Month, Year) + pub interval_type: IntervalType, pub setup_fee: u64, pub auto_renewal_enabled: bool, pub external_id: Option, @@ -1523,6 +1569,7 @@ pub enum SubscriptionType { IpRange = 0, // IP range allocation/LIR services AsnSponsoring = 1, // ASN sponsoring services DnsHosting = 2, // DNS hosting services + Vps = 3, // VM (links to vm table via vm.subscription_line_item_id) } impl Display for SubscriptionType { @@ -1531,6 +1578,7 @@ impl Display for SubscriptionType { SubscriptionType::IpRange => write!(f, "IP Range"), SubscriptionType::AsnSponsoring => write!(f, "ASN Sponsoring"), SubscriptionType::DnsHosting => write!(f, "DNS Hosting"), + SubscriptionType::Vps => write!(f, "VPS"), } } } @@ -1540,6 +1588,7 @@ impl Display for SubscriptionType { pub struct SubscriptionLineItem { pub id: u64, pub subscription_id: u64, + /// Discriminant indicating which product table owns this line item pub subscription_type: SubscriptionType, pub name: String, pub description: Option, @@ -1564,13 +1613,17 @@ pub struct SubscriptionPayment { pub external_id: Option, pub is_paid: bool, pub rate: f32, + /// Number of seconds this payment adds to subscription expiry + pub time_value: Option, + /// JSON metadata (e.g. upgrade parameters) + pub metadata: Option, pub tax: u64, pub processing_fee: u64, /// Timestamp when the payment was completed pub paid_at: Option>, } -/// Subscription payment with company info (for admin views) +/// Subscription payment with company info (for admin views and time-series reporting) #[derive(FromRow, Clone, Debug, Serialize, Deserialize)] pub struct SubscriptionPaymentWithCompany { pub id: Vec, @@ -1586,11 +1639,26 @@ pub struct SubscriptionPaymentWithCompany { pub external_id: Option, pub is_paid: bool, pub rate: f32, + /// Number of seconds this payment adds to subscription expiry + pub time_value: Option, + /// JSON metadata (e.g. upgrade parameters) + pub metadata: Option, pub tax: u64, pub processing_fee: u64, /// Timestamp when the payment was completed pub paid_at: Option>, + // Company information + pub company_id: u64, + pub company_name: String, pub company_base_currency: String, + // VM information (NULL for non-VM subscriptions) + pub vm_id: Option, + // Host information + pub host_id: Option, + pub host_name: Option, + // Region information + pub region_id: Option, + pub region_name: Option, } /// Internet Registry - Regional Internet Registry diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index bae7f7a..8020011 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,10 +1,11 @@ use crate::{ - AccessPolicy, AvailableIpSpace, Company, DbError, DbResult, IpRange, IpRangeSubscription, - IpSpacePricing, LNVpsDbBase, PaymentMethod, PaymentMethodConfig, PaymentType, Referral, - ReferralCostUsage, ReferralPayout, RegionStats, Router, Subscription, SubscriptionLineItem, - SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, - VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, VmHost, VmHostDisk, - VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmPaymentWithCompany, VmTemplate, + AccessPolicy, AvailableIpSpace, Company, DbError, DbResult, IntervalType, IpRange, + IpRangeSubscription, IpSpacePricing, LNVpsDbBase, PaymentMethod, PaymentMethodConfig, + PaymentType, Referral, ReferralCostUsage, ReferralPayout, RegionStats, Router, Subscription, + SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, + Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmForMigration, + VmHistory, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, + VmPaymentRaw, VmTemplate, }; #[cfg(feature = "admin")] use crate::{AdminDb, AdminRole, AdminRoleAssignment, AdminVmHost}; @@ -26,9 +27,140 @@ impl LNVpsDbMysql { } pub async fn execute(&self, sql: &str) -> DbResult<()> { - self.db.execute(sql).await?; + let mut conn = self.db.acquire().await?; + conn.execute(sql).await?; Ok(()) } + + pub fn pool(&self) -> &MySqlPool { + &self.db + } + + /// List IDs of ALL VMs (including deleted) that have not yet been linked to a subscription + /// line item. Used by the data migration tool to avoid decoding nullable + /// subscription_line_item_id via the Vm struct (which requires it non-null). + pub async fn list_vm_ids_without_subscription(&self) -> DbResult> { + let rows = sqlx::query( + "SELECT id FROM vm \ + WHERE subscription_line_item_id IS NULL OR subscription_line_item_id = 0", + ) + .fetch_all(&self.db) + .await?; + Ok(rows.iter().map(|r| r.get::("id") as u64).collect()) + } + + /// Insert a subscription_payment row by copying a vm_payment row verbatim, + /// writing external_data as raw bytes (no encrypt/decrypt round-trip). + pub async fn insert_subscription_payment_raw( + &self, + vp: &VmPaymentRaw, + subscription_id: u64, + user_id: u64, + payment_type: u16, + time_value: Option, + metadata: Option<&str>, + ) -> DbResult<()> { + sqlx::query( + "INSERT INTO subscription_payment \ + (id, subscription_id, user_id, created, expires, amount, currency, \ + payment_method, payment_type, external_data, external_id, is_paid, rate, \ + time_value, metadata, tax, processing_fee, paid_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&vp.id) + .bind(subscription_id) + .bind(user_id) + .bind(vp.created) + .bind(vp.expires) + .bind(vp.amount) + .bind(&vp.currency) + .bind(vp.payment_method as u16) + .bind(payment_type) + .bind(&vp.external_data) // raw string — no encryption + .bind(&vp.external_id) + .bind(vp.is_paid) + .bind(vp.rate) + .bind(time_value) + .bind(metadata) + .bind(vp.tax) + .bind(vp.processing_fee) + .bind(vp.paid_at) + .execute(&self.db) + .await?; + Ok(()) + } + + /// List the binary ids of all subscription_payments for a subscription. + /// Used by the migration tool for idempotency checking without decrypting external_data. + pub async fn list_subscription_payment_ids_for_subscription( + &self, + subscription_id: u64, + ) -> DbResult>> { + let rows = sqlx::query("SELECT id FROM subscription_payment WHERE subscription_id = ?") + .bind(subscription_id) + .fetch_all(&self.db) + .await?; + Ok(rows.iter().map(|r| r.get::, _>("id")).collect()) + } + + /// List VM ids that have vm_payment rows not yet copied to subscription_payment. + /// Identifies by id: a vm_payment is considered copied if a subscription_payment + /// with the same binary id exists. + pub async fn list_vm_ids_with_uncopied_payments(&self) -> DbResult> { + let rows = sqlx::query( + "SELECT DISTINCT vp.vm_id FROM vm_payment vp \ + WHERE NOT EXISTS ( \ + SELECT 1 FROM subscription_payment sp WHERE sp.id = vp.id \ + )", + ) + .fetch_all(&self.db) + .await?; + Ok(rows + .iter() + .map(|r| r.get::("vm_id") as u64) + .collect()) + } + + /// List all vm_payment rows for a VM, with external_data as raw String (no decryption). + /// Used by the data migration tool to copy rows without needing the encryption key. + pub async fn list_vm_payments_for_migration(&self, vm_id: u64) -> DbResult> { + Ok(sqlx::query_as( + "SELECT id, vm_id, created, expires, amount, currency, payment_method, payment_type, \ + external_data, external_id, is_paid, rate, time_value, tax, upgrade_params, \ + processing_fee, paid_at FROM vm_payment WHERE vm_id = ? ORDER BY created ASC", + ) + .bind(vm_id) + .fetch_all(&self.db) + .await?) + } + + /// Set subscription_line_item_id on a VM by id. + /// Used by the data migration tool where full Vm round-trip is not possible. + pub async fn set_vm_subscription_line_item( + &self, + vm_id: u64, + subscription_line_item_id: u64, + ) -> DbResult<()> { + sqlx::query("UPDATE vm SET subscription_line_item_id = ? WHERE id = ?") + .bind(subscription_line_item_id) + .bind(vm_id) + .execute(&self.db) + .await?; + Ok(()) + } + + /// Fetch a VM row with subscription_line_item_id decoded as Option. + /// Used by the data migration tool where the column may still be NULL. + pub async fn get_vm_for_migration(&self, vm_id: u64) -> DbResult { + Ok(sqlx::query_as( + "SELECT id, user_id, template_id, custom_template_id, expires, \ + auto_renewal_enabled, subscription_line_item_id, deleted \ + FROM vm WHERE id = ?", + ) + .bind(vm_id) + .fetch_one(&self.db) + .await?) + } } #[async_trait] @@ -447,6 +579,23 @@ impl LNVpsDbBase for LNVpsDbMysql { ) } + async fn list_cost_plans_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM vm_cost_plan") + .fetch_one(&self.db) + .await?; + let rows = + sqlx::query_as("SELECT * FROM vm_cost_plan ORDER BY created DESC LIMIT ? OFFSET ?") + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + Ok((rows, total as u64)) + } + async fn insert_cost_plan(&self, cost_plan: &VmCostPlan) -> DbResult { Ok(sqlx::query("insert into vm_cost_plan(name,created,amount,currency,interval_amount,interval_type) values(?,?,?,?,?,?) returning id") .bind(&cost_plan.name) @@ -548,11 +697,15 @@ impl LNVpsDbBase for LNVpsDbMysql { } async fn list_expired_vms(&self) -> DbResult> { - Ok( - sqlx::query_as("select * from vm where expires > current_timestamp() and deleted = 0") - .fetch_all(&self.db) - .await?, + // Expired VMs are those whose subscription has expired + Ok(sqlx::query_as( + "SELECT v.* FROM vm v \ + INNER JOIN subscription_line_item sli ON sli.id = v.subscription_line_item_id \ + INNER JOIN subscription s ON s.id = sli.subscription_id \ + WHERE v.deleted = 0 AND s.expires < NOW()", ) + .fetch_all(&self.db) + .await?) } async fn list_user_vms(&self, id: u64) -> DbResult> { @@ -572,19 +725,17 @@ impl LNVpsDbBase for LNVpsDbMysql { } async fn insert_vm(&self, vm: &Vm) -> DbResult { - Ok(sqlx::query("insert into vm(host_id,user_id,image_id,template_id,custom_template_id,ssh_key_id,created,expires,disk_id,mac_address,ref_code,auto_renewal_enabled) values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) returning id") + Ok(sqlx::query("insert into vm(host_id,user_id,image_id,template_id,custom_template_id,subscription_line_item_id,ssh_key_id,disk_id,mac_address,ref_code) values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) returning id") .bind(vm.host_id) .bind(vm.user_id) .bind(vm.image_id) .bind(vm.template_id) .bind(vm.custom_template_id) + .bind(vm.subscription_line_item_id) .bind(vm.ssh_key_id) - .bind(vm.created) - .bind(vm.expires) .bind(vm.disk_id) .bind(&vm.mac_address) .bind(&vm.ref_code) - .bind(vm.auto_renewal_enabled) .fetch_one(&self.db) .await? .try_get(0)?) @@ -600,16 +751,15 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn update_vm(&self, vm: &Vm) -> DbResult<()> { sqlx::query( - "update vm set image_id=?,template_id=?,custom_template_id=?,ssh_key_id=?,expires=?,disk_id=?,mac_address=?,auto_renewal_enabled=?,disabled=? where id=?", + "update vm set image_id=?,template_id=?,custom_template_id=?,subscription_line_item_id=?,ssh_key_id=?,disk_id=?,mac_address=?,disabled=? where id=?", ) .bind(vm.image_id) .bind(vm.template_id) .bind(vm.custom_template_id) + .bind(vm.subscription_line_item_id) .bind(vm.ssh_key_id) - .bind(vm.expires) .bind(vm.disk_id) .bind(&vm.mac_address) - .bind(vm.auto_renewal_enabled) .bind(vm.disabled) .bind(vm.id) .execute(&self.db) @@ -617,6 +767,94 @@ impl LNVpsDbBase for LNVpsDbMysql { Ok(()) } + async fn get_vm_by_line_item(&self, line_item_id: u64) -> DbResult { + Ok( + sqlx::query_as("SELECT * FROM vm WHERE subscription_line_item_id = ? AND deleted = 0") + .bind(line_item_id) + .fetch_one(&self.db) + .await?, + ) + } + + async fn get_vm_by_subscription(&self, subscription_id: u64) -> DbResult { + Ok(sqlx::query_as( + "SELECT v.* FROM vm v \ + INNER JOIN subscription_line_item sli ON sli.id = v.subscription_line_item_id \ + WHERE sli.subscription_id = ? \ + AND sli.subscription_type = 3 \ + LIMIT 1", + ) + .bind(subscription_id) + .fetch_one(&self.db) + .await?) + } + + async fn list_vm_subscription_payments( + &self, + vm_id: u64, + ) -> DbResult> { + Ok(sqlx::query_as( + "SELECT sp.* FROM subscription_payment sp \ + INNER JOIN subscription_line_item sli ON sli.subscription_id = sp.subscription_id \ + INNER JOIN vm v ON v.subscription_line_item_id = sli.id \ + WHERE v.id = ? \ + ORDER BY sp.created DESC", + ) + .bind(vm_id) + .fetch_all(&self.db) + .await?) + } + + async fn list_pending_vm_subscription_payments( + &self, + vm_id: u64, + ) -> DbResult> { + Ok(sqlx::query_as( + "SELECT sp.* FROM subscription_payment sp \ + INNER JOIN subscription_line_item sli ON sli.subscription_id = sp.subscription_id \ + INNER JOIN vm v ON v.subscription_line_item_id = sli.id \ + WHERE v.id = ? AND sp.is_paid = 0 AND sp.expires > NOW() \ + ORDER BY sp.created DESC", + ) + .bind(vm_id) + .fetch_all(&self.db) + .await?) + } + + async fn list_vm_subscription_payments_paginated( + &self, + vm_id: u64, + limit: u64, + offset: u64, + ) -> DbResult> { + Ok(sqlx::query_as( + "SELECT sp.* FROM subscription_payment sp \ + INNER JOIN subscription_line_item sli ON sli.subscription_id = sp.subscription_id \ + INNER JOIN vm v ON v.subscription_line_item_id = sli.id \ + WHERE v.id = ? \ + ORDER BY sp.created DESC \ + LIMIT ? OFFSET ?", + ) + .bind(vm_id) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?) + } + + async fn count_vm_subscription_payments(&self, vm_id: u64) -> DbResult { + let row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM subscription_payment sp \ + INNER JOIN subscription_line_item sli ON sli.subscription_id = sp.subscription_id \ + INNER JOIN vm v ON v.subscription_line_item_id = sli.id \ + WHERE v.id = ?", + ) + .bind(vm_id) + .fetch_one(&self.db) + .await?; + Ok(row.0 as u64) + } + async fn insert_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> DbResult { Ok(sqlx::query( "insert into vm_ip_assignment(vm_id,ip_range_id,ip,arp_ref,dns_forward,dns_forward_ref,dns_reverse,dns_reverse_ref) values(?,?,?,?,?,?,?,?) returning id", @@ -799,12 +1037,6 @@ impl LNVpsDbBase for LNVpsDbMysql { .execute(&mut *tx) .await?; - sqlx::query("update vm set expires = TIMESTAMPADD(SECOND, ?, expires) where id = ?") - .bind(vm_payment.time_value) - .bind(vm_payment.vm_id) - .execute(&mut *tx) - .await?; - tx.commit().await?; Ok(()) } @@ -826,6 +1058,57 @@ impl LNVpsDbBase for LNVpsDbMysql { ) } + async fn list_custom_pricing_paginated( + &self, + region_id: Option, + enabled: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + // Build WHERE clauses dynamically + let mut conditions = Vec::new(); + if region_id.is_some() { + conditions.push("region_id = ?"); + } + if enabled.is_some() { + conditions.push("enabled = ?"); + } + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM vm_custom_pricing {}", where_clause); + let data_sql = format!( + "SELECT * FROM vm_custom_pricing {} ORDER BY id DESC LIMIT ? OFFSET ?", + where_clause + ); + + // Build and execute count query + let mut count_q = sqlx::query_scalar(&count_sql); + if let Some(r) = region_id { + count_q = count_q.bind(r); + } + if let Some(e) = enabled { + count_q = count_q.bind(e); + } + let total: i64 = count_q.fetch_one(&self.db).await?; + + // Build and execute data query + let mut data_q = sqlx::query_as(&data_sql); + if let Some(r) = region_id { + data_q = data_q.bind(r); + } + if let Some(e) = enabled { + data_q = data_q.bind(e); + } + data_q = data_q.bind(limit).bind(offset); + let rows = data_q.fetch_all(&self.db).await?; + + Ok((rows, total as u64)) + } + async fn get_custom_pricing(&self, id: u64) -> DbResult { Ok(sqlx::query_as("select * from vm_custom_pricing where id=?") .bind(id) @@ -1110,20 +1393,58 @@ impl LNVpsDbBase for LNVpsDbMysql { // Subscriptions async fn list_subscriptions(&self) -> DbResult> { - Ok(sqlx::query_as("SELECT * FROM subscription") - .fetch_all(&self.db) - .await?) + Ok( + sqlx::query_as("SELECT * FROM subscription ORDER BY id DESC") + .fetch_all(&self.db) + .await?, + ) } async fn list_subscriptions_by_user(&self, user_id: u64) -> DbResult> { Ok( - sqlx::query_as("SELECT * FROM subscription WHERE user_id = ?") + sqlx::query_as("SELECT * FROM subscription WHERE user_id = ? ORDER BY id DESC") .bind(user_id) .fetch_all(&self.db) .await?, ) } + async fn list_subscriptions_paginated( + &self, + user_id: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let (total, rows) = if let Some(uid) = user_id { + let total: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM subscription WHERE user_id = ?") + .bind(uid) + .fetch_one(&self.db) + .await?; + let rows = sqlx::query_as( + "SELECT * FROM subscription WHERE user_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", + ) + .bind(uid) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + (total, rows) + } else { + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM subscription") + .fetch_one(&self.db) + .await?; + let rows = + sqlx::query_as("SELECT * FROM subscription ORDER BY id DESC LIMIT ? OFFSET ?") + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + (total, rows) + }; + Ok((rows, total as u64)) + } + async fn list_subscriptions_active(&self, user_id: u64) -> DbResult> { Ok( sqlx::query_as("SELECT * FROM subscription WHERE user_id = ? AND is_active = 1") @@ -1133,6 +1454,57 @@ impl LNVpsDbBase for LNVpsDbMysql { ) } + async fn list_expiring_subscriptions( + &self, + within_seconds: u64, + ) -> DbResult> { + Ok(sqlx::query_as( + "SELECT * FROM subscription WHERE is_active = 1 AND expires IS NOT NULL \ + AND expires < DATE_ADD(NOW(), INTERVAL ? SECOND) AND expires > NOW()", + ) + .bind(within_seconds) + .fetch_all(&self.db) + .await?) + } + + async fn list_expired_subscriptions(&self) -> DbResult> { + Ok(sqlx::query_as( + "SELECT * FROM subscription WHERE is_active = 1 AND expires IS NOT NULL \ + AND expires < NOW()", + ) + .fetch_all(&self.db) + .await?) + } + + async fn list_lifecycle_subscriptions(&self) -> DbResult> { + Ok( + sqlx::query_as( + "SELECT * FROM subscription WHERE is_active = 1 AND expires IS NOT NULL", + ) + .fetch_all(&self.db) + .await?, + ) + } + + async fn deactivate_subscription(&self, id: u64) -> DbResult<()> { + let mut tx = self.db.begin().await?; + sqlx::query("UPDATE subscription SET is_active = 0 WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE ip_range_subscription ips \ + INNER JOIN subscription_line_item sli ON ips.subscription_line_item_id = sli.id \ + SET ips.is_active = 0, ips.ended_at = NOW() \ + WHERE sli.subscription_id = ? AND ips.ended_at IS NULL", + ) + .bind(id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + async fn get_subscription(&self, id: u64) -> DbResult { Ok(sqlx::query_as("SELECT * FROM subscription WHERE id = ?") .bind(id) @@ -1151,7 +1523,7 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn insert_subscription(&self, subscription: &Subscription) -> DbResult { let res = sqlx::query( - "INSERT INTO subscription (user_id, company_id, name, description, created, expires, is_active, currency, setup_fee, auto_renewal_enabled, external_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO subscription (user_id, company_id, name, description, created, expires, is_active, is_setup, currency, interval_amount, interval_type, setup_fee, auto_renewal_enabled, external_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ) .bind(subscription.user_id) .bind(subscription.company_id) @@ -1160,7 +1532,10 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(subscription.created) .bind(subscription.expires) .bind(subscription.is_active) + .bind(subscription.is_setup) .bind(&subscription.currency) + .bind(subscription.interval_amount) + .bind(subscription.interval_type) .bind(subscription.setup_fee) .bind(subscription.auto_renewal_enabled) .bind(&subscription.external_id) @@ -1174,12 +1549,12 @@ impl LNVpsDbBase for LNVpsDbMysql { &self, subscription: &Subscription, mut line_items: Vec, - ) -> DbResult { + ) -> DbResult<(u64, Vec)> { let mut tx = self.db.begin().await?; // Insert subscription let res = sqlx::query( - "INSERT INTO subscription (user_id, company_id, name, description, created, expires, is_active, currency, setup_fee, auto_renewal_enabled, external_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO subscription (user_id, company_id, name, description, created, expires, is_active, is_setup, currency, interval_amount, interval_type, setup_fee, auto_renewal_enabled, external_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ) .bind(subscription.user_id) .bind(subscription.company_id) @@ -1188,7 +1563,10 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(subscription.created) .bind(subscription.expires) .bind(subscription.is_active) + .bind(subscription.is_setup) .bind(&subscription.currency) + .bind(subscription.interval_amount) + .bind(subscription.interval_type) .bind(subscription.setup_fee) .bind(subscription.auto_renewal_enabled) .bind(&subscription.external_id) @@ -1196,12 +1574,13 @@ impl LNVpsDbBase for LNVpsDbMysql { .await?; let subscription_id = res.last_insert_id(); + let mut line_item_ids = Vec::with_capacity(line_items.len()); // Insert all line items with the subscription_id for line_item in &mut line_items { line_item.subscription_id = subscription_id; - sqlx::query( + let li_res = sqlx::query( "INSERT INTO subscription_line_item (subscription_id, subscription_type, name, description, amount, setup_amount, configuration) VALUES (?, ?, ?, ?, ?, ?, ?)" ) .bind(line_item.subscription_id) @@ -1213,15 +1592,17 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(&line_item.configuration) .execute(&mut *tx) .await?; + + line_item_ids.push(li_res.last_insert_id()); } tx.commit().await?; - Ok(subscription_id) + Ok((subscription_id, line_item_ids)) } async fn update_subscription(&self, subscription: &Subscription) -> DbResult<()> { sqlx::query( - "UPDATE subscription SET user_id = ?, company_id = ?, name = ?, description = ?, expires = ?, is_active = ?, currency = ?, setup_fee = ?, auto_renewal_enabled = ?, external_id = ? WHERE id = ?" + "UPDATE subscription SET user_id = ?, company_id = ?, name = ?, description = ?, expires = ?, is_active = ?, is_setup = ?, currency = ?, interval_amount = ?, interval_type = ?, setup_fee = ?, auto_renewal_enabled = ?, external_id = ? WHERE id = ?" ) .bind(subscription.user_id) .bind(subscription.company_id) @@ -1229,7 +1610,10 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(&subscription.description) .bind(subscription.expires) .bind(subscription.is_active) + .bind(subscription.is_setup) .bind(&subscription.currency) + .bind(subscription.interval_amount) + .bind(subscription.interval_type) .bind(subscription.setup_fee) .bind(subscription.auto_renewal_enabled) .bind(&subscription.external_id) @@ -1286,6 +1670,17 @@ impl LNVpsDbBase for LNVpsDbMysql { ) } + async fn get_subscription_by_line_item_id(&self, line_item_id: u64) -> DbResult { + Ok(sqlx::query_as( + "SELECT s.* FROM subscription s + INNER JOIN subscription_line_item sli ON sli.subscription_id = s.id + WHERE sli.id = ?" + ) + .bind(line_item_id) + .fetch_one(&self.db) + .await?) + } + async fn insert_subscription_line_item( &self, line_item: &SubscriptionLineItem, @@ -1341,24 +1736,47 @@ impl LNVpsDbBase for LNVpsDbMysql { &self, subscription_id: u64, ) -> DbResult> { - Ok( - sqlx::query_as("SELECT * FROM subscription_payment WHERE subscription_id = ?") - .bind(subscription_id) - .fetch_all(&self.db) - .await?, + Ok(sqlx::query_as( + "SELECT * FROM subscription_payment WHERE subscription_id = ? ORDER BY created DESC", + ) + .bind(subscription_id) + .fetch_all(&self.db) + .await?) + } + + async fn list_subscription_payments_paginated( + &self, + subscription_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM subscription_payment WHERE subscription_id = ?", + ) + .bind(subscription_id) + .fetch_one(&self.db) + .await?; + let rows = sqlx::query_as( + "SELECT * FROM subscription_payment WHERE subscription_id = ? ORDER BY created DESC LIMIT ? OFFSET ?", ) + .bind(subscription_id) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + Ok((rows, total as u64)) } async fn list_subscription_payments_by_user( &self, user_id: u64, ) -> DbResult> { - Ok( - sqlx::query_as("SELECT * FROM subscription_payment WHERE user_id = ?") - .bind(user_id) - .fetch_all(&self.db) - .await?, + Ok(sqlx::query_as( + "SELECT * FROM subscription_payment WHERE user_id = ? ORDER BY created DESC", ) + .bind(user_id) + .fetch_all(&self.db) + .await?) } async fn get_subscription_payment(&self, id: &Vec) -> DbResult { @@ -1387,11 +1805,21 @@ impl LNVpsDbBase for LNVpsDbMysql { id: &Vec, ) -> DbResult { Ok(sqlx::query_as( - "SELECT sp.*, c.base_currency as company_base_currency + "SELECT sp.*, + c.id as company_id, c.name as company_name, c.base_currency as company_base_currency, + v.id as vm_id, + vh.id as host_id, vh.name as host_name, + vhr.id as region_id, vhr.name as region_name FROM subscription_payment sp JOIN subscription s ON sp.subscription_id = s.id - JOIN users u ON s.user_id = u.id - JOIN company c ON u.id = c.id + LEFT JOIN subscription_line_item sli ON sli.subscription_id = s.id + AND sli.subscription_type = 3 + LEFT JOIN vm v ON v.subscription_line_item_id = sli.id + LEFT JOIN vm_host vh ON v.host_id = vh.id + LEFT JOIN vm_host_region vhr ON vh.region_id = vhr.id + JOIN company c ON (CASE WHEN vhr.company_id IS NOT NULL + THEN vhr.company_id + ELSE s.user_id END) = c.id WHERE sp.id = ?", ) .bind(id) @@ -1401,7 +1829,7 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn insert_subscription_payment(&self, payment: &SubscriptionPayment) -> DbResult<()> { sqlx::query( - "INSERT INTO subscription_payment (id, subscription_id, user_id, created, expires, amount, currency, payment_method, payment_type, external_data, external_id, is_paid, rate, tax, paid_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO subscription_payment (id, subscription_id, user_id, created, expires, amount, currency, payment_method, payment_type, external_data, external_id, is_paid, rate, tax, processing_fee, time_value, metadata, paid_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ) .bind(&payment.id) .bind(payment.subscription_id) @@ -1417,6 +1845,9 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(payment.is_paid) .bind(payment.rate) .bind(payment.tax) + .bind(payment.processing_fee) + .bind(payment.time_value) + .bind(&payment.metadata) .bind(payment.paid_at) .execute(&self.db) .await?; @@ -1426,7 +1857,7 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn update_subscription_payment(&self, payment: &SubscriptionPayment) -> DbResult<()> { sqlx::query( - "UPDATE subscription_payment SET subscription_id = ?, user_id = ?, created = ?, expires = ?, amount = ?, currency = ?, payment_method = ?, payment_type = ?, external_data = ?, external_id = ?, is_paid = ?, rate = ?, tax = ? WHERE id = ?" + "UPDATE subscription_payment SET subscription_id = ?, user_id = ?, created = ?, expires = ?, amount = ?, currency = ?, payment_method = ?, payment_type = ?, external_data = ?, external_id = ?, is_paid = ?, rate = ?, tax = ?, processing_fee = ?, time_value = ?, metadata = ? WHERE id = ?" ) .bind(payment.subscription_id) .bind(payment.user_id) @@ -1441,6 +1872,9 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(payment.is_paid) .bind(payment.rate) .bind(payment.tax) + .bind(payment.processing_fee) + .bind(payment.time_value) + .bind(&payment.metadata) .bind(&payment.id) .execute(&self.db) .await?; @@ -1457,14 +1891,38 @@ impl LNVpsDbBase for LNVpsDbMysql { .execute(tx.as_mut()) .await?; - // Subscriptions are always monthly - extend by 30 days and activate - sqlx::query( - "UPDATE subscription SET expires = DATE_ADD(GREATEST(COALESCE(expires, NOW()), NOW()), INTERVAL 30 DAY), is_active = 1 WHERE id = ?" - ) - .bind(payment.subscription_id) - .execute(&self.db) - .await?; + if let Some(time_value) = payment.time_value { + // Extend subscription.expires by explicit time_value seconds + sqlx::query( + "UPDATE subscription SET expires = DATE_ADD(GREATEST(COALESCE(expires, NOW()), NOW()), INTERVAL ? SECOND), is_active = 1, is_setup = 1 WHERE id = ?", + ) + .bind(time_value) + .bind(payment.subscription_id) + .execute(tx.as_mut()) + .await?; + } else { + // Regular subscription path: read interval from the subscription itself + let sub: Subscription = sqlx::query_as("SELECT * FROM subscription WHERE id = ?") + .bind(payment.subscription_id) + .fetch_one(tx.as_mut()) + .await?; + let interval_sql = match sub.interval_type { + IntervalType::Day => "DAY", + IntervalType::Month => "MONTH", + IntervalType::Year => "YEAR", + }; + let sql = format!( + "UPDATE subscription SET expires = DATE_ADD(GREATEST(COALESCE(expires, NOW()), NOW()), INTERVAL ? {}), is_active = 1, is_setup = 1 WHERE id = ?", + interval_sql + ); + sqlx::query(&sql) + .bind(sub.interval_amount) + .bind(payment.subscription_id) + .execute(tx.as_mut()) + .await?; + } + tx.commit().await?; Ok(()) } @@ -1485,6 +1943,64 @@ impl LNVpsDbBase for LNVpsDbMysql { ) } + async fn list_available_ip_space_paginated( + &self, + is_available: Option, + is_reserved: Option, + registry: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let mut conditions: Vec<&str> = Vec::new(); + if is_available.is_some() { + conditions.push("is_available = ?"); + } + if is_reserved.is_some() { + conditions.push("is_reserved = ?"); + } + if registry.is_some() { + conditions.push("registry = ?"); + } + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + let count_sql = format!("SELECT COUNT(*) FROM available_ip_space {}", where_clause); + let data_sql = format!( + "SELECT * FROM available_ip_space {} ORDER BY created DESC LIMIT ? OFFSET ?", + where_clause + ); + + let mut count_q = sqlx::query_scalar(&count_sql); + if let Some(v) = is_available { + count_q = count_q.bind(v); + } + if let Some(v) = is_reserved { + count_q = count_q.bind(v); + } + if let Some(v) = registry { + count_q = count_q.bind(v); + } + let total: i64 = count_q.fetch_one(&self.db).await?; + + let mut data_q = sqlx::query_as(&data_sql); + if let Some(v) = is_available { + data_q = data_q.bind(v); + } + if let Some(v) = is_reserved { + data_q = data_q.bind(v); + } + if let Some(v) = registry { + data_q = data_q.bind(v); + } + data_q = data_q.bind(limit).bind(offset); + let rows = data_q.fetch_all(&self.db).await?; + + Ok((rows, total as u64)) + } + async fn get_available_ip_space(&self, id: u64) -> DbResult { Ok( sqlx::query_as("SELECT * FROM available_ip_space WHERE id = ?") @@ -1562,6 +2078,29 @@ impl LNVpsDbBase for LNVpsDbMysql { ) } + async fn list_ip_space_pricing_by_space_paginated( + &self, + available_ip_space_id: u64, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ip_space_pricing WHERE available_ip_space_id = ?", + ) + .bind(available_ip_space_id) + .fetch_one(&self.db) + .await?; + let rows = sqlx::query_as( + "SELECT * FROM ip_space_pricing WHERE available_ip_space_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", + ) + .bind(available_ip_space_id) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + Ok((rows, total as u64)) + } + async fn get_ip_space_pricing(&self, id: u64) -> DbResult { Ok( sqlx::query_as("SELECT * FROM ip_space_pricing WHERE id = ?") @@ -1666,6 +2205,52 @@ impl LNVpsDbBase for LNVpsDbMysql { .await?) } + async fn list_ip_range_subscriptions_by_space_paginated( + &self, + available_ip_space_id: u64, + user_id: Option, + is_active: Option, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let mut extra = String::from("AND ips.available_ip_space_id = ?"); + if user_id.is_some() { + extra.push_str(" AND s.user_id = ?"); + } + if is_active.is_some() { + extra.push_str(" AND ips.is_active = ?"); + } + + let base = "SELECT ips.* FROM ip_range_subscription ips \ + INNER JOIN subscription_line_item sli ON ips.subscription_line_item_id = sli.id \ + INNER JOIN subscription s ON sli.subscription_id = s.id \ + WHERE 1=1"; + + let count_sql = format!("{} {}", base, extra); + let data_sql = format!("{} {} ORDER BY ips.id DESC LIMIT ? OFFSET ?", base, extra); + + let mut count_q = sqlx::query_scalar(&count_sql).bind(available_ip_space_id); + if let Some(u) = user_id { + count_q = count_q.bind(u); + } + if let Some(a) = is_active { + count_q = count_q.bind(a); + } + let total: i64 = count_q.fetch_one(&self.db).await?; + + let mut data_q = sqlx::query_as(&data_sql).bind(available_ip_space_id); + if let Some(u) = user_id { + data_q = data_q.bind(u); + } + if let Some(a) = is_active { + data_q = data_q.bind(a); + } + data_q = data_q.bind(limit).bind(offset); + let rows = data_q.fetch_all(&self.db).await?; + + Ok((rows, total as u64)) + } + async fn get_ip_range_subscription(&self, id: u64) -> DbResult { Ok( sqlx::query_as("SELECT * FROM ip_range_subscription WHERE id = ?") @@ -1746,6 +2331,24 @@ impl LNVpsDbBase for LNVpsDbMysql { .await?) } + async fn list_payment_method_configs_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM payment_method_config") + .fetch_one(&self.db) + .await?; + let rows = sqlx::query_as( + "SELECT * FROM payment_method_config ORDER BY company_id, payment_method, name LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + Ok((rows, total as u64)) + } + async fn list_payment_method_configs_for_company( &self, company_id: u64, @@ -1926,23 +2529,26 @@ impl LNVpsDbBase for LNVpsDbMysql { Ok(sqlx::query_as( "SELECT v.id as vm_id, v.ref_code, - vp.created, - vp.amount, - vp.currency, - vp.rate, + sp.created, + sp.amount, + sp.currency, + sp.rate, c.base_currency FROM vm v JOIN ( - SELECT vm_id, currency, amount, created, rate, - ROW_NUMBER() OVER (PARTITION BY vm_id ORDER BY created ASC) AS rn - FROM vm_payment - WHERE is_paid = 1 - ) vp ON v.id = vp.vm_id AND vp.rn = 1 + SELECT v2.id as vm_id, sp2.currency, sp2.amount, sp2.created, sp2.rate, + ROW_NUMBER() OVER (PARTITION BY v2.id ORDER BY sp2.created ASC) AS rn + FROM subscription_payment sp2 + JOIN subscription_line_item sli2 ON sli2.subscription_id = sp2.subscription_id + AND sli2.subscription_type = 3 + JOIN vm v2 ON v2.subscription_line_item_id = sli2.id + WHERE sp2.is_paid = 1 + ) sp ON v.id = sp.vm_id AND sp.rn = 1 JOIN vm_host vh ON v.host_id = vh.id JOIN vm_host_region vhr ON vh.region_id = vhr.id JOIN company c ON vhr.company_id = c.id WHERE v.ref_code = ? - ORDER BY vp.created DESC", + ORDER BY sp.created DESC", ) .bind(code) .fetch_all(&self.db) @@ -1954,7 +2560,11 @@ impl LNVpsDbBase for LNVpsDbMysql { "SELECT COUNT(*) FROM vm v WHERE v.ref_code = ? AND NOT EXISTS ( - SELECT 1 FROM vm_payment vp WHERE vp.vm_id = v.id AND vp.is_paid = 1 + SELECT 1 + FROM subscription_payment sp + JOIN subscription_line_item sli ON sli.subscription_id = sp.subscription_id + AND sli.subscription_type = 3 + WHERE v.subscription_line_item_id = sli.id AND sp.is_paid = 1 )", ) .bind(code) @@ -2283,6 +2893,24 @@ impl AdminDb for LNVpsDbMysql { Ok(roles) } + async fn list_roles_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM admin_roles") + .fetch_one(&self.db) + .await?; + let rows = sqlx::query_as( + "SELECT * FROM admin_roles ORDER BY is_system_role DESC, name ASC LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + Ok((rows, total as u64)) + } + async fn update_role(&self, role: &AdminRole) -> DbResult<()> { let query = r#" UPDATE admin_roles @@ -3201,76 +3829,46 @@ impl AdminDb for LNVpsDbMysql { Ok(count as u64) } - async fn admin_get_payments_by_date_range( - &self, - start_date: chrono::DateTime, - end_date: chrono::DateTime, - ) -> DbResult> { - Ok(sqlx::query_as( - "SELECT * FROM vm_payment WHERE created >= ? AND created < ? AND is_paid = true ORDER BY created", - ) - .bind(start_date) - .bind(end_date) - .fetch_all(&self.db) - .await?) - } - - async fn admin_get_payments_by_date_range_and_company( - &self, - start_date: chrono::DateTime, - end_date: chrono::DateTime, - company_id: u64, - ) -> DbResult> { - Ok(sqlx::query_as( - "SELECT vp.* FROM vm_payment vp - JOIN vm v ON vp.vm_id = v.id - JOIN vm_host vh ON v.host_id = vh.id - JOIN vm_host_region vhr ON vh.region_id = vhr.id - WHERE vp.created >= ? AND vp.created < ? AND vp.is_paid = true AND vhr.company_id = ? - ORDER BY vp.created", - ) - .bind(start_date) - .bind(end_date) - .bind(company_id) - .fetch_all(&self.db) - .await?) - } - async fn admin_get_payments_with_company_info( &self, start_date: chrono::DateTime, end_date: chrono::DateTime, company_id: u64, currency: Option<&str>, - ) -> DbResult> { + ) -> DbResult> { let mut query = QueryBuilder::new( - "SELECT vp.*, + "SELECT sp.*, c.id as company_id, c.name as company_name, c.base_currency as company_base_currency, - v.user_id, + v.id as vm_id, vh.id as host_id, vh.name as host_name, vhr.id as region_id, vhr.name as region_name - FROM vm_payment vp - JOIN vm v ON vp.vm_id = v.id - JOIN vm_host vh ON v.host_id = vh.id - JOIN vm_host_region vhr ON vh.region_id = vhr.id - JOIN company c ON vhr.company_id = c.id - WHERE vp.created >= ", + FROM subscription_payment sp + JOIN subscription s ON sp.subscription_id = s.id + LEFT JOIN subscription_line_item sli ON sli.subscription_id = s.id + AND sli.subscription_type = 3 + LEFT JOIN vm v ON v.subscription_line_item_id = sli.id + LEFT JOIN vm_host vh ON v.host_id = vh.id + LEFT JOIN vm_host_region vhr ON vh.region_id = vhr.id + JOIN company c ON (CASE WHEN vhr.company_id IS NOT NULL + THEN vhr.company_id + ELSE s.user_id END) = c.id + WHERE sp.created >= ", ); query.push_bind(start_date); - query.push(" AND vp.created < "); + query.push(" AND sp.created < "); query.push_bind(end_date); - query.push(" AND vp.is_paid = true AND c.id = "); + query.push(" AND sp.is_paid = true AND c.id = "); query.push_bind(company_id); if let Some(currency) = currency { - query.push(" AND vp.currency = "); + query.push(" AND sp.currency = "); query.push_bind(currency); } - query.push(" ORDER BY vp.created"); + query.push(" ORDER BY sp.created"); Ok(query - .build_query_as::() + .build_query_as::() .fetch_all(&self.db) .await?) } @@ -3284,31 +3882,34 @@ impl AdminDb for LNVpsDbMysql { ) -> DbResult> { let mut query = "SELECT v.id as vm_id, v.ref_code, - vp.created, - vp.amount, - vp.currency, - vp.rate, + sp.created, + sp.amount, + sp.currency, + sp.rate, c.base_currency FROM vm v JOIN ( - SELECT vm_id, currency, amount, created, rate, - ROW_NUMBER() OVER (PARTITION BY vm_id ORDER BY created ASC) as rn - FROM vm_payment - WHERE is_paid = 1 - ) vp ON v.id = vp.vm_id AND vp.rn = 1 + SELECT v2.id as vm_id, sp2.currency, sp2.amount, sp2.created, sp2.rate, + ROW_NUMBER() OVER (PARTITION BY v2.id ORDER BY sp2.created ASC) as rn + FROM subscription_payment sp2 + JOIN subscription_line_item sli2 ON sli2.subscription_id = sp2.subscription_id + AND sli2.subscription_type = 3 + JOIN vm v2 ON v2.subscription_line_item_id = sli2.id + WHERE sp2.is_paid = 1 + ) sp ON v.id = sp.vm_id AND sp.rn = 1 JOIN vm_host vh ON v.host_id = vh.id JOIN vm_host_region vhr ON vh.region_id = vhr.id JOIN company c ON vhr.company_id = c.id - WHERE v.ref_code IS NOT NULL - AND vp.created >= ? - AND vp.created <= ? + WHERE v.ref_code IS NOT NULL + AND sp.created >= ? + AND sp.created <= ? AND c.id = ?".to_string(); if ref_code.is_some() { query.push_str(" AND v.ref_code = ?"); } - query.push_str(" ORDER BY vp.created DESC"); + query.push_str(" ORDER BY sp.created DESC"); let mut db_query = sqlx::query_as(&query) .bind(start_date) diff --git a/lnvps_e2e/Cargo.toml b/lnvps_e2e/Cargo.toml index 043aa8e..bf9d525 100644 --- a/lnvps_e2e/Cargo.toml +++ b/lnvps_e2e/Cargo.toml @@ -8,7 +8,7 @@ name = "lnvps_e2e" path = "src/lib.rs" [dependencies] -tokio.workspace = true +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "process", "time"] } reqwest.workspace = true serde.workspace = true serde_json.workspace = true @@ -18,3 +18,4 @@ base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } sqlx = { version = "0.8", default-features = false, features = ["mysql", "runtime-tokio", "macros"] } hex = "0.4" +redis = { version = "1", features = ["tokio-comp"] } diff --git a/lnvps_e2e/src/db.rs b/lnvps_e2e/src/db.rs index ce5f2ab..3468f79 100644 --- a/lnvps_e2e/src/db.rs +++ b/lnvps_e2e/src/db.rs @@ -1,15 +1,103 @@ +use std::sync::OnceLock; + use nostr::Keys; use sqlx::Row; use sqlx::mysql::MySqlPool; -/// Default database URL for local development (matches docker-compose). +// --------------------------------------------------------------------------- +// Per-run database isolation +// --------------------------------------------------------------------------- + +/// Return the unique run ID for this test process. +/// +/// Reads `LNVPS_E2E_RUN_ID` from the environment. If not set, generates a +/// timestamp-based ID once per process and caches it. +pub fn run_id() -> &'static str { + static ID: OnceLock = OnceLock::new(); + ID.get_or_init(|| { + std::env::var("LNVPS_E2E_RUN_ID").unwrap_or_else(|_| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + .to_string() + }) + }) +} + +/// Name of the per-run test database: `lnvps_e2e_{run_id}`. +pub fn test_db_name() -> String { + format!("lnvps_e2e_{}", run_id()) +} + +/// Base URL for the database server without any database name. +/// Reads `LNVPS_DB_BASE_URL` (e.g. `mysql://root:root@localhost:3376`). +/// Falls back to stripping the path from `LNVPS_DB_URL` or using the +/// docker-compose default. +fn root_db_url() -> String { + if let Ok(v) = std::env::var("LNVPS_DB_BASE_URL") { + return v; + } + // Derive from LNVPS_DB_URL by dropping everything from the last '/' + let full = std::env::var("LNVPS_DB_URL") + .unwrap_or_else(|_| "mysql://root:root@localhost:3376/lnvps".to_string()); + // Strip the database name component (last '/...' segment) + if let Some(idx) = full.rfind('/') { + full[..idx].to_string() + } else { + full + } +} + +/// Full connection URL for the per-run test database. fn db_url() -> String { - std::env::var("LNVPS_DB_URL") - .unwrap_or_else(|_| "mysql://root:root@localhost:3376/lnvps".to_string()) + format!("{}/{}", root_db_url(), test_db_name()) +} + +/// Create the per-run test database if it does not already exist. +pub async fn create_test_database() -> anyhow::Result<()> { + // Connect to a neutral system database to issue CREATE DATABASE + let root_url = format!("{}/mysql", root_db_url()); + let pool = MySqlPool::connect(&root_url).await?; + let db_name = test_db_name(); + sqlx::query(&format!("CREATE DATABASE IF NOT EXISTS `{db_name}`")) + .execute(&pool) + .await?; + pool.close().await; + eprintln!("[e2e] Created test database: {db_name}"); + Ok(()) } -/// Connect to the database. +/// Drop the per-run test database. +pub async fn drop_test_database() -> anyhow::Result<()> { + let root_url = format!("{}/mysql", root_db_url()); + let pool = MySqlPool::connect(&root_url).await?; + let db_name = test_db_name(); + sqlx::query(&format!("DROP DATABASE IF EXISTS `{db_name}`")) + .execute(&pool) + .await?; + pool.close().await; + eprintln!("[e2e] Dropped test database: {db_name}"); + Ok(()) +} + +/// Ensure the test database has been created exactly once per process. +/// Returns the database name. +pub async fn ensure_test_database() -> anyhow::Result { + static CREATED: OnceLock = OnceLock::new(); + if let Some(name) = CREATED.get() { + return Ok(name.clone()); + } + create_test_database().await?; + let name = test_db_name(); + // Ignore error if another thread beat us to it + let _ = CREATED.set(name.clone()); + Ok(name) +} + +/// Connect to the per-run test database (creating it first if necessary). pub async fn connect() -> anyhow::Result { + ensure_test_database().await?; let pool = MySqlPool::connect(&db_url()).await?; Ok(pool) } @@ -83,8 +171,23 @@ pub async fn remove_all_roles(pool: &MySqlPool, user_id: u64) -> anyhow::Result< /// Hard-delete a VM and all its dependent rows from the database. /// Used by E2E cleanup when the worker cannot reach a fake host. +/// +/// Also removes the subscription and its payments that back this VM, +/// because all new VMs link to a `subscription_line_item` and expiry is +/// tracked in `subscription.expires` (not in `vm` directly). pub async fn hard_delete_vm(pool: &MySqlPool, vm_id: u64) -> anyhow::Result<()> { - // Delete in dependency order + // Resolve subscription_id via the line-item link before deleting the VM row. + let sub_id: Option = sqlx::query_scalar( + "SELECT sli.subscription_id \ + FROM vm v \ + INNER JOIN subscription_line_item sli ON sli.id = v.subscription_line_item_id \ + WHERE v.id = ?", + ) + .bind(vm_id) + .fetch_optional(pool) + .await?; + + // Delete legacy vm_payment rows (pre-subscription-migration VMs only). sqlx::query("DELETE FROM vm_payment WHERE vm_id = ?") .bind(vm_id) .execute(pool) @@ -101,6 +204,36 @@ pub async fn hard_delete_vm(pool: &MySqlPool, vm_id: u64) -> anyhow::Result<()> .bind(vm_id) .execute(pool) .await?; + + // Delete subscription rows that were linked to this VM (if any). + if let Some(sid) = sub_id { + hard_delete_subscription(pool, sid).await?; + } + + Ok(()) +} + +/// Hard-delete a subscription and all its payments and line items. +/// +/// Use this when the admin API soft-deletes subscriptions or when the +/// lifecycle test needs to clean up a subscription that was created via +/// the admin API or the subscription endpoints directly. +pub async fn hard_delete_subscription(pool: &MySqlPool, sub_id: u64) -> anyhow::Result<()> { + // Payments reference the subscription; delete them first. + sqlx::query("DELETE FROM subscription_payment WHERE subscription_id = ?") + .bind(sub_id) + .execute(pool) + .await?; + // Line items cascade-delete from the subscription in production (ON DELETE + // CASCADE), but we delete explicitly here to be safe across all DB configs. + sqlx::query("DELETE FROM subscription_line_item WHERE subscription_id = ?") + .bind(sub_id) + .execute(pool) + .await?; + sqlx::query("DELETE FROM subscription WHERE id = ?") + .bind(sub_id) + .execute(pool) + .await?; Ok(()) } @@ -188,6 +321,42 @@ pub async fn hard_delete_company(pool: &MySqlPool, company_id: u64) -> anyhow::R Ok(()) } +/// Backdate `subscription.created` by the given number of hours so that `check_vms` +/// considers the VM eligible for unpaid-VM cleanup (threshold: 1 hour). +pub async fn backdate_vm_created(pool: &MySqlPool, vm_id: u64, hours: u32) -> anyhow::Result<()> { + sqlx::query( + "UPDATE subscription s \ + INNER JOIN subscription_line_item sli ON sli.subscription_id = s.id \ + INNER JOIN vm v ON v.subscription_line_item_id = sli.id \ + SET s.created = DATE_SUB(NOW(), INTERVAL ? HOUR) \ + WHERE v.id = ?", + ) + .bind(hours) + .bind(vm_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Set `subscription.expires` to a given number of seconds in the past so that +/// `check_subscriptions` considers it expired (or within the grace period). +/// +/// Pass `seconds_ago = 0` to set it to exactly `NOW()` (boundary). +pub async fn expire_subscription( + pool: &MySqlPool, + sub_id: u64, + seconds_ago: u64, +) -> anyhow::Result<()> { + sqlx::query( + "UPDATE subscription SET expires = DATE_SUB(NOW(), INTERVAL ? SECOND) WHERE id = ?", + ) + .bind(seconds_ago) + .bind(sub_id) + .execute(pool) + .await?; + Ok(()) +} + /// Insert a referral directly (bypasses lightning address validation). pub async fn insert_referral( pool: &MySqlPool, diff --git a/lnvps_e2e/src/lib.rs b/lnvps_e2e/src/lib.rs index 0fd75da..16cc0f1 100644 --- a/lnvps_e2e/src/lib.rs +++ b/lnvps_e2e/src/lib.rs @@ -15,7 +15,9 @@ mod admin_api; pub mod client; pub mod db; +pub mod lightning; mod lifecycle; pub mod nip98; mod rbac; mod user_api; +pub mod worker; diff --git a/lnvps_e2e/src/lifecycle.rs b/lnvps_e2e/src/lifecycle.rs index c8db4bb..99d8591 100644 --- a/lnvps_e2e/src/lifecycle.rs +++ b/lnvps_e2e/src/lifecycle.rs @@ -128,9 +128,9 @@ mod tests { let body = serde_json::json!({ "name": format!("e2e-host-{ts}"), "ip": "https://10.0.0.1:8006", - "api_token": "root@pam!test=00000000-0000-0000-0000-000000000000", + "api_token": "mock", "region_id": region_id, - "kind": "proxmox", + "kind": "mock", "cpu": 16, "memory": 68719476736_u64, "enabled": true @@ -364,7 +364,43 @@ mod tests { ); // ---------------------------------------------------------------- - // 12. Renew VM → creates an unpaid payment + // 12b. Subscription state immediately after VM creation + // The admin VM response includes the full subscription object. + // ---------------------------------------------------------------- + let vm_admin_initial = + json_ok(admin.get_auth(&format!("/api/admin/v1/vms/{vm_id}")).await.unwrap()).await; + let sub_obj = &vm_admin_initial["data"]["subscription"]; + assert!( + sub_obj.is_object(), + "Admin VM response should include a subscription object" + ); + let sub_id = sub_obj["id"].as_u64().expect("subscription.id should be a u64"); + // After VM creation but before first payment: is_setup=false, expires=null + assert!( + !sub_obj["is_setup"].as_bool().unwrap_or(true), + "Subscription should not be set-up before first payment" + ); + assert!( + sub_obj["expires"].is_null(), + "Subscription should have no expiry before first payment" + ); + eprintln!("Subscription {sub_id} created (is_setup=false, expires=null) ✓"); + + // User can see their subscription via the subscription endpoint + let user_sub = + json_ok(user.get_auth(&format!("/api/v1/subscriptions/{sub_id}")).await.unwrap()) + .await; + assert_eq!( + user_sub["data"]["id"].as_u64().unwrap(), + sub_id, + "User subscription endpoint should return the same subscription" + ); + eprintln!("User can read subscription {sub_id} ✓"); + + // ---------------------------------------------------------------- + // 13. Renew VM → creates an unpaid payment + // Use the VM shortcut (`/api/v1/vm/{id}/renew`) — this goes + // through the subscription handler internally. // ---------------------------------------------------------------- let resp = user .get_auth(&format!("/api/v1/vm/{vm_id}/renew")) @@ -377,9 +413,9 @@ mod tests { } let renew_data = serde_json::from_str::(&resp.text().await.unwrap()).unwrap(); let payment_id = renew_data["data"]["id"].as_str().unwrap().to_string(); - eprintln!("Created payment {payment_id}"); + eprintln!("Created payment {payment_id} (via vm renew shortcut)"); - // Confirm not paid yet + // Confirm not paid yet — check via admin VM-payments endpoint let p = json_ok( admin .get_auth(&format!("/api/admin/v1/vms/{vm_id}/payments/{payment_id}")) @@ -389,22 +425,31 @@ mod tests { .await; assert!(!p["data"]["is_paid"].as_bool().unwrap()); - // ---------------------------------------------------------------- - // 13. Admin completes payment - // ---------------------------------------------------------------- - let p = json_ok( + // Also confirm not paid via the admin subscription-payments endpoint + let sp = json_ok( admin - .post_auth( - &format!("/api/admin/v1/vms/{vm_id}/payments/{payment_id}/complete"), - &serde_json::json!({}), - ) + .get_auth(&format!( + "/api/admin/v1/subscription_payments/{payment_id}" + )) .await .unwrap(), ) .await; - assert!(p["data"]["is_paid"].as_bool().unwrap()); - assert!(p["data"]["paid_at"].is_string()); - eprintln!("Payment {payment_id} completed"); + assert!( + !sp["data"]["is_paid"].as_bool().unwrap(), + "Subscription payment should not be paid yet via subscription-payments endpoint" + ); + eprintln!( + "Payment {payment_id} confirmed unpaid via both vm-payments and subscription-payments ✓" + ); + + // ---------------------------------------------------------------- + // 14. Pay invoice via lnd-payer → lnd channel + // ---------------------------------------------------------------- + let bolt11 = crate::lightning::extract_bolt11(&renew_data).unwrap(); + pay_and_wait(&admin, &format!("/api/admin/v1/vms/{vm_id}/payments/{payment_id}"), &bolt11) + .await; + eprintln!("Payment {payment_id} settled via Lightning ✓"); // VM expiry should have moved forward let vm_after_pay = @@ -412,6 +457,164 @@ mod tests { let expires_str = vm_after_pay["data"]["expires"].as_str().unwrap(); eprintln!("VM {vm_id} expires: {expires_str}"); + // ---------------------------------------------------------------- + // 14b. Verify subscription state after first payment + // is_setup should now be true; expires should be set. + // ---------------------------------------------------------------- + let vm_admin_paid = + json_ok(admin.get_auth(&format!("/api/admin/v1/vms/{vm_id}")).await.unwrap()).await; + let sub_after_pay = &vm_admin_paid["data"]["subscription"]; + assert!( + sub_after_pay["is_setup"].as_bool().unwrap_or(false), + "Subscription should be set-up after first payment" + ); + assert!( + !sub_after_pay["expires"].is_null(), + "Subscription should have an expiry after first payment" + ); + let sub_expires_after_pay = sub_after_pay["expires"].as_str().unwrap().to_string(); + eprintln!("Subscription {sub_id} is_setup=true, expires={sub_expires_after_pay} ✓"); + + // User subscription list should now include our subscription + let user_subs = json_ok(user.get_auth("/api/v1/subscriptions").await.unwrap()).await; + assert!( + user_subs["data"] + .as_array() + .unwrap() + .iter() + .any(|s| s["id"].as_u64() == Some(sub_id)), + "Paid subscription should appear in user subscription list" + ); + + // Subscription payments list (user endpoint) should have 1 paid entry + let sub_payments = json_ok( + user.get_auth(&format!("/api/v1/subscriptions/{sub_id}/payments")) + .await + .unwrap(), + ) + .await; + let paid_sub_payments = sub_payments["data"] + .as_array() + .unwrap() + .iter() + .filter(|p| p["is_paid"].as_bool().unwrap_or(false)) + .count(); + assert_eq!( + paid_sub_payments, 1, + "Should have exactly 1 paid subscription payment after first renewal" + ); + eprintln!("User subscription {sub_id} has {paid_sub_payments} paid payment(s) ✓"); + + // Admin subscription-payments list should also show it + let admin_sub_payments = json_ok( + admin + .get_auth(&format!("/api/admin/v1/subscriptions/{sub_id}/payments")) + .await + .unwrap(), + ) + .await; + assert!( + admin_sub_payments["data"].as_array().unwrap().len() >= 1, + "Admin subscription payments list should have at least 1 entry" + ); + eprintln!("Admin can list subscription {sub_id} payments ✓"); + + // ---------------------------------------------------------------- + // 14c. Second renewal via the subscription endpoint directly + // (verifies that /api/v1/subscriptions/{id}/renew works + // independently of the VM-renew shortcut) + // ---------------------------------------------------------------- + let resp = user + .get_auth(&format!("/api/v1/subscriptions/{sub_id}/renew")) + .await + .unwrap(); + if resp.status() == StatusCode::OK { + let sub_renew = + serde_json::from_str::(&resp.text().await.unwrap()).unwrap(); + let sub_payment_id = sub_renew["data"]["id"].as_str().unwrap().to_string(); + eprintln!("Created subscription-path payment {sub_payment_id}"); + + // Confirm via admin subscription_payments endpoint (not yet paid) + let sp2 = json_ok( + admin + .get_auth(&format!( + "/api/admin/v1/subscription_payments/{sub_payment_id}" + )) + .await + .unwrap(), + ) + .await; + assert!(!sp2["data"]["is_paid"].as_bool().unwrap()); + + // Pay via Lightning and wait for the subscription-payments endpoint + // to confirm settlement (verifies the subscription-payments path + // reflects payment independently of the vm-payments path). + let bolt11_sub = crate::lightning::extract_bolt11(&sub_renew).unwrap(); + pay_and_wait( + &admin, + &format!("/api/admin/v1/subscription_payments/{sub_payment_id}"), + &bolt11_sub, + ) + .await; + + // VM expiry should have advanced beyond the previous value + let vm_after_second_pay = + json_ok(user.get_auth(&format!("/api/v1/vm/{vm_id}")).await.unwrap()) + .await; + let new_expires = vm_after_second_pay["data"]["expires"].as_str().unwrap(); + assert_ne!( + new_expires, expires_str, + "VM expiry should have advanced after second renewal payment" + ); + eprintln!( + "VM {vm_id} expiry advanced from {expires_str} → {new_expires} after subscription renewal ✓" + ); + + // Admin subscription list should include our subscription + let admin_subs = json_ok( + admin + .get_auth(&format!( + "/api/admin/v1/subscriptions?user_id={}", + vm_admin_paid["data"]["user_id"].as_u64().unwrap_or(0) + )) + .await + .unwrap(), + ) + .await; + assert!( + admin_subs["data"] + .as_array() + .unwrap() + .iter() + .any(|s| s["id"].as_u64() == Some(sub_id)), + "Admin subscription list should include subscription {sub_id}" + ); + eprintln!("Admin subscription list includes {sub_id} ✓"); + + // Admin can update (patch) the subscription name + let patch_resp = json_ok( + admin + .patch_auth( + &format!("/api/admin/v1/subscriptions/{sub_id}"), + &serde_json::json!({"name": format!("e2e-updated-{ts}")}), + ) + .await + .unwrap(), + ) + .await; + assert_eq!( + patch_resp["data"]["name"].as_str().unwrap(), + format!("e2e-updated-{ts}"), + "Admin subscription PATCH should update the name" + ); + eprintln!("Admin PATCH subscription {sub_id} name ✓"); + } else { + eprintln!( + "Subscription renew via subscription endpoint returned {} — skipping second renewal flow", + resp.status() + ); + } + // ---------------------------------------------------------------- // 14. Verify referral earnings after payment // ---------------------------------------------------------------- @@ -483,21 +686,15 @@ mod tests { let upg_payment_id = upg["data"]["id"].as_str().unwrap().to_string(); eprintln!("Created upgrade payment {upg_payment_id}"); - // Admin completes upgrade payment - let upg_done = json_ok( - admin - .post_auth( - &format!( - "/api/admin/v1/vms/{vm_id}/payments/{upg_payment_id}/complete" - ), - &serde_json::json!({}), - ) - .await - .unwrap(), + // Pay upgrade invoice via Lightning + let upg_bolt11 = crate::lightning::extract_bolt11(&upg).unwrap(); + pay_and_wait( + &admin, + &format!("/api/admin/v1/vms/{vm_id}/payments/{upg_payment_id}"), + &upg_bolt11, ) .await; - assert!(upg_done["data"]["is_paid"].as_bool().unwrap()); - eprintln!("Upgrade payment {upg_payment_id} completed"); + eprintln!("Upgrade payment {upg_payment_id} settled via Lightning ✓"); // Give the worker a moment then verify template CPU changed tokio::time::sleep(std::time::Duration::from_millis(500)).await; @@ -674,21 +871,15 @@ mod tests { let renew = serde_json::from_str::(&resp.text().await.unwrap()).unwrap(); let custom_payment_id = renew["data"]["id"].as_str().unwrap().to_string(); - // Admin completes custom VM payment - let p = json_ok( - admin - .post_auth( - &format!( - "/api/admin/v1/vms/{cvm_id}/payments/{custom_payment_id}/complete" - ), - &serde_json::json!({}), - ) - .await - .unwrap(), + // Pay custom VM invoice via Lightning + let cvm_bolt11 = crate::lightning::extract_bolt11(&renew).unwrap(); + pay_and_wait( + &admin, + &format!("/api/admin/v1/vms/{cvm_id}/payments/{custom_payment_id}"), + &cvm_bolt11, ) .await; - assert!(p["data"]["is_paid"].as_bool().unwrap()); - eprintln!("Custom VM {cvm_id} payment completed"); + eprintln!("Custom VM {cvm_id} payment settled via Lightning ✓"); } else { eprintln!("Custom VM renew failed: {}", resp.status()); } @@ -754,6 +945,615 @@ mod tests { pool.close().await; + // Drop the per-run test database so it does not accumulate across runs. + // The API servers must be stopped before this point (CI tears them down + // in the Cleanup step after tests finish, so this is safe here). + crate::db::drop_test_database().await.unwrap(); + eprintln!("=== Full lifecycle test passed ==="); } + + // ==================================================================== + // Unpaid-VM cleanup test + // + // Verifies two worker-driven cleanup paths: + // + // Path A — check_vms: + // Order a VM, never pay, backdate vm.created by 2 h, publish + // CheckVms → worker deletes the VM → vm.deleted = true. + // + // Path B — check_subscriptions (expiry + stop): + // Order a VM, pay for it, manually expire the subscription via DB, + // publish CheckSubscriptions → worker stops the VM → a "Expired" + // entry appears in vm_history (the stop call will fail on a fake + // host but the history log is written first via the best-effort + // stop path; if the host call happens to fail before the log we + // simply verify the subscription state is consistent). + // ==================================================================== + + #[tokio::test] + async fn test_unpaid_vm_cleanup() { + let admin = admin().await; + let user = user_client(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + + // ---------------------------------------------------------------- + // Infrastructure (same pattern as test_full_lifecycle) + // ---------------------------------------------------------------- + let company = json_ok( + admin + .post_auth( + "/api/admin/v1/companies", + &serde_json::json!({ + "name": format!("Cleanup Corp {ts}"), + "country_code": "US", + "email": format!("cleanup-{ts}@test.local"), + "base_currency": "EUR" + }), + ) + .await + .unwrap(), + ) + .await; + let company_id = company["data"]["id"].as_u64().unwrap(); + + let region = json_ok( + admin + .post_auth( + "/api/admin/v1/regions", + &serde_json::json!({ + "name": format!("cleanup-region-{ts}"), + "enabled": true, + "company_id": company_id + }), + ) + .await + .unwrap(), + ) + .await; + let region_id = region["data"]["id"].as_u64().unwrap(); + + let cost_plan = json_ok( + admin + .post_auth( + "/api/admin/v1/cost_plans", + &serde_json::json!({ + "name": format!("cleanup-cost-{ts}"), + "amount": 100, + "currency": "EUR", + "interval_amount": 1, + "interval_type": "month" + }), + ) + .await + .unwrap(), + ) + .await; + let cost_plan_id = cost_plan["data"]["id"].as_u64().unwrap(); + + let image = json_ok( + admin + .post_auth( + "/api/admin/v1/vm_os_images", + &serde_json::json!({ + "distribution": "debian", + "flavour": format!("cleanup-{ts}"), + "version": format!("12.cleanup.{ts}"), + "enabled": true, + "release_date": "2026-01-01T00:00:00Z", + "url": "https://example.com/debian-12.qcow2", + "default_username": "root" + }), + ) + .await + .unwrap(), + ) + .await; + let image_id = image["data"]["id"].as_u64().unwrap(); + + let host = json_ok( + admin + .post_auth( + "/api/admin/v1/hosts", + &serde_json::json!({ + "name": format!("cleanup-host-{ts}"), + "ip": "https://10.9.9.1:8006", + "api_token": "mock", + "region_id": region_id, + "kind": "mock", + "cpu": 8, + "memory": 34359738368_u64, + "enabled": true + }), + ) + .await + .unwrap(), + ) + .await; + let host_id = host["data"]["id"].as_u64().unwrap(); + + json_ok( + admin + .post_auth( + &format!("/api/admin/v1/hosts/{host_id}/disks"), + &serde_json::json!({ + "name": format!("cleanup-ssd-{ts}"), + "size": 549755813888_u64, + "kind": "ssd", + "interface": "pcie", + "enabled": true + }), + ) + .await + .unwrap(), + ) + .await; + + let octet2 = ((ts / 256) % 256) as u8; + let octet3 = ((ts / 65536) % 256) as u8; + let cidr = format!("10.{octet2}.{octet3}.0/24"); + let gateway = format!("10.{octet2}.{octet3}.1"); + let ip_range = json_ok( + admin + .post_auth( + "/api/admin/v1/ip_ranges", + &serde_json::json!({ + "cidr": cidr, + "gateway": gateway, + "enabled": true, + "region_id": region_id + }), + ) + .await + .unwrap(), + ) + .await; + let ip_range_id = ip_range["data"]["id"].as_u64().unwrap(); + + let template = json_ok( + admin + .post_auth( + "/api/admin/v1/vm_templates", + &serde_json::json!({ + "name": format!("cleanup-tpl-{ts}"), + "enabled": true, + "cpu": 1, + "memory": 1073741824_u64, + "disk_size": 10737418240_u64, + "disk_type": "ssd", + "disk_interface": "pcie", + "region_id": region_id, + "cost_plan_id": cost_plan_id + }), + ) + .await + .unwrap(), + ) + .await; + let template_id = template["data"]["id"].as_u64().unwrap(); + eprintln!("[cleanup] Infrastructure ready (template={template_id})"); + + let ssh_key = json_ok( + user.post_auth( + "/api/v1/ssh-key", + &serde_json::json!({ + "name": format!("cleanup-key-{ts}"), + "key_data": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHDQnBw8TklSNuqFMHSujgNs48eNMdOl7qGAl68E0T4o cleanup" + }), + ) + .await + .unwrap(), + ) + .await; + let ssh_key_id = ssh_key["data"]["id"].as_u64().unwrap(); + + // ================================================================ + // PATH A: unpaid VM deleted by check_vms after > 1 hour + // ================================================================ + + // Order a VM but do NOT pay for it. + let resp = user + .post_auth( + "/api/v1/vm", + &serde_json::json!({ + "template_id": template_id, + "image_id": image_id, + "ssh_key_id": ssh_key_id + }), + ) + .await + .unwrap(); + if resp.status() != reqwest::StatusCode::OK { + let err = resp.text().await.unwrap(); + eprintln!("[cleanup] Skipping: VM creation failed: {err}"); + // Still clean up infrastructure before returning. + let pool = crate::db::connect().await.unwrap(); + cleanup_infra( + &pool, + company_id, + region_id, + cost_plan_id, + image_id, + host_id, + ip_range_id, + template_id, + None, + None, + ) + .await; + pool.close().await; + return; + } + let vm_data: serde_json::Value = + serde_json::from_str(&resp.text().await.unwrap()).unwrap(); + let unpaid_vm_id = vm_data["data"]["id"].as_u64().unwrap(); + eprintln!("[cleanup] Created unpaid VM {unpaid_vm_id}"); + + // Verify the VM is visible and its subscription is NOT set up. + let vm_admin = + json_ok(admin.get_auth(&format!("/api/admin/v1/vms/{unpaid_vm_id}")).await.unwrap()) + .await; + assert!( + !vm_admin["data"]["deleted"].as_bool().unwrap_or(true), + "Unpaid VM should not be deleted yet" + ); + let sub_obj = &vm_admin["data"]["subscription"]; + assert!( + !sub_obj["is_setup"].as_bool().unwrap_or(true), + "Subscription should not be set-up for an unpaid VM" + ); + assert!( + sub_obj["expires"].is_null(), + "Unpaid VM subscription should have no expiry" + ); + eprintln!("[cleanup] Unpaid VM state verified (is_setup=false, expires=null) ✓"); + + // User sees the VM in their list. + let list = json_ok(user.get_auth("/api/v1/vm").await.unwrap()).await; + assert!( + list["data"] + .as_array() + .unwrap() + .iter() + .any(|v| v["id"].as_u64() == Some(unpaid_vm_id)), + "Unpaid VM should appear in user list before cleanup" + ); + + // Backdate subscription.created so the worker considers it eligible (> 1 h old). + { + let pool = crate::db::connect().await.unwrap(); + crate::db::backdate_vm_created(&pool, unpaid_vm_id, 2) + .await + .unwrap(); + pool.close().await; + } + eprintln!("[cleanup] Backdated unpaid VM created time by 2 hours ✓"); + + // Trigger check_vms and wait for the worker to process it. + crate::worker::trigger_check_vms().await.unwrap(); + eprintln!("[cleanup] Published CheckVms job"); + + // Poll the admin API until vm.deleted = true (up to 30 s). + let deleted = poll_until(30, 500, || { + let admin = admin.clone(); + async move { + let r = admin + .get_auth(&format!("/api/admin/v1/vms/{unpaid_vm_id}")) + .await + .unwrap(); + let body: serde_json::Value = + serde_json::from_str(&r.text().await.unwrap()).unwrap(); + body["data"]["deleted"].as_bool().unwrap_or(false) + } + }) + .await; + + assert!( + deleted, + "Unpaid VM {unpaid_vm_id} should be deleted by check_vms within 30 s" + ); + eprintln!("[cleanup] Unpaid VM {unpaid_vm_id} deleted by worker ✓"); + + // After deletion the user should no longer see the VM. + let list_after = + json_ok(user.get_auth("/api/v1/vm").await.unwrap()).await; + assert!( + !list_after["data"] + .as_array() + .unwrap() + .iter() + .any(|v| v["id"].as_u64() == Some(unpaid_vm_id)), + "Deleted VM should not appear in user VM list" + ); + eprintln!("[cleanup] Deleted VM absent from user list ✓"); + + // Direct GET should fail (404 / not-found). + let resp = user + .get_auth(&format!("/api/v1/vm/{unpaid_vm_id}")) + .await + .unwrap(); + assert_ne!( + resp.status(), + reqwest::StatusCode::OK, + "GET on deleted VM should return an error" + ); + eprintln!("[cleanup] GET deleted VM correctly rejected ✓"); + + // ================================================================ + // PATH B: paid VM stopped by check_subscriptions after expiry + // ================================================================ + + // Order a second VM and pay for it so the subscription becomes active. + let resp = user + .post_auth( + "/api/v1/vm", + &serde_json::json!({ + "template_id": template_id, + "image_id": image_id, + "ssh_key_id": ssh_key_id + }), + ) + .await + .unwrap(); + if resp.status() != reqwest::StatusCode::OK { + eprintln!("[cleanup] Skipping path B: second VM creation failed"); + } else { + let vm2_data: serde_json::Value = + serde_json::from_str(&resp.text().await.unwrap()).unwrap(); + let paid_vm_id = vm2_data["data"]["id"].as_u64().unwrap(); + eprintln!("[cleanup] Created paid VM {paid_vm_id}"); + + // Renew (creates invoice) then pay via Lightning. + let renew_resp = user + .get_auth(&format!("/api/v1/vm/{paid_vm_id}/renew")) + .await + .unwrap(); + if renew_resp.status() == reqwest::StatusCode::OK { + let renew: serde_json::Value = + serde_json::from_str(&renew_resp.text().await.unwrap()).unwrap(); + let pay_id = renew["data"]["id"].as_str().unwrap().to_string(); + let cleanup_bolt11 = crate::lightning::extract_bolt11(&renew).unwrap(); + pay_and_wait( + &admin, + &format!("/api/admin/v1/vms/{paid_vm_id}/payments/{pay_id}"), + &cleanup_bolt11, + ) + .await; + eprintln!("[cleanup] Payment {pay_id} settled via Lightning; VM {paid_vm_id} now active"); + + // Confirm subscription is active and has an expiry. + let vm2_admin = json_ok( + admin + .get_auth(&format!("/api/admin/v1/vms/{paid_vm_id}")) + .await + .unwrap(), + ) + .await; + let sub2 = &vm2_admin["data"]["subscription"]; + let sub2_id = sub2["id"].as_u64().unwrap(); + assert!( + sub2["is_setup"].as_bool().unwrap_or(false), + "Subscription should be set-up after payment" + ); + assert!( + !sub2["expires"].is_null(), + "Subscription should have expiry after payment" + ); + eprintln!("[cleanup] Subscription {sub2_id} active and has expiry ✓"); + + // Manually expire the subscription (set expires 2 days in the past). + { + let pool = crate::db::connect().await.unwrap(); + crate::db::expire_subscription(&pool, sub2_id, 2 * 86_400) + .await + .unwrap(); + pool.close().await; + } + eprintln!("[cleanup] Expired subscription {sub2_id} by 2 days ✓"); + + // Trigger check_subscriptions. + crate::worker::trigger_check_subscriptions().await.unwrap(); + eprintln!("[cleanup] Published CheckSubscriptions job"); + + // Poll VM history for an "Expired" entry (up to 30 s). + // The worker calls on_expired → stop_vm (fails on fake host + // but the history entry is written best-effort). We also + // accept the subscription becoming inactive as a valid signal + // that the grace-period path fired instead. + let expired_signal = poll_until(30, 500, || { + let admin = admin.clone(); + async move { + // Check VM history for Expired action + let hr = admin + .get_auth(&format!("/api/admin/v1/vms/{paid_vm_id}/history")) + .await + .unwrap(); + if let Ok(h) = serde_json::from_str::( + &hr.text().await.unwrap(), + ) { + if h["data"].as_array().map_or(false, |arr| { + arr.iter().any(|e| { + e["action_type"] + .as_str() + .map_or(false, |t| t.eq_ignore_ascii_case("expired")) + }) + }) { + return true; + } + } + // Also accept subscription becoming inactive + let sr = admin + .get_auth(&format!("/api/admin/v1/subscriptions/{sub2_id}")) + .await + .unwrap(); + if let Ok(s) = serde_json::from_str::( + &sr.text().await.unwrap(), + ) { + return !s["data"]["is_active"].as_bool().unwrap_or(true); + } + false + } + }) + .await; + + assert!( + expired_signal, + "Expired subscription {sub2_id} should have triggered stop/deactivation \ + within 30 s (check vm history for Expired entry or subscription is_active=false)" + ); + eprintln!( + "[cleanup] Subscription expiry handled by worker for VM {paid_vm_id} ✓" + ); + + // Clean up the paid VM (hard-delete bypasses the worker). + let pool = crate::db::connect().await.unwrap(); + crate::db::hard_delete_vm(&pool, paid_vm_id).await.unwrap(); + eprintln!("[cleanup] Hard-deleted paid VM {paid_vm_id}"); + pool.close().await; + } else { + eprintln!("[cleanup] Path B renew failed — skipping expiry check"); + let pool = crate::db::connect().await.unwrap(); + crate::db::hard_delete_vm(&pool, paid_vm_id).await.unwrap(); + pool.close().await; + } + } + + // ================================================================ + // Cleanup infrastructure + // ================================================================ + let pool = crate::db::connect().await.unwrap(); + // The unpaid VM was deleted by the worker (deleted=true), but we still + // need to remove its subscription rows — hard_delete_vm handles both. + crate::db::hard_delete_vm(&pool, unpaid_vm_id).await.unwrap(); + eprintln!("[cleanup] Hard-deleted unpaid VM row {unpaid_vm_id}"); + + cleanup_infra( + &pool, + company_id, + region_id, + cost_plan_id, + image_id, + host_id, + ip_range_id, + template_id, + None, + None, + ) + .await; + pool.close().await; + + eprintln!("=== Unpaid VM cleanup test passed ==="); + } + + // ---------------------------------------------------------------- + // Shared infrastructure teardown helper used by cleanup test + // ---------------------------------------------------------------- + #[allow(clippy::too_many_arguments)] + async fn cleanup_infra( + pool: &sqlx::mysql::MySqlPool, + company_id: u64, + region_id: u64, + cost_plan_id: u64, + image_id: u64, + host_id: u64, + ip_range_id: u64, + template_id: u64, + custom_pricing_id: Option, + ssh_key_id: Option, + ) { + if let Some(cp) = custom_pricing_id { + crate::db::hard_delete_custom_pricing(pool, cp).await.unwrap(); + } + let _ = ssh_key_id; // SSH keys are owned by the user row, not a separate cleanup needed + crate::db::hard_delete_vm_template(pool, template_id).await.unwrap(); + crate::db::hard_delete_ip_range(pool, ip_range_id).await.unwrap(); + crate::db::hard_delete_host(pool, host_id).await.unwrap(); + crate::db::hard_delete_os_image(pool, image_id).await.unwrap(); + crate::db::hard_delete_cost_plan(pool, cost_plan_id).await.unwrap(); + crate::db::hard_delete_region(pool, region_id).await.unwrap(); + crate::db::hard_delete_company(pool, company_id).await.unwrap(); + eprintln!("[cleanup] Infrastructure hard-deleted ✓"); + } + + // ---------------------------------------------------------------- + // Poll helper: retry a condition up to `max_secs` seconds, + // checking every `interval_ms` milliseconds. + // ---------------------------------------------------------------- + async fn poll_until(max_secs: u64, interval_ms: u64, f: F) -> bool + where + F: Fn() -> Fut, + Fut: std::future::Future, + { + let deadline = + std::time::Instant::now() + std::time::Duration::from_secs(max_secs); + loop { + if f().await { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(std::time::Duration::from_millis(interval_ms)).await; + } + } + + // ---------------------------------------------------------------- + // Lightning payment helper + // + // Pays `bolt11` via the `lnd-payer` node and polls `status_path` + // (an admin payment GET endpoint) until `is_paid = true`. + // + // If the `lnd-payer` container is not reachable (e.g. the test is + // run without the full docker-compose stack), falls back to the + // admin complete endpoint so the suite can still pass in minimal + // environments. + // ---------------------------------------------------------------- + async fn pay_and_wait( + admin: &crate::client::TestClient, + status_path: &str, + bolt11: &str, + ) { + match crate::lightning::pay_invoice(bolt11).await { + Ok(()) => { + eprintln!("Lightning payment submitted, polling {status_path} ..."); + // Poll up to 30 s for the API to mark the payment as settled. + let paid = poll_until(30, 300, || { + let admin = admin.clone(); + let path = status_path.to_string(); + async move { + if let Ok(r) = admin.get_auth(&path).await { + if let Ok(body) = serde_json::from_str::( + &r.text().await.unwrap_or_default(), + ) { + return body["data"]["is_paid"].as_bool().unwrap_or(false); + } + } + false + } + }) + .await; + assert!(paid, "Payment at {status_path} was not marked paid within 30 s after Lightning settlement"); + } + Err(e) => { + // lnd-payer unavailable — fall back to admin complete so the + // test suite still passes when running without the full stack. + eprintln!("lnd-payer not available ({e}), falling back to admin complete"); + let complete_path = format!("{status_path}/complete"); + let p = json_ok( + admin + .post_auth(&complete_path, &serde_json::json!({})) + .await + .unwrap(), + ) + .await; + assert!( + p["data"]["is_paid"].as_bool().unwrap_or(false), + "Admin complete at {complete_path} did not mark payment as paid" + ); + } + } + } } diff --git a/lnvps_e2e/src/lightning.rs b/lnvps_e2e/src/lightning.rs new file mode 100644 index 0000000..17be360 --- /dev/null +++ b/lnvps_e2e/src/lightning.rs @@ -0,0 +1,76 @@ +//! Helpers for paying Lightning invoices from E2E tests. +//! +//! The `lnd-payer` docker service has a funded channel open to the `lnd` +//! service (the API's node). Tests call [`pay_invoice`] to pay a BOLT11 +//! payment request via `lncli` inside that container. + +/// Name of the payer LND docker-compose service. +/// Resolved at runtime via `docker compose ps -q lnd-payer`. +const PAYER_SERVICE: &str = "lnd-payer"; + +/// Docker compose file used by the E2E environment. +const COMPOSE_FILE: &str = "docker-compose.e2e.yaml"; + +/// Pay a BOLT11 invoice using the `lnd-payer` node. +/// +/// Runs `lncli --network=regtest payinvoice --force ` inside the +/// `lnd-payer` container. Returns an error if the container call fails or +/// the payment is rejected. +pub async fn pay_invoice(bolt11: &str) -> anyhow::Result<()> { + // Resolve the container ID for the payer service. + let id_out = tokio::process::Command::new("docker") + .args(["compose", "-f", COMPOSE_FILE, "ps", "-q", PAYER_SERVICE]) + .output() + .await?; + let container_id = String::from_utf8(id_out.stdout)?.trim().to_string(); + anyhow::ensure!( + !container_id.is_empty(), + "Could not find running container for service '{PAYER_SERVICE}'. \ + Is docker-compose.e2e.yaml up?" + ); + + let out = tokio::process::Command::new("docker") + .args([ + "exec", + &container_id, + "lncli", + "--network=regtest", + "payinvoice", + "--force", + bolt11, + ]) + .output() + .await?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + anyhow::bail!( + "lncli payinvoice failed (exit {})\nstdout: {stdout}\nstderr: {stderr}", + out.status + ); + } + Ok(()) +} + +/// Extract the BOLT11 payment request from a VM renew / subscription renew +/// API response body (raw JSON `Value`). +/// +/// The response shape is: +/// ```json +/// { "data": { "data": { "lightning": "lnbc..." } } } +/// ``` +pub fn extract_bolt11(renew_response: &serde_json::Value) -> anyhow::Result { + let bolt11 = renew_response["data"]["data"]["lightning"] + .as_str() + .ok_or_else(|| { + anyhow::anyhow!( + "No lightning invoice found in renew response. \ + Expected data.data.lightning to be a string. \ + Response: {}", + renew_response + ) + })? + .to_string(); + Ok(bolt11) +} diff --git a/lnvps_e2e/src/worker.rs b/lnvps_e2e/src/worker.rs new file mode 100644 index 0000000..5038949 --- /dev/null +++ b/lnvps_e2e/src/worker.rs @@ -0,0 +1,58 @@ +//! Helpers for interacting with the API worker via Redis. +//! +//! The worker consumes jobs from a Redis Stream named `"worker"` using consumer +//! groups. Tests can publish jobs directly and clear the rate-limit timestamps +//! that the worker uses to avoid running the same check too frequently. + +use redis::AsyncCommands; +use redis::streams::{StreamAddOptions, StreamTrimStrategy, StreamTrimmingMode}; + +/// Redis URL used by the E2E test environment. +/// Reads `LNVPS_REDIS_URL`, falling back to the docker-compose.e2e.yaml default. +pub fn redis_url() -> String { + std::env::var("LNVPS_REDIS_URL") + .unwrap_or_else(|_| "redis://localhost:6399".to_string()) +} + +/// Publish a `WorkJob` to the worker stream. +/// +/// The job is serialized as JSON (matching how `RedisWorkCommander::send` works) +/// and added to the `"worker"` stream. The worker will pick it up on its next +/// poll cycle (~100 ms). +pub async fn publish_job(job_json: &str) -> anyhow::Result<()> { + let client = redis::Client::open(redis_url())?; + let mut conn = client.get_multiplexed_async_connection().await?; + let opts = StreamAddOptions::default() + .trim(StreamTrimStrategy::maxlen(StreamTrimmingMode::Approx, 1000)); + let _id: String = conn + .xadd_options("worker", "*", &[("job", job_json)], &opts) + .await?; + Ok(()) +} + +/// Publish `CheckVms` to the worker stream. +pub async fn trigger_check_vms() -> anyhow::Result<()> { + // Clear the rate-limit key first so the worker doesn't skip the job. + clear_last_check("worker-last-check-vms").await?; + publish_job("\"CheckVms\"").await +} + +/// Publish `CheckSubscriptions` to the worker stream. +pub async fn trigger_check_subscriptions() -> anyhow::Result<()> { + // Clear the rate-limit key first so the worker doesn't skip the job. + clear_last_check("worker-last-check-subscriptions").await?; + publish_job("\"CheckSubscriptions\"").await +} + +/// Delete a worker rate-limit key so the next job execution is not skipped. +/// +/// The worker stores the last-run timestamp under keys such as +/// `"worker-last-check-vms"` and `"worker-last-check-subscriptions"`. +/// Deleting the key forces the rate-limit guard to consider sufficient +/// time as having passed. +async fn clear_last_check(key: &str) -> anyhow::Result<()> { + let client = redis::Client::open(redis_url())?; + let mut conn = client.get_multiplexed_async_connection().await?; + let _: u64 = conn.del(key).await?; + Ok(()) +} diff --git a/lnvps_health/src/main.rs b/lnvps_health/src/main.rs index 3d27c06..c926250 100644 --- a/lnvps_health/src/main.rs +++ b/lnvps_health/src/main.rs @@ -10,7 +10,7 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use tokio::net::TcpListener; +use tokio::net::{TcpListener, TcpSocket}; use tokio::signal; use tokio::sync::Mutex; use tokio::time::interval; @@ -181,7 +181,7 @@ async fn main() -> Result<()> { .with_state(metrics_clone); info!("Starting metrics server on {}", metrics_bind); - match TcpListener::bind(metrics_bind).await { + match bind_address(metrics_bind).await { Ok(listener) => { if let Err(e) = axum::serve(listener, app).await { error!("Metrics server error: {}", e); @@ -363,3 +363,10 @@ fn record_check_metric(metrics: &HealthMetrics, check_id: &str, result: &checks: _ => {} } } + +async fn bind_address(address: SocketAddr) -> std::io::Result { + let socket = TcpSocket::new_v4()?; + socket.set_reuseaddr(true)?; + socket.bind(address)?; + socket.listen(1024) +} diff --git a/lnvps_nostr/src/main.rs b/lnvps_nostr/src/main.rs index a2e584d..cc1fb86 100644 --- a/lnvps_nostr/src/main.rs +++ b/lnvps_nostr/src/main.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; -use tokio::net::TcpListener; +use tokio::net::{TcpListener, TcpSocket}; use tower_http::cors::CorsLayer; mod routes; @@ -36,10 +36,17 @@ async fn main() -> Result<()> { Some(i) => i.parse()?, None => SocketAddr::new(IpAddr::from([0, 0, 0, 0]), 8000), }; - let listener = TcpListener::bind(ip).await?; + let listener = bind_address(ip).await?; info!("Listening on {}", ip); let router = routes::routes(db); axum::serve(listener, router.layer(CorsLayer::permissive())).await?; Ok(()) } + +async fn bind_address(address: SocketAddr) -> std::io::Result { + let socket = TcpSocket::new_v4()?; + socket.set_reuseaddr(true)?; + socket.bind(address)?; + socket.listen(1024) +} diff --git a/log.txt b/log.txt new file mode 100644 index 0000000..3bf2c33 --- /dev/null +++ b/log.txt @@ -0,0 +1 @@ +"2026-03-10T12:41:39.611904Z INFO ThreadId(498) zap_stream_core::pipeline::runner: Pipeline run starting\n2026-03-10T12:41:39.843521Z INFO ThreadId(498) zap_stream_core::ingress::rtmp: Metadata configured: StreamMetadata { video_width: Some(1920), video_height: Some(1080), video_codec_id: Some(7), video_frame_rate: Some(30.0), video_bitrate_kbps: Some(5000), audio_codec_id: Some(10), audio_bitrate_kbps: Some(256), audio_sample_rate: Some(48000), audio_channels: Some(2), audio_is_stereo: Some(true), encoder: Some(\"obs-output module (libobs version 31.0.3)\") }\n2026-03-10T12:41:39.843563Z INFO ThreadId(498) zap_stream_core::ingress::rtmp: FLV header written with audio: true, video: true\n2026-03-10T12:41:40.905593Z WARN ThreadId(498) ffmpeg: [h264 @ 0x7ec9b81029c0] Increasing reorder buffer to 1\n2026-03-10T12:41:40.948102Z DEBUG ThreadId(498) zap_stream_core::metrics: RTMP: 2.3 Mbps, 11.9 pps, 24 packets, 581037 bytes\n2026-03-10T12:41:41.864992Z INFO ThreadId(498) zap_stream_core::endpoint: Skipping variant 2160p, source would be upscaled from 1080p\n2026-03-10T12:41:41.865039Z INFO ThreadId(498) zap_stream_core::endpoint: Skipping variant 1440p, source would be upscaled from 1080p\n2026-03-10T12:41:41.883644Z DEBUG ThreadId(498) zap_stream::viewer: Found 0 viewers for stream 97dd6f61-9281-4912-8581-85bc9c6c2ea5 in Redis\n2026-03-10T12:41:42.042711Z INFO ThreadId(498) zap_stream::overseer: Published stream event e2bb123b586974cdc008cd90ecb815e03db6378c1c75d26b40fbf6d6b1a1d73d\n2026-03-10T12:41:42.046406Z ERROR ThreadId(498) ffmpeg: [AVHWDeviceContext @ 0x7ec9bbd919c0] Cannot load libcuda.so.1\n2026-03-10T12:41:42.046439Z ERROR ThreadId(498) ffmpeg: [AVHWDeviceContext @ 0x7ec9bbd919c0] Could not dynamically load CUDA\n2026-03-10T12:41:42.046466Z WARN ThreadId(498) ffmpeg_rs_raw::decode: Failed to create hardware context cuda, continuing without hwaccel: Operation not permitted\n2026-03-10T12:41:42.046570Z WARN ThreadId(498) ffmpeg_rs_raw::decode: Failed to create hardware context vaapi, continuing without hwaccel: Generic error in an external library\n2026-03-10T12:41:42.048408Z WARN ThreadId(498) zap_stream_core::variant::video: Color range not found bt709, Invalid argument\n2026-03-10T12:41:42.049638Z INFO ThreadId(498) ffmpeg: [libx264 @ 0x7ec9b931cb80] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512\n2026-03-10T12:41:42.056124Z INFO ThreadId(498) ffmpeg: [libx264 @ 0x7ec9b931cb80] profile Main, level 5.1, 4:2:0, 8-bit\n2026-03-10T12:41:42.056219Z INFO ThreadId(498) ffmpeg: [libx264 @ 0x7ec9b931cb80] 264 - core 164 r3108 31e19f9 - H.264/MPEG-4 AVC codec - Copyleft 2003-2023 - http://www.videolan.org/x264.html - options: cabac=1 ref=1 deblock=1:0:0 analyse=0x1:0x111 me=hex subme=2 psy=1 psy_rd=1.00:0.00 mixed_ref=0 me_range=16 chroma_me=1 trellis=0 8x8dct=0 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=0 threads=17 lookahead_threads=16 sliced_threads=1 slices=17 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=1 keyint=60 keyint_min=31 scenecut=40 intra_refresh=0 rc=abr mbtree=0 bitrate=8000 ratetol=1.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 pb_ratio=1.30 aq=1:1.00\n2026-03-10T12:41:42.057434Z WARN ThreadId(498) zap_stream_core::variant::video: Color range not found bt709, Invalid argument\n2026-03-10T12:41:42.057910Z INFO ThreadId(498) ffmpeg: [libx264 @ 0x7ec9badfe040] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512\n2026-03-10T12:41:42.060675Z INFO ThreadId(498) ffmpeg: [libx264 @ 0x7ec9badfe040] profile Main, level 5.1, 4:2:0, 8-bit\n2026-03-10T12:41:42.060724Z INFO ThreadId(498) ffmpeg: [libx264 @ 0x7ec9badfe040] 264 - core 164 r3108 31e19f9 - H.264/MPEG-4 AVC codec - Copyleft 2003-2023 - http://www.videolan.org/x264.html - options: cabac=1 ref=1 deblock=1:0:0 analyse=0x1:0x111 me=hex subme=2 psy=1 psy_rd=1.00:0.00 mixed_ref=0 me_range=16 chroma_me=1 trellis=0 8x8dct=0 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=0 threads=11 lookahead_threads=11 sliced_threads=1 slices=11 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=1 keyint=60 keyint_min=31 scenecut=40 intra_refresh=0 rc=abr mbtree=0 bitrate=4000 ratetol=1.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 pb_ratio=1.30 aq=1:1.00\n2026-03-10T12:41:42.060869Z WARN ThreadId(498) zap_stream_core::variant::video: Color range not found bt709, Invalid argument\n2026-03-10T12:41:42.061354Z INFO ThreadId(498) ffmpeg: [libx264 @ 0x7ec9b9347880] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512\n2026-03-10T12:41:42.063272Z INFO ThreadId(498) ffmpeg: [libx264 @ 0x7ec9b9347880] profile Main, level 5.1, 4:2:0, 8-bit\n2026-03-10T12:41:42.063343Z INFO ThreadId(498) ffmpeg: [libx264 @ 0x7ec9b9347880] 264 - core 164 r3108 31e19f9 - H.264/MPEG-4 AVC codec - Copyleft 2003-2023 - http://www.videolan.org/x264.html - options: cabac=1 ref=1 deblock=1:0:0 analyse=0x1:0x111 me=hex subme=2 psy=1 psy_rd=1.00:0.00 mixed_ref=0 me_range=16 chroma_me=1 trellis=0 8x8dct=0 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=0 threads=7 lookahead_threads=7 sliced_threads=1 slices=7 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=1 keyint=60 keyint_min=31 scenecut=40 intra_refresh=0 rc=abr mbtree=0 bitrate=1500 ratetol=1.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 pb_ratio=1.30 aq=1:1.00\n2026-03-10T12:41:42.063376Z INFO ThreadId(498) zap_stream_core::egress::moq: MoQ: video track 5125deef-5684-4136-baf8-01ac6f069703 has 40 bytes of codec description (extradata)\n2026-03-10T12:41:42.063390Z INFO ThreadId(498) zap_stream_core::egress::moq: MoQ: video track c6fd8cc8-2c2e-4330-9884-9996935da8f2 has 38 bytes of codec description (extradata)\n2026-03-10T12:41:42.063397Z INFO ThreadId(498) zap_stream_core::egress::moq: MoQ: video track ad24b2a2-4743-47a3-bd39-7c37c4d806db has 39 bytes of codec description (extradata)\n2026-03-10T12:41:42.063634Z INFO ThreadId(498) zap_stream_core::mux::hls::variant: 77283349-6c98-404c-b8e8-4a76d1062fbe will use stream index 0 as reference for segmentation\n2026-03-10T12:41:42.063690Z INFO ThreadId(498) zap_stream_core::mux::hls::variant: 9496a1b3-781b-4b0e-b0f8-3964ac0120ad will use stream index 0 as reference for segmentation\n2026-03-10T12:41:42.063741Z INFO ThreadId(498) zap_stream_core::mux::hls::variant: 193dfb5a-f868-4450-a60d-01dbb6d748ba will use stream index 0 as reference for segmentation\n2026-03-10T12:41:42.063946Z INFO ThreadId(498) zap_stream_core::mux::hls::variant: Created fMP4 initialization segment: /data/97dd6f61-9281-4912-8581-85bc9c6c2ea5/hls/77283349-6c98-404c-b8e8-4a76d1062fbe/init.mp4\n2026-03-10T12:41:42.064102Z INFO ThreadId(498) zap_stream_core::mux::hls::variant: Created fMP4 initialization segment: /data/97dd6f61-9281-4912-8581-85bc9c6c2ea5/hls/9496a1b3-781b-4b0e-b0f8-3964ac0120ad/init.mp4\n2026-03-10T12:41:42.064265Z INFO ThreadId(498) zap_stream_core::mux::hls::variant: Created fMP4 initialization segment: /data/97dd6f61-9281-4912-8581-85bc9c6c2ea5/hls/193dfb5a-f868-4450-a60d-01dbb6d748ba/init.mp4\n2026-03-10T12:41:42.064418Z INFO ThreadId(498) zap_stream_core::pipeline::worker: Worker thread starting for variant: c6fd8cc8-2c2e-4330-9884-9996935da8f2\n2026-03-10T12:41:42.064480Z INFO ThreadId(498) zap_stream_core::pipeline::worker: Worker thread starting for variant: 5125deef-5684-4136-baf8-01ac6f069703\n2026-03-10T12:41:42.064550Z INFO ThreadId(498) zap_stream_core::pipeline::worker: Worker thread starting for variant: ad24b2a2-4743-47a3-bd39-7c37c4d806db\n2026-03-10T12:41:42.064629Z INFO ThreadId(498) zap_stream_core::pipeline::worker: Worker thread starting for variant: 4e31c080-8014-4939-9465-b3a0a1ac45ee\n2026-03-10T12:41:42.064715Z INFO ThreadId(498) zap_stream_core::pipeline::worker: Worker thread starting for variant: 9554c50a-61a3-4196-a96b-d992589f8949\n2026-03-10T12:41:42.064826Z INFO ThreadId(498) zap_stream_core::pipeline::runner: PipelineConfig:\n├── Sources: video=1, audio=Some(0)\n├── Ingress Streams (2):\n│ ├── #0 Audio: 2ch @ 48000Hz, aac (fltp), 0kbps\n│ └── #1 Video: 1920x1080 @ 30.30fps, h264 (yuv420p), 0kbps\n├── Variants (5):\n│ ├── Video #1: h264, 1920x1080, 30.30303fps, 8000kbps (5125deef-5684-4136-baf8-01ac6f069703)\n│ ├── Audio #0: aac, 192kbps, 48.00 kHz, 2ch (9554c50a-61a3-4196-a96b-d992589f8949)\n│ ├── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (4e31c080-8014-4939-9465-b3a0a1ac45ee)\n│ ├── Video #1: h264, 1280x720, 30.30303fps, 4000kbps (c6fd8cc8-2c2e-4330-9884-9996935da8f2)\n│ └── Video #1: h264, 854x480, 30.30303fps, 1500kbps (ad24b2a2-4743-47a3-bd39-7c37c4d806db)\n└── Egress (2):\n ├── MoQ (62867daf-2021-4461-b677-20fa9e7f22e6)\n │ ├── Group (3dc33ced-0ed7-4951-a150-0525c0fa5368)\n │ │ ├── Video #1: h264, 1920x1080, 30.30303fps, 8000kbps (5125deef-5684-4136-baf8-01ac6f069703)\n │ │ └── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (4e31c080-8014-4939-9465-b3a0a1ac45ee)\n │ ├── Group (cba00731-eb22-4180-ac08-148c93b9d801)\n │ │ ├── Video #1: h264, 1280x720, 30.30303fps, 4000kbps (c6fd8cc8-2c2e-4330-9884-9996935da8f2)\n │ │ └── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (4e31c080-8014-4939-9465-b3a0a1ac45ee)\n │ └── Group (d72ec980-a4bc-4cb2-9c80-8d97c13b236c)\n │ ├── Video #1: h264, 854x480, 30.30303fps, 1500kbps (ad24b2a2-4743-47a3-bd39-7c37c4d806db)\n │ └── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (4e31c080-8014-4939-9465-b3a0a1ac45ee)\n └── HLS (2f6fe815-7c02-4dd4-8335-4f2c0b6c573b)\n ├── Group (77283349-6c98-404c-b8e8-4a76d1062fbe)\n │ ├── Video #1: h264, 1920x1080, 30.30303fps, 8000kbps (5125deef-5684-4136-baf8-01ac6f069703)\n │ └── Audio #0: aac, 192kbps, 48.00 kHz, 2ch (9554c50a-61a3-4196-a96b-d992589f8949)\n ├── Group (9496a1b3-781b-4b0e-b0f8-3964ac0120ad)\n │ ├── Video #1: h264, 1280x720, 30.30303fps, 4000kbps (c6fd8cc8-2c2e-4330-9884-9996935da8f2)\n │ └── Audio #0: aac, 192kbps, 48.00 kHz, 2ch (9554c50a-61a3-4196-a96b-d992589f8949)\n └── Group (193dfb5a-f868-4450-a60d-01dbb6d748ba)\n ├── Video #1: h264, 854x480, 30.30303fps, 1500kbps (ad24b2a2-4743-47a3-bd39-7c37c4d806db)\n └── Audio #0: aac, 192kbps, 48.00 kHz, 2ch (9554c50a-61a3-4196-a96b-d992589f8949)\n\n2026-03-10T12:41:42.064878Z INFO ThreadId(498) zap_stream_core::pipeline::runner: Decoder for stream 0: aac\n2026-03-10T12:41:42.064882Z INFO ThreadId(498) zap_stream_core::pipeline::runner: Decoder for stream 1: h264\n2026-03-10T12:41:42.082953Z DEBUG ThreadId(498) zap_stream_core::pipeline::runner: Average fps: 0.40\n2026-03-10T12:41:42.961612Z DEBUG ThreadId(498) zap_stream_core::metrics: RTMP: 5.1 Mbps, 84.9 pps, 171 packets, 1288341 bytes\n2026-03-10T12:41:44.091348Z DEBUG ThreadId(498) zap_stream_core::pipeline::runner: Average fps: 53.28\n2026-03-10T12:41:44.966038Z DEBUG ThreadId(498) zap_stream_core::metrics: RTMP: 5.2 Mbps, 76.8 pps, 154 packets, 1311487 bytes\n2026-03-10T12:41:46.099920Z DEBUG ThreadId(498) zap_stream_core::pipeline::runner: Average fps: 30.37\n2026-03-10T12:41:46.989373Z DEBUG ThreadId(498) zap_stream_core::metrics: RTMP: 5.0 Mbps, 77.1 pps, 156 packets, 1273449 bytes\n2026-03-10T12:41:48.147571Z DEBUG ThreadId(498) zap_stream_core::pipeline::runner: Average fps: 29.30\n2026-03-10T12:41:48.991174Z DEBUG ThreadId(498) zap_stream_core::metrics: RTMP: 5.4 Mbps, 76.9 pps, 154 packets, 1359814 bytes\n2026-03-10T12:41:50.150972Z DEBUG ThreadId(498) zap_stream_core::pipeline::runner: Average fps: 30.95\n2026-03-10T12:41:50.994218Z DEBUG ThreadId(498) zap_stream_core::metrics: RTMP: 5.1 Mbps, 76.9 pps, 154 packets, 1287748 bytes\n2026-03-10T12:41:51.365030Z WARN ThreadId(498) zap_stream_core::ingress::rtmp: Unhandled ServerSessionEvent: UnhandleableAmf0Command { command_name: \"FCUnpublish\", transaction_id: 7.0, command_object: Null, additional_values: [Utf8String(\"1b24b8ec-1799-4769-9e58-be612a233f3f\")] }\n2026-03-10T12:41:51.365050Z INFO ThreadId(498) zap_stream_core::ingress::rtmp: Stream ending\n2026-03-10T12:41:51.542276Z INFO ThreadId(498) zap_stream::overseer: Published stream event 865f3a56772ab5253cb9ba7f429e0b0544c721bf0b0f2951024d316ab295ebc7\n2026-03-10T12:41:51.544251Z INFO ThreadId(498) zap_stream::overseer: Stream ended 97dd6f61-9281-4912-8581-85bc9c6c2ea5\n2026-03-10T12:41:51.544311Z INFO ThreadId(498) zap_stream_core::pipeline::runner: Pipeline 97dd6f61-9281-4912-8581-85bc9c6c2ea5 ending normally\n2026-03-10T17:59:33.861214Z INFO ThreadId(531) zap_stream_core::pipeline::runner: Pipeline run starting\n2026-03-10T17:59:34.083437Z INFO ThreadId(531) zap_stream_core::ingress::rtmp: Metadata configured: StreamMetadata { video_width: Some(1920), video_height: Some(1080), video_codec_id: Some(7), video_frame_rate: Some(30.0), video_bitrate_kbps: Some(6500), audio_codec_id: Some(10), audio_bitrate_kbps: Some(256), audio_sample_rate: Some(48000), audio_channels: Some(2), audio_is_stereo: Some(true), encoder: Some(\"obs-output module (libobs version 31.0.3)\") }\n2026-03-10T17:59:34.083462Z INFO ThreadId(531) zap_stream_core::ingress::rtmp: FLV header written with audio: true, video: true\n2026-03-10T17:59:35.211850Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 1.9 Mbps, 20.9 pps, 42 packets, 475384 bytes\n2026-03-10T17:59:36.072865Z INFO ThreadId(531) zap_stream_core::endpoint: Skipping variant 2160p, source would be upscaled from 1080p\n2026-03-10T17:59:36.072910Z INFO ThreadId(531) zap_stream_core::endpoint: Skipping variant 1440p, source would be upscaled from 1080p\n2026-03-10T17:59:36.086691Z DEBUG ThreadId(531) zap_stream::viewer: Found 0 viewers for stream 97dd6f61-9281-4912-8581-85bc9c6c2ea5 in Redis\n2026-03-10T17:59:36.248065Z INFO ThreadId(531) zap_stream::overseer: Published stream event 2b04cc0b1a3a9ffe6686289dd43422e4bb78966a5f5377c2f424721e5be8b6aa\n2026-03-10T17:59:36.252793Z ERROR ThreadId(531) ffmpeg: [AVHWDeviceContext @ 0x7ec9e52ae480] Cannot load libcuda.so.1\n2026-03-10T17:59:36.252829Z ERROR ThreadId(531) ffmpeg: [AVHWDeviceContext @ 0x7ec9e52ae480] Could not dynamically load CUDA\n2026-03-10T17:59:36.252864Z WARN ThreadId(531) ffmpeg_rs_raw::decode: Failed to create hardware context cuda, continuing without hwaccel: Operation not permitted\n2026-03-10T17:59:36.252993Z WARN ThreadId(531) ffmpeg_rs_raw::decode: Failed to create hardware context vaapi, continuing without hwaccel: Generic error in an external library\n2026-03-10T17:59:36.254789Z WARN ThreadId(531) zap_stream_core::variant::video: Color range not found bt709, Invalid argument\n2026-03-10T17:59:36.256093Z INFO ThreadId(531) ffmpeg: [libx264 @ 0x7ec9e4500500] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512\n2026-03-10T17:59:36.262783Z INFO ThreadId(531) ffmpeg: [libx264 @ 0x7ec9e4500500] profile Main, level 5.1, 4:2:0, 8-bit\n2026-03-10T17:59:36.262905Z INFO ThreadId(531) ffmpeg: [libx264 @ 0x7ec9e4500500] 264 - core 164 r3108 31e19f9 - H.264/MPEG-4 AVC codec - Copyleft 2003-2023 - http://www.videolan.org/x264.html - options: cabac=1 ref=1 deblock=1:0:0 analyse=0x1:0x111 me=hex subme=2 psy=1 psy_rd=1.00:0.00 mixed_ref=0 me_range=16 chroma_me=1 trellis=0 8x8dct=0 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=0 threads=17 lookahead_threads=16 sliced_threads=1 slices=17 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=1 keyint=60 keyint_min=31 scenecut=40 intra_refresh=0 rc=abr mbtree=0 bitrate=8000 ratetol=1.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 pb_ratio=1.30 aq=1:1.00\n2026-03-10T17:59:36.265439Z WARN ThreadId(531) zap_stream_core::variant::video: Color range not found bt709, Invalid argument\n2026-03-10T17:59:36.266262Z INFO ThreadId(531) ffmpeg: [libx264 @ 0x7ec9897f3740] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512\n2026-03-10T17:59:36.268914Z INFO ThreadId(531) ffmpeg: [libx264 @ 0x7ec9897f3740] profile Main, level 5.1, 4:2:0, 8-bit\n2026-03-10T17:59:36.268964Z INFO ThreadId(531) ffmpeg: [libx264 @ 0x7ec9897f3740] 264 - core 164 r3108 31e19f9 - H.264/MPEG-4 AVC codec - Copyleft 2003-2023 - http://www.videolan.org/x264.html - options: cabac=1 ref=1 deblock=1:0:0 analyse=0x1:0x111 me=hex subme=2 psy=1 psy_rd=1.00:0.00 mixed_ref=0 me_range=16 chroma_me=1 trellis=0 8x8dct=0 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=0 threads=11 lookahead_threads=11 sliced_threads=1 slices=11 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=1 keyint=60 keyint_min=31 scenecut=40 intra_refresh=0 rc=abr mbtree=0 bitrate=4000 ratetol=1.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 pb_ratio=1.30 aq=1:1.00\n2026-03-10T17:59:36.269109Z WARN ThreadId(531) zap_stream_core::variant::video: Color range not found bt709, Invalid argument\n2026-03-10T17:59:36.269596Z INFO ThreadId(531) ffmpeg: [libx264 @ 0x7ec989ff6300] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512\n2026-03-10T17:59:36.271563Z INFO ThreadId(531) ffmpeg: [libx264 @ 0x7ec989ff6300] profile Main, level 5.1, 4:2:0, 8-bit\n2026-03-10T17:59:36.271601Z INFO ThreadId(531) ffmpeg: [libx264 @ 0x7ec989ff6300] 264 - core 164 r3108 31e19f9 - H.264/MPEG-4 AVC codec - Copyleft 2003-2023 - http://www.videolan.org/x264.html - options: cabac=1 ref=1 deblock=1:0:0 analyse=0x1:0x111 me=hex subme=2 psy=1 psy_rd=1.00:0.00 mixed_ref=0 me_range=16 chroma_me=1 trellis=0 8x8dct=0 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=0 threads=7 lookahead_threads=7 sliced_threads=1 slices=7 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=1 keyint=60 keyint_min=31 scenecut=40 intra_refresh=0 rc=abr mbtree=0 bitrate=1500 ratetol=1.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 pb_ratio=1.30 aq=1:1.00\n2026-03-10T17:59:36.871160Z INFO ThreadId(531) zap_stream_core::mux::hls::variant: 42052b04-9e32-49f2-879d-fb6e56828cfc will use stream index 0 as reference for segmentation\n2026-03-10T17:59:36.871461Z INFO ThreadId(531) zap_stream_core::mux::hls::variant: 82fd4815-e80e-4ca3-b989-faa79117dece will use stream index 0 as reference for segmentation\n2026-03-10T17:59:36.871657Z INFO ThreadId(531) zap_stream_core::mux::hls::variant: 860eb688-c3c6-4ac1-821f-01f0bf65ad29 will use stream index 0 as reference for segmentation\n2026-03-10T17:59:36.872423Z INFO ThreadId(531) zap_stream_core::mux::hls::variant: Created fMP4 initialization segment: /data/97dd6f61-9281-4912-8581-85bc9c6c2ea5/hls/42052b04-9e32-49f2-879d-fb6e56828cfc/init.mp4\n2026-03-10T17:59:36.872926Z INFO ThreadId(531) zap_stream_core::mux::hls::variant: Created fMP4 initialization segment: /data/97dd6f61-9281-4912-8581-85bc9c6c2ea5/hls/82fd4815-e80e-4ca3-b989-faa79117dece/init.mp4\n2026-03-10T17:59:36.873466Z INFO ThreadId(531) zap_stream_core::mux::hls::variant: Created fMP4 initialization segment: /data/97dd6f61-9281-4912-8581-85bc9c6c2ea5/hls/860eb688-c3c6-4ac1-821f-01f0bf65ad29/init.mp4\n2026-03-10T17:59:36.873806Z INFO ThreadId(531) zap_stream_core::egress::moq: MoQ: video track 0b2496b1-1cdb-4f63-bdb5-9cdbaf29f9d5 has 40 bytes of codec description (extradata)\n2026-03-10T17:59:36.873859Z INFO ThreadId(531) zap_stream_core::egress::moq: MoQ: video track 75eb516f-a6b7-4a2c-bf6b-35d0de838222 has 38 bytes of codec description (extradata)\n2026-03-10T17:59:36.873890Z INFO ThreadId(531) zap_stream_core::egress::moq: MoQ: video track 4d722c62-2d88-49ba-9ae9-1f7feebb317f has 39 bytes of codec description (extradata)\n2026-03-10T17:59:36.874111Z INFO ThreadId(531) zap_stream_core::pipeline::worker: Worker thread starting for variant: 4d722c62-2d88-49ba-9ae9-1f7feebb317f\n2026-03-10T17:59:36.874315Z INFO ThreadId(531) zap_stream_core::pipeline::worker: Worker thread starting for variant: 0b2496b1-1cdb-4f63-bdb5-9cdbaf29f9d5\n2026-03-10T17:59:36.874481Z INFO ThreadId(531) zap_stream_core::pipeline::worker: Worker thread starting for variant: bdfc9850-0212-4c7d-b2e5-9d08a60a8af6\n2026-03-10T17:59:36.874861Z INFO ThreadId(531) zap_stream_core::pipeline::worker: Worker thread starting for variant: 5326ed7d-5698-4549-8f2b-aaf44b2b49ef\n2026-03-10T17:59:36.875085Z INFO ThreadId(531) zap_stream_core::pipeline::worker: Worker thread starting for variant: 75eb516f-a6b7-4a2c-bf6b-35d0de838222\n2026-03-10T17:59:36.875535Z INFO ThreadId(531) zap_stream_core::pipeline::runner: PipelineConfig:\n├── Sources: video=1, audio=Some(0)\n├── Ingress Streams (2):\n│ ├── #0 Audio: 2ch @ 48000Hz, aac (fltp), 0kbps\n│ └── #1 Video: 1920x1080 @ 30.30fps, h264 (yuv420p), 0kbps\n├── Variants (5):\n│ ├── Video #1: h264, 1920x1080, 30.30303fps, 8000kbps (0b2496b1-1cdb-4f63-bdb5-9cdbaf29f9d5)\n│ ├── Audio #0: aac, 192kbps, 48.00 kHz, 2ch (bdfc9850-0212-4c7d-b2e5-9d08a60a8af6)\n│ ├── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (5326ed7d-5698-4549-8f2b-aaf44b2b49ef)\n│ ├── Video #1: h264, 1280x720, 30.30303fps, 4000kbps (75eb516f-a6b7-4a2c-bf6b-35d0de838222)\n│ └── Video #1: h264, 854x480, 30.30303fps, 1500kbps (4d722c62-2d88-49ba-9ae9-1f7feebb317f)\n└── Egress (4):\n ├── RTMPForwarder rtmp://a.rtmp.youtube.com:1935/live2/ (1572216a-c518-4452-b725-8fb6b0f82bc3)\n │ └── Group (9923b05a-9a95-4b5a-8648-4b873b069084)\n │ ├── Video #1: h264, 1280x720, 30.30303fps, 4000kbps (75eb516f-a6b7-4a2c-bf6b-35d0de838222)\n │ └── Audio #0: aac, 192kbps, 48.00 kHz, 2ch (bdfc9850-0212-4c7d-b2e5-9d08a60a8af6)\n ├── RTMPForwarder rtmp://a.rtmp.youtube.com:1935/live2/ (ebb6403b-b62b-47b1-9959-4a0b20cb2bfb)\n │ ├── Group (f11693c6-192b-4854-b31d-404a65e261cd)\n │ │ ├── Video #1: h264, 1920x1080, 30.30303fps, 8000kbps (0b2496b1-1cdb-4f63-bdb5-9cdbaf29f9d5)\n │ │ └── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (5326ed7d-5698-4549-8f2b-aaf44b2b49ef)\n │ ├── Group (6e2714dd-8a18-418a-9726-ec6341ddd2e5)\n │ │ ├── Video #1: h264, 1280x720, 30.30303fps, 4000kbps (75eb516f-a6b7-4a2c-bf6b-35d0de838222)\n │ │ └── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (5326ed7d-5698-4549-8f2b-aaf44b2b49ef)\n │ └── Group (9e5882ff-2241-475f-82d4-bbb491095de5)\n │ ├── Video #1: h264, 854x480, 30.30303fps, 1500kbps (4d722c62-2d88-49ba-9ae9-1f7feebb317f)\n │ └── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (5326ed7d-5698-4549-8f2b-aaf44b2b49ef)\n ├── HLS (1a9e6823-2552-477d-9313-9897d189dbb7)\n │ ├── Group (42052b04-9e32-49f2-879d-fb6e56828cfc)\n │ │ ├── Video #1: h264, 1920x1080, 30.30303fps, 8000kbps (0b2496b1-1cdb-4f63-bdb5-9cdbaf29f9d5)\n │ │ └── Audio #0: aac, 192kbps, 48.00 kHz, 2ch (bdfc9850-0212-4c7d-b2e5-9d08a60a8af6)\n │ ├── Group (82fd4815-e80e-4ca3-b989-faa79117dece)\n │ │ ├── Video #1: h264, 1280x720, 30.30303fps, 4000kbps (75eb516f-a6b7-4a2c-bf6b-35d0de838222)\n │ │ └── Audio #0: aac, 192kbps, 48.00 kHz, 2ch (bdfc9850-0212-4c7d-b2e5-9d08a60a8af6)\n │ └── Group (860eb688-c3c6-4ac1-821f-01f0bf65ad29)\n │ ├── Video #1: h264, 854x480, 30.30303fps, 1500kbps (4d722c62-2d88-49ba-9ae9-1f7feebb317f)\n │ └── Audio #0: aac, 192kbps, 48.00 kHz, 2ch (bdfc9850-0212-4c7d-b2e5-9d08a60a8af6)\n └── MoQ (190084d5-b7ca-43a9-ac18-c2b666f5d441)\n ├── Group (31c70872-cb71-4a03-ad2c-09b405e5da53)\n │ ├── Video #1: h264, 1920x1080, 30.30303fps, 8000kbps (0b2496b1-1cdb-4f63-bdb5-9cdbaf29f9d5)\n │ └── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (5326ed7d-5698-4549-8f2b-aaf44b2b49ef)\n ├── Group (e7972283-6dcd-403b-9f45-e3561f088ef1)\n │ ├── Video #1: h264, 1280x720, 30.30303fps, 4000kbps (75eb516f-a6b7-4a2c-bf6b-35d0de838222)\n │ └── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (5326ed7d-5698-4549-8f2b-aaf44b2b49ef)\n └── Group (60c3c111-7e01-4103-a1c1-c5d21c4e7e73)\n ├── Video #1: h264, 854x480, 30.30303fps, 1500kbps (4d722c62-2d88-49ba-9ae9-1f7feebb317f)\n └── Audio #0: aac, 0kbps, 48.00 kHz, 2ch (5326ed7d-5698-4549-8f2b-aaf44b2b49ef)\n\n2026-03-10T17:59:36.875728Z INFO ThreadId(531) zap_stream_core::pipeline::runner: Decoder for stream 0: aac\n2026-03-10T17:59:36.875739Z INFO ThreadId(531) zap_stream_core::pipeline::runner: Decoder for stream 1: h264\n2026-03-10T17:59:36.929534Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 0.33\n2026-03-10T17:59:37.214592Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 3.5 Mbps, 39.9 pps, 80 packets, 872565 bytes\n2026-03-10T17:59:39.137192Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 54.36\n2026-03-10T17:59:39.218993Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 10.0 Mbps, 113.3 pps, 227 packets, 2513100 bytes\n2026-03-10T17:59:41.139754Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T17:59:41.224028Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 155 packets, 1692685 bytes\n2026-03-10T17:59:43.143526Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.94\n2026-03-10T17:59:43.229472Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1694357 bytes\n2026-03-10T17:59:45.143704Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.50\n2026-03-10T17:59:45.235565Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1695353 bytes\n2026-03-10T17:59:47.145927Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.47\n2026-03-10T17:59:47.240859Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 155 packets, 1716743 bytes\n2026-03-10T17:59:49.146465Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.49\n2026-03-10T17:59:49.244518Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 153 packets, 1667833 bytes\n2026-03-10T17:59:51.149364Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 33.45\n2026-03-10T17:59:51.252001Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1716350 bytes\n2026-03-10T17:59:53.150063Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 27.99\n2026-03-10T17:59:53.254171Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692085 bytes\n2026-03-10T17:59:55.151150Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.48\n2026-03-10T17:59:55.283666Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.9 pps, 154 packets, 1692114 bytes\n2026-03-10T17:59:57.152487Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T17:59:57.289390Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.8 pps, 156 packets, 1719994 bytes\n2026-03-10T17:59:59.251472Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 32.87\n2026-03-10T17:59:59.294366Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692184 bytes\n2026-03-10T18:00:01.256271Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:00:01.298223Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1691938 bytes\n2026-03-10T18:00:03.259102Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 32.45\n2026-03-10T18:00:03.303497Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692241 bytes\n2026-03-10T18:00:05.259480Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.99\n2026-03-10T18:00:05.314938Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1721842 bytes\n2026-03-10T18:00:07.263718Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.94\n2026-03-10T18:00:07.315587Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1694079 bytes\n2026-03-10T18:00:09.264717Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.98\n2026-03-10T18:00:09.320019Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.3 pps, 153 packets, 1664938 bytes\n2026-03-10T18:00:11.266694Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.97\n2026-03-10T18:00:11.322031Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1719331 bytes\n2026-03-10T18:00:13.270655Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.94\n2026-03-10T18:00:13.330490Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1692103 bytes\n2026-03-10T18:00:15.273846Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.95\n2026-03-10T18:00:15.340556Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1692909 bytes\n2026-03-10T18:00:17.276122Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:00:17.340920Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 153 packets, 1691512 bytes\n2026-03-10T18:00:19.283064Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.39\n2026-03-10T18:00:19.345688Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692138 bytes\n2026-03-10T18:00:21.294531Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:00:21.354253Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1719144 bytes\n2026-03-10T18:00:23.325594Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.03\n2026-03-10T18:00:23.356080Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692234 bytes\n2026-03-10T18:00:25.328480Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:00:25.361238Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692212 bytes\n2026-03-10T18:00:27.328725Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:00:27.367926Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1692331 bytes\n2026-03-10T18:00:29.359402Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.04\n2026-03-10T18:00:29.376032Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1719243 bytes\n2026-03-10T18:00:31.360888Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:00:31.376542Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.5 pps, 153 packets, 1665135 bytes\n2026-03-10T18:00:33.384513Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1719121 bytes\n2026-03-10T18:00:33.388322Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.09\n2026-03-10T18:00:35.389556Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692160 bytes\n2026-03-10T18:00:35.394973Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:00:37.398217Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1692114 bytes\n2026-03-10T18:00:37.426772Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.02\n2026-03-10T18:00:39.422741Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 156 packets, 1719872 bytes\n2026-03-10T18:00:39.431823Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:00:41.426420Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692208 bytes\n2026-03-10T18:00:41.458026Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.11\n2026-03-10T18:00:43.431651Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692143 bytes\n2026-03-10T18:00:43.461433Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:00:45.435342Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692196 bytes\n2026-03-10T18:00:45.490850Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.06\n2026-03-10T18:00:47.444578Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1719348 bytes\n2026-03-10T18:00:47.500598Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:00:49.451568Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1691933 bytes\n2026-03-10T18:00:49.518938Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.22\n2026-03-10T18:00:51.466073Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 154 packets, 1692116 bytes\n2026-03-10T18:00:51.526423Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:00:53.483741Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 156 packets, 1720070 bytes\n2026-03-10T18:00:53.528239Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:00:55.484635Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.5 pps, 153 packets, 1665122 bytes\n2026-03-10T18:00:55.560199Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.02\n2026-03-10T18:00:57.487412Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1718972 bytes\n2026-03-10T18:00:57.563628Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:00:59.495721Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1692107 bytes\n2026-03-10T18:00:59.587497Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.14\n2026-03-10T18:01:01.498596Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692256 bytes\n2026-03-10T18:01:01.599426Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.82\n2026-03-10T18:01:03.510089Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1719249 bytes\n2026-03-10T18:01:03.617884Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.22\n2026-03-10T18:01:05.514902Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1691896 bytes\n2026-03-10T18:01:05.628346Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:01:07.518245Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692129 bytes\n2026-03-10T18:01:07.650438Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.17\n2026-03-10T18:01:09.521526Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692245 bytes\n2026-03-10T18:01:09.663358Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.81\n2026-03-10T18:01:11.527393Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1692013 bytes\n2026-03-10T18:01:11.663646Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:01:13.531572Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692220 bytes\n2026-03-10T18:01:13.694702Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.03\n2026-03-10T18:01:15.536151Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1691953 bytes\n2026-03-10T18:01:15.714396Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.20\n2026-03-10T18:01:17.543037Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.2 pps, 155 packets, 1719273 bytes\n2026-03-10T18:01:17.723428Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:01:19.547629Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692226 bytes\n2026-03-10T18:01:19.723767Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:01:21.553571Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1692010 bytes\n2026-03-10T18:01:21.725193Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:01:23.556734Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692053 bytes\n2026-03-10T18:01:23.760139Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:01:25.561779Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692172 bytes\n2026-03-10T18:01:25.762559Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:01:27.569470Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1692101 bytes\n2026-03-10T18:01:27.794100Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.03\n2026-03-10T18:01:29.579456Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1719231 bytes\n2026-03-10T18:01:29.796895Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:01:31.582383Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692138 bytes\n2026-03-10T18:01:31.822855Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.11\n2026-03-10T18:01:33.587019Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692049 bytes\n2026-03-10T18:01:33.825210Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:01:35.587177Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1692212 bytes\n2026-03-10T18:01:35.860679Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:01:37.595234Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1692064 bytes\n2026-03-10T18:01:37.861179Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:01:39.603515Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1692104 bytes\n2026-03-10T18:01:39.885013Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.14\n2026-03-10T18:01:41.606974Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1719205 bytes\n2026-03-10T18:01:41.893636Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:01:43.614529Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1692000 bytes\n2026-03-10T18:01:43.895954Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:01:45.618635Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692254 bytes\n2026-03-10T18:01:45.922868Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.10\n2026-03-10T18:01:47.621545Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692057 bytes\n2026-03-10T18:01:47.929410Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:01:49.625751Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692184 bytes\n2026-03-10T18:01:49.959501Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.05\n2026-03-10T18:01:51.635020Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1692754 bytes\n2026-03-10T18:01:51.959540Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:01:53.640941Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1718723 bytes\n2026-03-10T18:01:53.990215Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.04\n2026-03-10T18:01:55.646868Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1690771 bytes\n2026-03-10T18:01:55.993838Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:01:57.649856Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692147 bytes\n2026-03-10T18:01:57.995350Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:01:59.653019Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692103 bytes\n2026-03-10T18:02:00.029208Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:02:01.661485Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1694533 bytes\n2026-03-10T18:02:02.048378Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.21\n2026-03-10T18:02:03.667100Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1694307 bytes\n2026-03-10T18:02:04.060587Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.82\n2026-03-10T18:02:05.674922Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.2 pps, 155 packets, 1720126 bytes\n2026-03-10T18:02:06.062455Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:02:07.677057Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692224 bytes\n2026-03-10T18:02:08.090785Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.07\n2026-03-10T18:02:09.679181Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1692194 bytes\n2026-03-10T18:02:10.093346Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:02:11.685263Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1692223 bytes\n2026-03-10T18:02:12.118391Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.12\n2026-03-10T18:02:13.694096Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1692070 bytes\n2026-03-10T18:02:14.122948Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:02:15.698350Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692068 bytes\n2026-03-10T18:02:16.126920Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:02:17.702778Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 155 packets, 1692920 bytes\n2026-03-10T18:02:18.155943Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.06\n2026-03-10T18:02:19.709412Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1718466 bytes\n2026-03-10T18:02:20.159982Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:02:21.713974Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692100 bytes\n2026-03-10T18:02:22.192211Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.02\n2026-03-10T18:02:23.719056Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692220 bytes\n2026-03-10T18:02:24.192523Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:02:25.722941Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1698243 bytes\n2026-03-10T18:02:26.224196Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.02\n2026-03-10T18:02:27.730767Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1692853 bytes\n2026-03-10T18:02:28.230032Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:02:29.737086Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1692053 bytes\n2026-03-10T18:02:30.250650Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.19\n2026-03-10T18:02:31.741156Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.3 pps, 153 packets, 1691450 bytes\n2026-03-10T18:02:32.259045Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:02:33.744851Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1719906 bytes\n2026-03-10T18:02:34.283238Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.14\n2026-03-10T18:02:35.751700Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1692232 bytes\n2026-03-10T18:02:36.293471Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:02:37.752224Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1692002 bytes\n2026-03-10T18:02:38.293524Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:02:39.752441Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 9.0 Mbps, 77.0 pps, 154 packets, 2238365 bytes\n2026-03-10T18:02:40.293853Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.50\n2026-03-10T18:02:41.784174Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.9 Mbps, 75.8 pps, 154 packets, 1495737 bytes\n2026-03-10T18:02:42.296118Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.47\n2026-03-10T18:02:43.791015Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.7 pps, 156 packets, 1696363 bytes\n2026-03-10T18:02:44.302870Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:02:45.809330Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.3 pps, 154 packets, 1724664 bytes\n2026-03-10T18:02:46.390324Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.18\n2026-03-10T18:02:47.824153Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 77.4 pps, 156 packets, 1599239 bytes\n2026-03-10T18:02:48.397853Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.88\n2026-03-10T18:02:49.828154Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1693784 bytes\n2026-03-10T18:02:50.401829Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:02:51.835475Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.2 pps, 155 packets, 1764177 bytes\n2026-03-10T18:02:52.483868Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.30\n2026-03-10T18:02:53.836608Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 153 packets, 1708096 bytes\n2026-03-10T18:02:54.508850Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.62\n2026-03-10T18:02:55.854233Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.8 pps, 155 packets, 1754385 bytes\n2026-03-10T18:02:56.512793Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:02:57.864562Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.6 pps, 154 packets, 1607366 bytes\n2026-03-10T18:02:58.530987Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.73\n2026-03-10T18:02:59.879917Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 155 packets, 1705975 bytes\n2026-03-10T18:03:00.560896Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.05\n2026-03-10T18:03:01.897489Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 155 packets, 1719349 bytes\n2026-03-10T18:03:02.560964Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:03:03.904435Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.2 pps, 155 packets, 1631312 bytes\n2026-03-10T18:03:04.563574Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:03:05.915689Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.1 pps, 155 packets, 1733413 bytes\n2026-03-10T18:03:06.570921Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:03:07.915890Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.0 pps, 154 packets, 1779938 bytes\n2026-03-10T18:03:08.601586Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.53\n2026-03-10T18:03:09.924877Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.2 Mbps, 76.7 pps, 154 packets, 1552650 bytes\n2026-03-10T18:03:10.666013Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.55\n2026-03-10T18:03:11.933573Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 77.2 pps, 155 packets, 1832387 bytes\n2026-03-10T18:03:12.672919Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:03:13.936526Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.9 pps, 154 packets, 1627873 bytes\n2026-03-10T18:03:14.677128Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.44\n2026-03-10T18:03:15.943590Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1677995 bytes\n2026-03-10T18:03:16.691188Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:03:17.945444Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 153 packets, 1689846 bytes\n2026-03-10T18:03:18.703733Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.31\n2026-03-10T18:03:19.948597Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.4 pps, 155 packets, 1645970 bytes\n2026-03-10T18:03:20.780502Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.37\n2026-03-10T18:03:21.964977Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 154 packets, 1671007 bytes\n2026-03-10T18:03:22.797456Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.74\n2026-03-10T18:03:23.986702Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.2 pps, 156 packets, 1734176 bytes\n2026-03-10T18:03:24.799618Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.97\n2026-03-10T18:03:26.004842Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 75.8 pps, 153 packets, 1648625 bytes\n2026-03-10T18:03:26.805549Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:03:28.021239Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.9 pps, 157 packets, 1781612 bytes\n2026-03-10T18:03:28.895700Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.14\n2026-03-10T18:03:30.021938Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 154 packets, 1727970 bytes\n2026-03-10T18:03:30.900435Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.93\n2026-03-10T18:03:32.025068Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1660199 bytes\n2026-03-10T18:03:32.962713Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.58\n2026-03-10T18:03:34.031081Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.8 pps, 154 packets, 1616734 bytes\n2026-03-10T18:03:34.970173Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:03:36.035841Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1738109 bytes\n2026-03-10T18:03:36.971615Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:03:38.040453Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.3 pps, 153 packets, 1744213 bytes\n2026-03-10T18:03:38.972821Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:03:40.046932Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.8 pps, 154 packets, 1746161 bytes\n2026-03-10T18:03:40.973466Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:03:42.049440Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.4 pps, 155 packets, 1673096 bytes\n2026-03-10T18:03:42.977133Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:03:44.067878Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 76.3 pps, 154 packets, 1843863 bytes\n2026-03-10T18:03:44.979802Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:03:46.075137Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1650423 bytes\n2026-03-10T18:03:47.001610Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.17\n2026-03-10T18:03:48.077880Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1674792 bytes\n2026-03-10T18:03:49.007870Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:03:50.081473Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.2 Mbps, 76.9 pps, 154 packets, 1540869 bytes\n2026-03-10T18:03:51.011949Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.44\n2026-03-10T18:03:52.083906Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1729102 bytes\n2026-03-10T18:03:53.053877Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:03:54.085112Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1682584 bytes\n2026-03-10T18:03:55.068082Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:03:56.107764Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 75.6 pps, 153 packets, 1750588 bytes\n2026-03-10T18:03:57.091625Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.65\n2026-03-10T18:03:58.116135Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 78.2 pps, 157 packets, 1653425 bytes\n2026-03-10T18:03:59.094842Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.95\n2026-03-10T18:04:00.122841Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 76.7 pps, 154 packets, 1865029 bytes\n2026-03-10T18:04:01.106025Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.84\n2026-03-10T18:04:02.126738Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 76.9 pps, 154 packets, 1575104 bytes\n2026-03-10T18:04:03.183996Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:04:04.136538Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.1 pps, 155 packets, 1809235 bytes\n2026-03-10T18:04:05.197094Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.80\n2026-03-10T18:04:06.168109Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.1 Mbps, 76.3 pps, 155 packets, 1545033 bytes\n2026-03-10T18:04:07.198358Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:04:08.190455Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 155 packets, 1721967 bytes\n2026-03-10T18:04:09.205309Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.40\n2026-03-10T18:04:10.203260Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 155 packets, 1738917 bytes\n2026-03-10T18:04:11.252114Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.29\n2026-03-10T18:04:12.213836Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.6 pps, 154 packets, 1660076 bytes\n2026-03-10T18:04:13.255152Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:04:14.223591Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.6 pps, 156 packets, 1709116 bytes\n2026-03-10T18:04:15.263769Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:04:16.229862Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1721410 bytes\n2026-03-10T18:04:17.267917Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:04:18.234474Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 155 packets, 1702520 bytes\n2026-03-10T18:04:19.297906Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.54\n2026-03-10T18:04:20.235398Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.5 pps, 153 packets, 1732102 bytes\n2026-03-10T18:04:21.300780Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:04:22.242965Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.7 pps, 154 packets, 1763091 bytes\n2026-03-10T18:04:23.366496Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.53\n2026-03-10T18:04:24.248009Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 155 packets, 1644578 bytes\n2026-03-10T18:04:25.397026Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.04\n2026-03-10T18:04:26.281352Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 76.2 pps, 155 packets, 1591121 bytes\n2026-03-10T18:04:27.399160Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.47\n2026-03-10T18:04:28.288507Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.2 pps, 155 packets, 1770392 bytes\n2026-03-10T18:04:29.401939Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.46\n2026-03-10T18:04:30.305678Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 155 packets, 1732497 bytes\n2026-03-10T18:04:31.465675Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.04\n2026-03-10T18:04:32.312527Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1652269 bytes\n2026-03-10T18:04:33.479269Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.29\n2026-03-10T18:04:34.322357Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.1 pps, 155 packets, 1729401 bytes\n2026-03-10T18:04:35.644938Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 26.78\n2026-03-10T18:04:36.345162Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.1 pps, 156 packets, 1744698 bytes\n2026-03-10T18:04:37.666647Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 33.14\n2026-03-10T18:04:38.346826Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1662297 bytes\n2026-03-10T18:04:39.677168Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:04:40.376095Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 155 packets, 1692763 bytes\n2026-03-10T18:04:41.690745Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.29\n2026-03-10T18:04:42.382478Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.3 pps, 155 packets, 1801866 bytes\n2026-03-10T18:04:43.703771Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.81\n2026-03-10T18:04:44.382876Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1693535 bytes\n2026-03-10T18:04:45.710624Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:04:46.384161Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.5 pps, 153 packets, 1682390 bytes\n2026-03-10T18:04:47.751965Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:04:48.397159Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 155 packets, 1715480 bytes\n2026-03-10T18:04:49.761365Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:04:50.402466Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1661061 bytes\n2026-03-10T18:04:51.765438Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:04:52.403335Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.0 pps, 154 packets, 1620292 bytes\n2026-03-10T18:04:53.766561Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:04:54.414306Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1700845 bytes\n2026-03-10T18:04:55.767371Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:04:56.422158Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.2 pps, 155 packets, 1812885 bytes\n2026-03-10T18:04:57.772449Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:04:58.424933Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.4 pps, 153 packets, 1626007 bytes\n2026-03-10T18:04:59.779268Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:05:00.427764Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1654647 bytes\n2026-03-10T18:05:01.790533Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.33\n2026-03-10T18:05:02.444992Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.8 pps, 155 packets, 1789387 bytes\n2026-03-10T18:05:03.796814Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:05:04.453522Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1720257 bytes\n2026-03-10T18:05:05.805399Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:05:06.461538Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1679601 bytes\n2026-03-10T18:05:07.862765Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.65\n2026-03-10T18:05:08.481435Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 155 packets, 1691911 bytes\n2026-03-10T18:05:09.874728Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:05:10.504773Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 77.1 pps, 156 packets, 1880594 bytes\n2026-03-10T18:05:11.905047Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.04\n2026-03-10T18:05:12.533952Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 155 packets, 1708715 bytes\n2026-03-10T18:05:13.908052Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:05:14.547973Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 78.0 pps, 157 packets, 1648765 bytes\n2026-03-10T18:05:15.962773Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.69\n2026-03-10T18:05:16.577492Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 155 packets, 1699419 bytes\n2026-03-10T18:05:17.966677Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:05:18.604590Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.5 pps, 155 packets, 1751478 bytes\n2026-03-10T18:05:19.971692Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:05:20.609055Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.3 pps, 153 packets, 1709996 bytes\n2026-03-10T18:05:21.992434Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.68\n2026-03-10T18:05:22.622040Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 78.0 pps, 157 packets, 1694255 bytes\n2026-03-10T18:05:23.997870Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:05:24.641769Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 156 packets, 1722129 bytes\n2026-03-10T18:05:26.005483Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:05:26.644486Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 153 packets, 1681515 bytes\n2026-03-10T18:05:28.052874Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:05:28.650624Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1724692 bytes\n2026-03-10T18:05:30.054636Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:05:30.665596Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 154 packets, 1692948 bytes\n2026-03-10T18:05:32.084356Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.56\n2026-03-10T18:05:32.679159Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1692258 bytes\n2026-03-10T18:05:34.095971Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.82\n2026-03-10T18:05:34.684181Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1739635 bytes\n2026-03-10T18:05:36.105896Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.35\n2026-03-10T18:05:36.687453Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 153 packets, 1660572 bytes\n2026-03-10T18:05:38.168437Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.06\n2026-03-10T18:05:38.700912Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 78.0 pps, 157 packets, 1750467 bytes\n2026-03-10T18:05:40.193535Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.62\n2026-03-10T18:05:40.706224Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1692983 bytes\n2026-03-10T18:05:42.201074Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:05:42.708905Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.4 pps, 153 packets, 1746067 bytes\n2026-03-10T18:05:44.263537Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.58\n2026-03-10T18:05:44.712775Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.4 pps, 155 packets, 1692617 bytes\n2026-03-10T18:05:46.264354Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:05:46.720476Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1703451 bytes\n2026-03-10T18:05:48.267728Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:05:48.729590Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1719914 bytes\n2026-03-10T18:05:50.306036Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 26.49\n2026-03-10T18:05:50.853345Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.1 Mbps, 70.2 pps, 149 packets, 1616623 bytes\n2026-03-10T18:05:52.526807Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.72\n2026-03-10T18:05:52.857518Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 73.8 pps, 148 packets, 1571933 bytes\n2026-03-10T18:05:54.565973Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 32.37\n2026-03-10T18:05:54.870727Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 79.5 pps, 160 packets, 1863494 bytes\n2026-03-10T18:05:56.659684Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.66\n2026-03-10T18:05:56.877900Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 82.2 pps, 165 packets, 1855691 bytes\n2026-03-10T18:05:58.670882Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:05:58.975416Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.7 Mbps, 69.6 pps, 146 packets, 1494024 bytes\n2026-03-10T18:06:00.683059Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 32.80\n2026-03-10T18:06:00.994894Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 86.2 pps, 174 packets, 1829155 bytes\n2026-03-10T18:06:02.692780Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.85\n2026-03-10T18:06:02.999400Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.8 pps, 156 packets, 1745800 bytes\n2026-03-10T18:06:04.699580Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:06:05.002118Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.9 pps, 154 packets, 1761770 bytes\n2026-03-10T18:06:06.754781Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.68\n2026-03-10T18:06:07.008566Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 153 packets, 1676921 bytes\n2026-03-10T18:06:08.758594Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:06:09.012509Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 77.3 pps, 155 packets, 1582307 bytes\n2026-03-10T18:06:10.767676Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:06:11.018464Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1685737 bytes\n2026-03-10T18:06:12.780446Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.31\n2026-03-10T18:06:13.032101Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1681773 bytes\n2026-03-10T18:06:14.793032Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.31\n2026-03-10T18:06:15.046284Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1692252 bytes\n2026-03-10T18:06:16.827037Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.50\n2026-03-10T18:06:17.067499Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.2 pps, 154 packets, 1661517 bytes\n2026-03-10T18:06:18.874059Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:06:19.087077Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.2 pps, 156 packets, 1765273 bytes\n2026-03-10T18:06:20.889093Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.77\n2026-03-10T18:06:21.090202Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.9 pps, 154 packets, 1775801 bytes\n2026-03-10T18:06:22.894401Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:06:23.100849Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 77.1 pps, 155 packets, 1620975 bytes\n2026-03-10T18:06:24.896695Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.47\n2026-03-10T18:06:25.118668Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 155 packets, 1734951 bytes\n2026-03-10T18:06:26.956172Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.10\n2026-03-10T18:06:27.123492Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.8 pps, 154 packets, 1744295 bytes\n2026-03-10T18:06:28.997135Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.40\n2026-03-10T18:06:29.162684Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 75.5 pps, 154 packets, 1602814 bytes\n2026-03-10T18:06:31.001657Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.93\n2026-03-10T18:06:31.169648Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.7 pps, 156 packets, 1761157 bytes\n2026-03-10T18:06:33.009356Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:06:33.175007Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.8 pps, 154 packets, 1641734 bytes\n2026-03-10T18:06:35.060557Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.74\n2026-03-10T18:06:35.180554Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1721882 bytes\n2026-03-10T18:06:37.065984Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:06:37.188568Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.7 pps, 156 packets, 1783859 bytes\n2026-03-10T18:06:39.068223Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:06:39.200211Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1705088 bytes\n2026-03-10T18:06:41.073118Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.43\n2026-03-10T18:06:41.204112Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.9 pps, 154 packets, 1620522 bytes\n2026-03-10T18:06:43.075831Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:06:43.213343Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1720340 bytes\n2026-03-10T18:06:45.076071Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:06:45.218063Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1696012 bytes\n2026-03-10T18:06:47.077757Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:06:47.220308Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1690620 bytes\n2026-03-10T18:06:49.093411Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.26\n2026-03-10T18:06:49.224722Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.8 pps, 154 packets, 1773989 bytes\n2026-03-10T18:06:51.094857Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:06:51.230634Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1649095 bytes\n2026-03-10T18:06:53.098500Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.45\n2026-03-10T18:06:53.236914Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.3 pps, 155 packets, 1621609 bytes\n2026-03-10T18:06:55.172532Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:06:55.243325Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1697226 bytes\n2026-03-10T18:06:57.183770Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.33\n2026-03-10T18:06:57.253600Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1677673 bytes\n2026-03-10T18:06:59.190171Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:06:59.272213Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.3 pps, 154 packets, 1731420 bytes\n2026-03-10T18:07:01.190837Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.49\n2026-03-10T18:07:01.281270Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.6 pps, 156 packets, 1767719 bytes\n2026-03-10T18:07:03.194185Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.95\n2026-03-10T18:07:03.319205Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 75.6 pps, 154 packets, 1657436 bytes\n2026-03-10T18:07:05.194413Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:07:05.319266Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 78.5 pps, 157 packets, 1735984 bytes\n2026-03-10T18:07:07.231399Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:07:07.320713Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1665958 bytes\n2026-03-10T18:07:09.252724Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.68\n2026-03-10T18:07:09.322452Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 75.9 pps, 152 packets, 1692618 bytes\n2026-03-10T18:07:11.267640Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.78\n2026-03-10T18:07:11.331066Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.7 pps, 156 packets, 1735544 bytes\n2026-03-10T18:07:13.279140Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.33\n2026-03-10T18:07:13.332980Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.9 pps, 154 packets, 1742842 bytes\n2026-03-10T18:07:15.290407Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.33\n2026-03-10T18:07:15.338914Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1664341 bytes\n2026-03-10T18:07:17.290609Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.50\n2026-03-10T18:07:17.344018Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1667800 bytes\n2026-03-10T18:07:19.344104Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 154 packets, 1651198 bytes\n2026-03-10T18:07:19.369023Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:07:21.370250Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:07:21.370494Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.0 pps, 154 packets, 1733927 bytes\n2026-03-10T18:07:23.370436Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:07:23.370654Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.0 pps, 154 packets, 1764355 bytes\n2026-03-10T18:07:25.381544Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:07:25.381900Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 76.6 pps, 154 packets, 1581354 bytes\n2026-03-10T18:07:27.389959Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:07:27.390360Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.7 pps, 154 packets, 1747272 bytes\n2026-03-10T18:07:29.391114Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:07:29.391392Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 78.0 pps, 156 packets, 1697140 bytes\n2026-03-10T18:07:31.391313Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:07:31.391572Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.0 pps, 154 packets, 1740847 bytes\n2026-03-10T18:07:33.406769Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.9 pps, 155 packets, 1621584 bytes\n2026-03-10T18:07:33.504627Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.86\n2026-03-10T18:07:35.407705Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 75.5 pps, 151 packets, 1717470 bytes\n2026-03-10T18:07:35.601211Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.05\n2026-03-10T18:07:37.416748Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 78.1 pps, 157 packets, 1689824 bytes\n2026-03-10T18:07:37.655141Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.67\n2026-03-10T18:07:39.424802Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1706676 bytes\n2026-03-10T18:07:39.669638Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.78\n2026-03-10T18:07:41.426700Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1668756 bytes\n2026-03-10T18:07:41.675809Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.41\n2026-03-10T18:07:43.429750Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1702570 bytes\n2026-03-10T18:07:43.689892Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.29\n2026-03-10T18:07:45.440858Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1691718 bytes\n2026-03-10T18:07:45.690003Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.00\n2026-03-10T18:07:47.443487Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1708367 bytes\n2026-03-10T18:07:47.695145Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:07:49.472966Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 155 packets, 1715506 bytes\n2026-03-10T18:07:49.720468Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.12\n2026-03-10T18:07:51.478604Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.8 pps, 154 packets, 1762270 bytes\n2026-03-10T18:07:51.753147Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.52\n2026-03-10T18:07:53.479125Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.5 pps, 155 packets, 1686729 bytes\n2026-03-10T18:07:53.753216Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:07:55.486316Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1724116 bytes\n2026-03-10T18:07:55.765017Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.82\n2026-03-10T18:07:57.495432Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.1 pps, 155 packets, 1653680 bytes\n2026-03-10T18:07:57.774032Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:07:59.501439Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 153 packets, 1689113 bytes\n2026-03-10T18:07:59.791748Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.23\n2026-03-10T18:08:01.506704Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 155 packets, 1686479 bytes\n2026-03-10T18:08:01.796402Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:08:03.517099Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1707358 bytes\n2026-03-10T18:08:03.800060Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:08:05.534156Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 156 packets, 1717570 bytes\n2026-03-10T18:08:05.852582Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.72\n2026-03-10T18:08:07.542634Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1676771 bytes\n2026-03-10T18:08:07.860669Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:08:09.546012Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1696918 bytes\n2026-03-10T18:08:09.861306Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:08:11.561397Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 154 packets, 1711587 bytes\n2026-03-10T18:08:11.894544Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.51\n2026-03-10T18:08:13.580659Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 156 packets, 1743222 bytes\n2026-03-10T18:08:13.962145Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.47\n2026-03-10T18:08:15.581478Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 154 packets, 1648513 bytes\n2026-03-10T18:08:15.994669Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.52\n2026-03-10T18:08:17.592957Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.1 pps, 153 packets, 1690590 bytes\n2026-03-10T18:08:18.098694Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:08:19.594888Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.9 pps, 156 packets, 1700978 bytes\n2026-03-10T18:08:20.105202Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.40\n2026-03-10T18:08:21.597677Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.4 pps, 153 packets, 1635690 bytes\n2026-03-10T18:08:22.149686Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.33\n2026-03-10T18:08:23.602625Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 77.3 pps, 155 packets, 1817911 bytes\n2026-03-10T18:08:24.156368Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:08:25.610403Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1720668 bytes\n2026-03-10T18:08:26.170373Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:08:27.615597Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 155 packets, 1642453 bytes\n2026-03-10T18:08:28.180427Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.35\n2026-03-10T18:08:29.616523Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.5 pps, 153 packets, 1781338 bytes\n2026-03-10T18:08:30.193863Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.30\n2026-03-10T18:08:31.624354Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.7 pps, 154 packets, 1637848 bytes\n2026-03-10T18:08:32.194582Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:08:33.629441Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1678124 bytes\n2026-03-10T18:08:34.262654Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.50\n2026-03-10T18:08:35.633174Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1687606 bytes\n2026-03-10T18:08:36.289040Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.60\n2026-03-10T18:08:37.639110Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1718450 bytes\n2026-03-10T18:08:38.295360Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:08:39.668763Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 155 packets, 1680922 bytes\n2026-03-10T18:08:40.352696Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.65\n2026-03-10T18:08:41.672600Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1703942 bytes\n2026-03-10T18:08:42.358267Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:08:43.684959Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.5 pps, 156 packets, 1740360 bytes\n2026-03-10T18:08:44.366972Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:08:45.710969Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 156 packets, 1667553 bytes\n2026-03-10T18:08:46.380645Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.30\n2026-03-10T18:08:47.729122Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 155 packets, 1676381 bytes\n2026-03-10T18:08:48.386689Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.91\n2026-03-10T18:08:49.746690Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.8 pps, 155 packets, 1767761 bytes\n2026-03-10T18:08:50.401476Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.79\n2026-03-10T18:08:51.766790Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 155 packets, 1669699 bytes\n2026-03-10T18:08:52.470658Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.45\n2026-03-10T18:08:53.768965Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 153 packets, 1696557 bytes\n2026-03-10T18:08:54.505514Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.47\n2026-03-10T18:08:55.778688Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.6 pps, 156 packets, 1734677 bytes\n2026-03-10T18:08:56.562878Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.65\n2026-03-10T18:08:57.796911Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 154 packets, 1696454 bytes\n2026-03-10T18:08:58.567033Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.44\n2026-03-10T18:08:59.810344Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.5 pps, 154 packets, 1630686 bytes\n2026-03-10T18:09:00.598732Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.02\n2026-03-10T18:09:01.812000Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.9 pps, 154 packets, 1780583 bytes\n2026-03-10T18:09:02.608754Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.86\n2026-03-10T18:09:03.816891Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.8 pps, 156 packets, 1638879 bytes\n2026-03-10T18:09:04.611054Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.96\n2026-03-10T18:09:05.820817Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1681856 bytes\n2026-03-10T18:09:06.673520Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.58\n2026-03-10T18:09:07.825448Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1725091 bytes\n2026-03-10T18:09:08.696395Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.66\n2026-03-10T18:09:09.833080Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1699143 bytes\n2026-03-10T18:09:10.745074Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.75\n2026-03-10T18:09:11.838862Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1703153 bytes\n2026-03-10T18:09:12.756737Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:09:13.842138Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1707365 bytes\n2026-03-10T18:09:14.765917Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.37\n2026-03-10T18:09:15.846038Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1682973 bytes\n2026-03-10T18:09:16.767916Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:09:17.869809Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.1 pps, 154 packets, 1672220 bytes\n2026-03-10T18:09:18.790815Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.65\n2026-03-10T18:09:19.876515Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.7 pps, 156 packets, 1756273 bytes\n2026-03-10T18:09:20.795852Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:09:21.888471Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.0 pps, 153 packets, 1762229 bytes\n2026-03-10T18:09:22.857765Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.58\n2026-03-10T18:09:23.890158Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.9 pps, 156 packets, 1661175 bytes\n2026-03-10T18:09:24.865416Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:09:25.915849Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 75.5 pps, 153 packets, 1646173 bytes\n2026-03-10T18:09:26.868589Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:09:27.919908Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.8 pps, 156 packets, 1810524 bytes\n2026-03-10T18:09:28.870765Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.47\n2026-03-10T18:09:29.932550Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.5 pps, 154 packets, 1607227 bytes\n2026-03-10T18:09:30.871349Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.49\n2026-03-10T18:09:31.940376Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 77.2 pps, 155 packets, 1823833 bytes\n2026-03-10T18:09:32.890806Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.22\n2026-03-10T18:09:33.965778Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.2 Mbps, 76.5 pps, 155 packets, 1572465 bytes\n2026-03-10T18:09:34.904376Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:09:35.978607Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.5 pps, 154 packets, 1749787 bytes\n2026-03-10T18:09:36.994153Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.15\n2026-03-10T18:09:37.990367Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.5 pps, 156 packets, 1666405 bytes\n2026-03-10T18:09:39.057351Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.54\n2026-03-10T18:09:39.994254Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1731226 bytes\n2026-03-10T18:09:41.081007Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.65\n2026-03-10T18:09:42.001489Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1699088 bytes\n2026-03-10T18:09:43.087047Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.91\n2026-03-10T18:09:44.008523Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1688767 bytes\n2026-03-10T18:09:45.097153Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.85\n2026-03-10T18:09:46.009983Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.9 pps, 154 packets, 1766523 bytes\n2026-03-10T18:09:47.168858Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.41\n2026-03-10T18:09:48.013992Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.8 pps, 154 packets, 1592925 bytes\n2026-03-10T18:09:49.190678Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.67\n2026-03-10T18:09:50.025412Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.1 pps, 155 packets, 1749497 bytes\n2026-03-10T18:09:51.199234Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:09:52.027901Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1679506 bytes\n2026-03-10T18:09:53.261017Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.59\n2026-03-10T18:09:54.034006Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.8 pps, 154 packets, 1630944 bytes\n2026-03-10T18:09:55.286585Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.61\n2026-03-10T18:09:56.039411Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1723631 bytes\n2026-03-10T18:09:57.300603Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:09:58.041452Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.9 pps, 154 packets, 1772710 bytes\n2026-03-10T18:09:59.380454Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.33\n2026-03-10T18:10:00.065608Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.1 pps, 154 packets, 1633727 bytes\n2026-03-10T18:10:01.390915Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.84\n2026-03-10T18:10:02.100040Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 156 packets, 1731191 bytes\n2026-03-10T18:10:03.396703Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:10:04.103875Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.9 pps, 156 packets, 1722445 bytes\n2026-03-10T18:10:05.461864Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.54\n2026-03-10T18:10:06.119110Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 154 packets, 1693801 bytes\n2026-03-10T18:10:07.486033Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.63\n2026-03-10T18:10:08.133030Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 155 packets, 1671149 bytes\n2026-03-10T18:10:09.497987Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.82\n2026-03-10T18:10:10.141963Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1701149 bytes\n2026-03-10T18:10:11.522627Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.13\n2026-03-10T18:10:12.145430Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1720002 bytes\n2026-03-10T18:10:13.555203Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.52\n2026-03-10T18:10:14.158048Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.5 pps, 154 packets, 1612808 bytes\n2026-03-10T18:10:15.556367Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:10:16.166459Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.8 Mbps, 76.7 pps, 154 packets, 1949714 bytes\n2026-03-10T18:10:17.571285Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.27\n2026-03-10T18:10:18.167928Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.1 Mbps, 76.9 pps, 154 packets, 1526338 bytes\n2026-03-10T18:10:19.586529Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.27\n2026-03-10T18:10:20.178829Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.6 pps, 154 packets, 1669534 bytes\n2026-03-10T18:10:21.594031Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:10:22.188935Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.6 pps, 156 packets, 1754193 bytes\n2026-03-10T18:10:23.597340Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:10:24.201907Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1674585 bytes\n2026-03-10T18:10:25.650140Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.72\n2026-03-10T18:10:26.206775Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1702658 bytes\n2026-03-10T18:10:27.658165Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:10:28.209991Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.9 pps, 154 packets, 1748304 bytes\n2026-03-10T18:10:29.662225Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.44\n2026-03-10T18:10:30.213018Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.9 pps, 154 packets, 1632369 bytes\n2026-03-10T18:10:31.671335Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:10:32.220185Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1722836 bytes\n2026-03-10T18:10:33.691641Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.69\n2026-03-10T18:10:34.227469Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1697137 bytes\n2026-03-10T18:10:35.758267Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.52\n2026-03-10T18:10:36.242447Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 154 packets, 1678444 bytes\n2026-03-10T18:10:37.759279Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:10:38.272307Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.4 pps, 155 packets, 1759027 bytes\n2026-03-10T18:10:39.769256Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.35\n2026-03-10T18:10:40.281365Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.7 pps, 156 packets, 1670057 bytes\n2026-03-10T18:10:41.778593Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:10:42.292645Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1676751 bytes\n2026-03-10T18:10:43.782175Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:10:44.297472Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.3 pps, 153 packets, 1697990 bytes\n2026-03-10T18:10:45.795726Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.29\n2026-03-10T18:10:46.298108Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1686602 bytes\n2026-03-10T18:10:47.806804Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.34\n2026-03-10T18:10:48.370092Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.9 Mbps, 68.1 pps, 141 packets, 1529120 bytes\n2026-03-10T18:10:49.971566Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.18\n2026-03-10T18:10:50.375406Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 8.2 Mbps, 83.8 pps, 168 packets, 2060385 bytes\n2026-03-10T18:10:52.126574Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.23\n2026-03-10T18:10:52.478925Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 67.5 pps, 142 packets, 1691011 bytes\n2026-03-10T18:10:54.276165Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.24\n2026-03-10T18:10:54.544092Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.9 Mbps, 81.4 pps, 168 packets, 1515573 bytes\n2026-03-10T18:10:56.280328Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 34.43\n2026-03-10T18:10:56.564554Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 8.2 Mbps, 84.1 pps, 170 packets, 2062840 bytes\n2026-03-10T18:10:58.290772Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:10:58.568226Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1674718 bytes\n2026-03-10T18:11:00.293515Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:11:00.577605Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1694556 bytes\n2026-03-10T18:11:02.373930Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.32\n2026-03-10T18:11:02.592959Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.9 pps, 155 packets, 1759030 bytes\n2026-03-10T18:11:04.388596Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.78\n2026-03-10T18:11:04.605087Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.0 pps, 155 packets, 1639295 bytes\n2026-03-10T18:11:06.394347Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.91\n2026-03-10T18:11:06.608320Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1683332 bytes\n2026-03-10T18:11:08.394361Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:11:08.613330Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1652201 bytes\n2026-03-10T18:11:10.465834Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.45\n2026-03-10T18:11:10.623925Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 75.6 pps, 152 packets, 1806284 bytes\n2026-03-10T18:11:12.470618Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:11:12.639501Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 78.4 pps, 158 packets, 1621474 bytes\n2026-03-10T18:11:14.472131Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:11:14.665253Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.5 pps, 155 packets, 1670143 bytes\n2026-03-10T18:11:16.478371Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.41\n2026-03-10T18:11:16.681678Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.4 pps, 154 packets, 1748223 bytes\n2026-03-10T18:11:18.493726Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.27\n2026-03-10T18:11:18.697009Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 155 packets, 1740837 bytes\n2026-03-10T18:11:20.502600Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:11:20.711849Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.4 pps, 156 packets, 1670138 bytes\n2026-03-10T18:11:22.521619Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.21\n2026-03-10T18:11:22.733444Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.7 pps, 155 packets, 1768564 bytes\n2026-03-10T18:11:24.541557Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.70\n2026-03-10T18:11:24.739037Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 155 packets, 1678128 bytes\n2026-03-10T18:11:26.594998Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.22\n2026-03-10T18:11:26.765793Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.0 pps, 154 packets, 1702831 bytes\n2026-03-10T18:11:28.663774Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.45\n2026-03-10T18:11:28.776853Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.6 pps, 156 packets, 1749202 bytes\n2026-03-10T18:11:30.689588Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.60\n2026-03-10T18:11:30.779442Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1656095 bytes\n2026-03-10T18:11:32.693161Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:11:32.795653Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.9 pps, 153 packets, 1689617 bytes\n2026-03-10T18:11:34.743904Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.75\n2026-03-10T18:11:34.806743Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 78.1 pps, 157 packets, 1752126 bytes\n2026-03-10T18:11:36.753346Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:11:36.819191Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.5 pps, 154 packets, 1675696 bytes\n2026-03-10T18:11:38.759544Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:11:38.821215Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1675502 bytes\n2026-03-10T18:11:40.760490Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:11:40.824359Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.4 pps, 155 packets, 1705519 bytes\n2026-03-10T18:11:42.780563Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.69\n2026-03-10T18:11:42.830063Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1670004 bytes\n2026-03-10T18:11:44.790466Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:11:44.835277Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1666750 bytes\n2026-03-10T18:11:46.791027Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:11:46.838128Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1739157 bytes\n2026-03-10T18:11:48.795987Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:11:48.859487Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.2 pps, 154 packets, 1688790 bytes\n2026-03-10T18:11:50.857096Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.60\n2026-03-10T18:11:50.869838Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1713558 bytes\n2026-03-10T18:11:52.867871Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:11:52.883998Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.5 pps, 156 packets, 1676716 bytes\n2026-03-10T18:11:54.875008Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:11:54.886644Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1722002 bytes\n2026-03-10T18:11:56.884282Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:11:56.896749Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.6 pps, 154 packets, 1661505 bytes\n2026-03-10T18:11:58.887418Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:11:58.898083Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 154 packets, 1730293 bytes\n2026-03-10T18:12:00.900390Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1677685 bytes\n2026-03-10T18:12:00.939557Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.73\n2026-03-10T18:12:02.904675Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1718525 bytes\n2026-03-10T18:12:02.941261Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:12:04.906908Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1662624 bytes\n2026-03-10T18:12:04.949691Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:12:06.914953Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.7 pps, 154 packets, 1774192 bytes\n2026-03-10T18:12:06.959431Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:12:08.921658Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1679699 bytes\n2026-03-10T18:12:08.968116Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.37\n2026-03-10T18:12:10.928330Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1660185 bytes\n2026-03-10T18:12:10.989814Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.17\n2026-03-10T18:12:12.933284Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1661522 bytes\n2026-03-10T18:12:12.994330Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:12:14.935883Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.9 pps, 154 packets, 1767494 bytes\n2026-03-10T18:12:15.057462Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.57\n2026-03-10T18:12:16.963108Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.0 pps, 154 packets, 1638829 bytes\n2026-03-10T18:12:17.097383Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.41\n2026-03-10T18:12:18.972060Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.7 pps, 156 packets, 1784579 bytes\n2026-03-10T18:12:19.130621Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.99\n2026-03-10T18:12:20.976347Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.3 pps, 153 packets, 1645297 bytes\n2026-03-10T18:12:21.135280Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:12:22.980835Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1727240 bytes\n2026-03-10T18:12:23.165606Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.55\n2026-03-10T18:12:24.982886Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1656795 bytes\n2026-03-10T18:12:25.170516Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.43\n2026-03-10T18:12:26.990143Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1712653 bytes\n2026-03-10T18:12:27.171114Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:12:29.010234Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 75.7 pps, 153 packets, 1734774 bytes\n2026-03-10T18:12:29.186240Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.27\n2026-03-10T18:12:31.020862Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.6 pps, 156 packets, 1758153 bytes\n2026-03-10T18:12:31.189908Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:12:33.025652Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1643892 bytes\n2026-03-10T18:12:33.252435Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.58\n2026-03-10T18:12:35.032145Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1665322 bytes\n2026-03-10T18:12:35.255575Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.45\n2026-03-10T18:12:37.036736Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.3 pps, 155 packets, 1625995 bytes\n2026-03-10T18:12:37.267833Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.32\n2026-03-10T18:12:39.060803Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.1 pps, 154 packets, 1732498 bytes\n2026-03-10T18:12:39.273370Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.91\n2026-03-10T18:12:41.075913Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.4 pps, 156 packets, 1753626 bytes\n2026-03-10T18:12:41.287783Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:12:43.076101Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.0 pps, 152 packets, 1649882 bytes\n2026-03-10T18:12:43.361705Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.41\n2026-03-10T18:12:45.078095Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1707635 bytes\n2026-03-10T18:12:45.370673Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:12:47.087582Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.6 pps, 156 packets, 1761999 bytes\n2026-03-10T18:12:47.371394Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:12:49.092251Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 155 packets, 1650028 bytes\n2026-03-10T18:12:49.386431Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.27\n2026-03-10T18:12:51.100957Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1704390 bytes\n2026-03-10T18:12:51.388075Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:12:53.101258Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1682440 bytes\n2026-03-10T18:12:53.461402Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.42\n2026-03-10T18:12:55.108375Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.2 pps, 153 packets, 1685847 bytes\n2026-03-10T18:12:55.463604Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.47\n2026-03-10T18:12:57.110589Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1716170 bytes\n2026-03-10T18:12:57.464391Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:12:59.115541Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1723470 bytes\n2026-03-10T18:12:59.468945Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:13:01.124095Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1645638 bytes\n2026-03-10T18:13:01.473728Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:13:03.124665Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.5 pps, 153 packets, 1751405 bytes\n2026-03-10T18:13:03.495800Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.17\n2026-03-10T18:13:05.135849Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1700106 bytes\n2026-03-10T18:13:05.496429Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:13:07.137819Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.9 pps, 154 packets, 1631809 bytes\n2026-03-10T18:13:07.499122Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:13:09.157878Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.2 pps, 154 packets, 1779913 bytes\n2026-03-10T18:13:09.527342Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.08\n2026-03-10T18:13:11.168261Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1711730 bytes\n2026-03-10T18:13:11.556241Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.57\n2026-03-10T18:13:13.178840Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.6 pps, 156 packets, 1692748 bytes\n2026-03-10T18:13:13.557145Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:13:15.199151Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.2 pps, 154 packets, 1725955 bytes\n2026-03-10T18:13:15.584656Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.59\n2026-03-10T18:13:17.211757Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.5 pps, 156 packets, 1637095 bytes\n2026-03-10T18:13:17.587316Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.96\n2026-03-10T18:13:19.213193Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1664832 bytes\n2026-03-10T18:13:19.594698Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:13:21.218009Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.8 pps, 154 packets, 1775697 bytes\n2026-03-10T18:13:21.645554Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.74\n2026-03-10T18:13:23.222636Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1651095 bytes\n2026-03-10T18:13:23.654047Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.37\n2026-03-10T18:13:25.229086Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.8 pps, 154 packets, 1765776 bytes\n2026-03-10T18:13:25.658689Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.43\n2026-03-10T18:13:27.238352Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1685991 bytes\n2026-03-10T18:13:27.669413Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:13:29.241358Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.9 pps, 154 packets, 1602327 bytes\n2026-03-10T18:13:29.684621Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.27\n2026-03-10T18:13:31.257974Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.4 pps, 154 packets, 1768828 bytes\n2026-03-10T18:13:31.688988Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:13:33.273533Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.4 pps, 156 packets, 1714316 bytes\n2026-03-10T18:13:33.758366Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T18:13:35.285606Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.5 pps, 154 packets, 1626410 bytes\n2026-03-10T18:13:35.789102Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.53\n2026-03-10T18:13:37.302059Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.4 pps, 156 packets, 1706609 bytes\n2026-03-10T18:13:37.791583Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:13:39.303734Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.9 pps, 152 packets, 1678463 bytes\n2026-03-10T18:13:39.857849Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.52\n2026-03-10T18:13:41.313146Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.6 pps, 156 packets, 1740173 bytes\n2026-03-10T18:13:41.887663Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.54\n2026-03-10T18:13:43.319722Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1681352 bytes\n2026-03-10T18:13:43.891097Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:13:45.322015Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 153 packets, 1672586 bytes\n2026-03-10T18:13:45.892429Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T18:13:47.330024Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1684427 bytes\n2026-03-10T18:13:47.895716Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.45\n2026-03-10T18:13:49.336403Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1735717 bytes\n2026-03-10T18:13:49.973059Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.36\n2026-03-10T18:13:51.341116Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1659731 bytes\n2026-03-10T18:13:51.977560Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:13:53.357889Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 154 packets, 1699622 bytes\n2026-03-10T18:13:53.984808Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.89\n2026-03-10T18:13:55.362810Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1704545 bytes\n2026-03-10T18:13:55.989766Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:13:57.374431Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.1 pps, 155 packets, 1758570 bytes\n2026-03-10T18:13:58.052983Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.57\n2026-03-10T18:13:59.378445Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1739742 bytes\n2026-03-10T18:14:00.054622Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:14:01.386329Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.2 pps, 155 packets, 1636826 bytes\n2026-03-10T18:14:02.061724Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.40\n2026-03-10T18:14:03.388415Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1681863 bytes\n2026-03-10T18:14:04.067757Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.41\n2026-03-10T18:14:05.398312Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1715251 bytes\n2026-03-10T18:14:06.069081Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:14:07.403262Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1675924 bytes\n2026-03-10T18:14:08.087264Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.23\n2026-03-10T18:14:09.407977Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 75.8 pps, 152 packets, 1797935 bytes\n2026-03-10T18:14:10.089686Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:14:11.412779Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.8 pps, 156 packets, 1686436 bytes\n2026-03-10T18:14:12.188672Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.06\n2026-03-10T18:14:13.420896Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.5 Mbps, 76.7 pps, 154 packets, 1888727 bytes\n2026-03-10T18:14:14.254831Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.49\n2026-03-10T18:14:15.422864Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.0 Mbps, 76.9 pps, 154 packets, 1505335 bytes\n2026-03-10T18:14:16.265246Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:14:17.433823Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 77.1 pps, 155 packets, 1613154 bytes\n2026-03-10T18:14:18.302713Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.43\n2026-03-10T18:14:19.435741Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1684075 bytes\n2026-03-10T18:14:20.355430Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.72\n2026-03-10T18:14:21.463745Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 155 packets, 1678291 bytes\n2026-03-10T18:14:22.358530Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:14:23.469974Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.3 pps, 155 packets, 1806224 bytes\n2026-03-10T18:14:24.380038Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.67\n2026-03-10T18:14:25.475434Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.8 pps, 154 packets, 1622631 bytes\n2026-03-10T18:14:26.391634Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:14:27.478519Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1736436 bytes\n2026-03-10T18:14:28.468141Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.38\n2026-03-10T18:14:29.486583Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.2 pps, 153 packets, 1727644 bytes\n2026-03-10T18:14:30.492684Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.62\n2026-03-10T18:14:31.491708Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1654948 bytes\n2026-03-10T18:14:32.494235Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:14:33.496985Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1721122 bytes\n2026-03-10T18:14:34.494962Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:14:35.499154Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1649238 bytes\n2026-03-10T18:14:36.521817Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.60\n2026-03-10T18:14:37.505320Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1706719 bytes\n2026-03-10T18:14:38.554401Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.01\n2026-03-10T18:14:39.508036Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1697131 bytes\n2026-03-10T18:14:40.558771Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.43\n2026-03-10T18:14:41.525333Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 155 packets, 1699786 bytes\n2026-03-10T18:14:42.562391Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.45\n2026-03-10T18:14:43.556679Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 155 packets, 1692272 bytes\n2026-03-10T18:14:44.580604Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.72\n2026-03-10T18:14:45.560246Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1708780 bytes\n2026-03-10T18:14:46.596029Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.78\n2026-03-10T18:14:47.567117Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1663811 bytes\n2026-03-10T18:14:48.651339Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.65\n2026-03-10T18:14:49.579700Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.0 pps, 155 packets, 1772880 bytes\n2026-03-10T18:14:50.687615Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.47\n2026-03-10T18:14:51.584050Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1672970 bytes\n2026-03-10T18:14:52.689116Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.98\n2026-03-10T18:14:53.589802Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.8 pps, 156 packets, 1690973 bytes\n2026-03-10T18:14:54.692890Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:14:55.613819Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 75.6 pps, 153 packets, 1713008 bytes\n2026-03-10T18:14:56.745599Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.72\n2026-03-10T18:14:57.619351Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 78.3 pps, 157 packets, 1783093 bytes\n2026-03-10T18:14:58.752604Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:14:59.625145Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.3 pps, 153 packets, 1762690 bytes\n2026-03-10T18:15:00.768850Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.76\n2026-03-10T18:15:01.630505Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 155 packets, 1687578 bytes\n2026-03-10T18:15:02.775082Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.90\n2026-03-10T18:15:03.635251Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.8 pps, 154 packets, 1605239 bytes\n2026-03-10T18:15:04.786021Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:15:05.667475Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.8 pps, 154 packets, 1695540 bytes\n2026-03-10T18:15:06.787280Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:15:07.684762Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 156 packets, 1720661 bytes\n2026-03-10T18:15:08.787619Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.50\n2026-03-10T18:15:09.695081Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.6 pps, 156 packets, 1673998 bytes\n2026-03-10T18:15:10.867170Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.81\n2026-03-10T18:15:11.705757Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 75.6 pps, 152 packets, 1698027 bytes\n2026-03-10T18:15:12.869668Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.46\n2026-03-10T18:15:13.712146Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.8 pps, 156 packets, 1722571 bytes\n2026-03-10T18:15:14.870227Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:15:15.715484Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.4 pps, 155 packets, 1661499 bytes\n2026-03-10T18:15:16.885871Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.26\n2026-03-10T18:15:17.719764Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1709959 bytes\n2026-03-10T18:15:18.886716Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:15:19.721464Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 153 packets, 1695379 bytes\n2026-03-10T18:15:20.895703Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:15:21.732621Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1715010 bytes\n2026-03-10T18:15:22.949523Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.70\n2026-03-10T18:15:23.744482Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1674060 bytes\n2026-03-10T18:15:24.991713Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.38\n2026-03-10T18:15:25.751938Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.7 pps, 154 packets, 1800512 bytes\n2026-03-10T18:15:27.049200Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.62\n2026-03-10T18:15:27.776036Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 76.6 pps, 155 packets, 1846428 bytes\n2026-03-10T18:15:29.093422Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.35\n2026-03-10T18:15:29.785187Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 77.1 pps, 155 packets, 1867249 bytes\n2026-03-10T18:15:31.098426Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:15:31.796378Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.7 Mbps, 77.1 pps, 155 packets, 1429246 bytes\n2026-03-10T18:15:33.184212Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.73\n2026-03-10T18:15:33.805765Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.6 pps, 154 packets, 1749411 bytes\n2026-03-10T18:15:35.186990Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.96\n2026-03-10T18:15:35.809883Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 155 packets, 1688395 bytes\n2026-03-10T18:15:37.189276Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:15:37.814957Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 76.3 pps, 153 packets, 1567424 bytes\n2026-03-10T18:15:39.199003Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:15:39.819932Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 155 packets, 1683186 bytes\n2026-03-10T18:15:41.246424Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:15:41.847767Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.9 pps, 154 packets, 1693537 bytes\n2026-03-10T18:15:43.256072Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.35\n2026-03-10T18:15:43.862475Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.4 pps, 156 packets, 1686522 bytes\n2026-03-10T18:15:45.260525Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.43\n2026-03-10T18:15:45.866663Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.3 pps, 153 packets, 1762693 bytes\n2026-03-10T18:15:47.261254Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.49\n2026-03-10T18:15:47.875981Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.6 pps, 156 packets, 1712963 bytes\n2026-03-10T18:15:49.269000Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:15:49.887617Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1688547 bytes\n2026-03-10T18:15:51.354887Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.72\n2026-03-10T18:15:51.896439Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1709118 bytes\n2026-03-10T18:15:53.384175Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.55\n2026-03-10T18:15:53.898922Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1708248 bytes\n2026-03-10T18:15:55.454989Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.46\n2026-03-10T18:15:55.904697Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1680112 bytes\n2026-03-10T18:15:57.463842Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:15:57.906127Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.9 pps, 154 packets, 1765574 bytes\n2026-03-10T18:15:59.485854Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.66\n2026-03-10T18:15:59.914330Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.7 pps, 154 packets, 1624250 bytes\n2026-03-10T18:16:01.486718Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:16:01.921335Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1693117 bytes\n2026-03-10T18:16:03.496077Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:16:03.923313Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1711021 bytes\n2026-03-10T18:16:05.506328Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.35\n2026-03-10T18:16:05.930797Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1650842 bytes\n2026-03-10T18:16:07.590201Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.75\n2026-03-10T18:16:07.933900Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1720382 bytes\n2026-03-10T18:16:09.638635Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.76\n2026-03-10T18:16:09.937919Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1708311 bytes\n2026-03-10T18:16:11.641568Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:16:11.953365Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 154 packets, 1701985 bytes\n2026-03-10T18:16:13.654979Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:16:13.955228Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1683894 bytes\n2026-03-10T18:16:15.684656Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.56\n2026-03-10T18:16:15.969830Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 154 packets, 1686278 bytes\n2026-03-10T18:16:17.685567Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.99\n2026-03-10T18:16:17.986072Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.4 pps, 156 packets, 1789320 bytes\n2026-03-10T18:16:19.693183Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:16:19.988796Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.4 pps, 155 packets, 1713834 bytes\n2026-03-10T18:16:21.771580Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.35\n2026-03-10T18:16:21.997268Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1658248 bytes\n2026-03-10T18:16:23.783911Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.81\n2026-03-10T18:16:23.999861Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1718562 bytes\n2026-03-10T18:16:25.855847Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.44\n2026-03-10T18:16:26.007234Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1659723 bytes\n2026-03-10T18:16:27.863583Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:16:28.023693Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 154 packets, 1703327 bytes\n2026-03-10T18:16:29.866251Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:16:30.032152Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.7 pps, 156 packets, 1760295 bytes\n2026-03-10T18:16:31.877322Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.33\n2026-03-10T18:16:32.056123Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.1 pps, 154 packets, 1755145 bytes\n2026-03-10T18:16:33.933695Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.66\n2026-03-10T18:16:34.061263Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.8 pps, 154 packets, 1619681 bytes\n2026-03-10T18:16:35.947544Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:16:36.072902Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.6 pps, 156 packets, 1779449 bytes\n2026-03-10T18:16:37.969171Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.68\n2026-03-10T18:16:38.084452Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.6 pps, 154 packets, 1626784 bytes\n2026-03-10T18:16:39.980813Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.82\n2026-03-10T18:16:40.085632Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.5 pps, 155 packets, 1654703 bytes\n2026-03-10T18:16:41.987542Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:16:42.087596Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 153 packets, 1674222 bytes\n2026-03-10T18:16:44.055228Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.50\n2026-03-10T18:16:44.092439Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1723760 bytes\n2026-03-10T18:16:46.062257Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.39\n2026-03-10T18:16:46.097382Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 76.8 pps, 154 packets, 1821867 bytes\n2026-03-10T18:16:48.077924Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.26\n2026-03-10T18:16:48.098691Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 154 packets, 1638973 bytes\n2026-03-10T18:16:50.091908Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.80\n2026-03-10T18:16:50.110818Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.0 pps, 153 packets, 1703672 bytes\n2026-03-10T18:16:52.128167Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.8 pps, 155 packets, 1640545 bytes\n2026-03-10T18:16:52.161594Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.44\n2026-03-10T18:16:54.145919Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 156 packets, 1664893 bytes\n2026-03-10T18:16:54.189796Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.58\n2026-03-10T18:16:56.166545Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.2 pps, 154 packets, 1807339 bytes\n2026-03-10T18:16:56.257803Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.46\n2026-03-10T18:16:58.170337Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.9 pps, 154 packets, 1790837 bytes\n2026-03-10T18:16:58.280835Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.66\n2026-03-10T18:17:00.179309Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1655473 bytes\n2026-03-10T18:17:00.287434Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.90\n2026-03-10T18:17:02.195777Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.4 pps, 156 packets, 1691431 bytes\n2026-03-10T18:17:02.356961Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T18:17:04.204729Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1732323 bytes\n2026-03-10T18:17:04.365975Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:17:06.212920Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.2 pps, 155 packets, 1643087 bytes\n2026-03-10T18:17:06.398169Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.02\n2026-03-10T18:17:08.230369Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 155 packets, 1703994 bytes\n2026-03-10T18:17:08.462225Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.55\n2026-03-10T18:17:10.264020Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.2 pps, 155 packets, 1682980 bytes\n2026-03-10T18:17:10.471334Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:17:12.270346Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.3 pps, 155 packets, 1788266 bytes\n2026-03-10T18:17:12.494105Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.16\n2026-03-10T18:17:14.274546Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.3 pps, 153 packets, 1621743 bytes\n2026-03-10T18:17:14.548125Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.70\n2026-03-10T18:17:16.289570Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 156 packets, 1739456 bytes\n2026-03-10T18:17:16.578623Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.53\n2026-03-10T18:17:18.313913Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.6 pps, 155 packets, 1638062 bytes\n2026-03-10T18:17:18.670659Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.16\n2026-03-10T18:17:20.339785Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 77.0 pps, 156 packets, 1848567 bytes\n2026-03-10T18:17:20.675594Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.92\n2026-03-10T18:17:22.347820Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 77.2 pps, 155 packets, 1593212 bytes\n2026-03-10T18:17:22.679099Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:17:24.348507Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1707553 bytes\n2026-03-10T18:17:24.682706Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:17:26.352244Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.9 pps, 154 packets, 1779643 bytes\n2026-03-10T18:17:26.736796Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.70\n2026-03-10T18:17:28.362880Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.2 Mbps, 76.1 pps, 153 packets, 1567316 bytes\n2026-03-10T18:17:28.745494Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:17:30.363896Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.5 pps, 155 packets, 1724244 bytes\n2026-03-10T18:17:30.768404Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.15\n2026-03-10T18:17:32.367639Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.4 pps, 155 packets, 1698615 bytes\n2026-03-10T18:17:32.776112Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:17:34.386182Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.3 pps, 154 packets, 1783972 bytes\n2026-03-10T18:17:34.778939Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:17:36.391695Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1696791 bytes\n2026-03-10T18:17:36.782665Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:17:38.409178Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 156 packets, 1697767 bytes\n2026-03-10T18:17:38.791733Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.37\n2026-03-10T18:17:40.410053Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 154 packets, 1643628 bytes\n2026-03-10T18:17:40.809484Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.74\n2026-03-10T18:17:42.435645Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.5 pps, 155 packets, 1818876 bytes\n2026-03-10T18:17:42.838695Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.55\n2026-03-10T18:17:44.438476Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1689030 bytes\n2026-03-10T18:17:44.865040Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.61\n2026-03-10T18:17:46.445014Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1715567 bytes\n2026-03-10T18:17:46.874344Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.86\n2026-03-10T18:17:48.448518Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1684428 bytes\n2026-03-10T18:17:48.879663Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:17:50.479241Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 156 packets, 1758289 bytes\n2026-03-10T18:17:50.885054Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:17:52.493340Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 155 packets, 1670106 bytes\n2026-03-10T18:17:52.893424Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:17:54.501582Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1699204 bytes\n2026-03-10T18:17:54.970169Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.37\n2026-03-10T18:17:56.503221Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.9 pps, 156 packets, 1704390 bytes\n2026-03-10T18:17:56.979034Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.86\n2026-03-10T18:17:58.509869Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1704856 bytes\n2026-03-10T18:17:59.086739Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.94\n2026-03-10T18:18:00.532116Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.6 pps, 155 packets, 1781892 bytes\n2026-03-10T18:18:01.187002Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:18:02.558021Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.5 pps, 155 packets, 1666665 bytes\n2026-03-10T18:18:03.193847Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.40\n2026-03-10T18:18:04.558982Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1692548 bytes\n2026-03-10T18:18:05.282636Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.68\n2026-03-10T18:18:06.566321Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.2 pps, 155 packets, 1719290 bytes\n2026-03-10T18:18:07.350775Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.46\n2026-03-10T18:18:08.595281Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 75.9 pps, 154 packets, 1736078 bytes\n2026-03-10T18:18:09.390735Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.41\n2026-03-10T18:18:10.602590Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.7 pps, 156 packets, 1676980 bytes\n2026-03-10T18:18:11.454802Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.52\n2026-03-10T18:18:12.608374Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1738324 bytes\n2026-03-10T18:18:13.472578Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.74\n2026-03-10T18:18:14.613285Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.3 pps, 155 packets, 1631676 bytes\n2026-03-10T18:18:15.482392Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.85\n2026-03-10T18:18:16.615897Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.9 pps, 154 packets, 1745290 bytes\n2026-03-10T18:18:17.488808Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:18:18.621165Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 75.3 pps, 151 packets, 1657257 bytes\n2026-03-10T18:18:19.532422Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:18:20.623664Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 78.4 pps, 157 packets, 1660023 bytes\n2026-03-10T18:18:21.547438Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.78\n2026-03-10T18:18:22.629150Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1715278 bytes\n2026-03-10T18:18:23.567742Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.70\n2026-03-10T18:18:24.630943Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.9 pps, 154 packets, 1761805 bytes\n2026-03-10T18:18:25.577437Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.85\n2026-03-10T18:18:26.647289Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 154 packets, 1692904 bytes\n2026-03-10T18:18:27.644799Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.51\n2026-03-10T18:18:28.652385Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1675774 bytes\n2026-03-10T18:18:29.649864Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:18:30.660041Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1644756 bytes\n2026-03-10T18:18:31.668328Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.73\n2026-03-10T18:18:32.661819Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1687983 bytes\n2026-03-10T18:18:33.671746Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.95\n2026-03-10T18:18:34.668888Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.2 pps, 155 packets, 1772719 bytes\n2026-03-10T18:18:35.682938Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:18:36.680714Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.5 pps, 154 packets, 1607788 bytes\n2026-03-10T18:18:37.684796Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:18:38.693064Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.5 pps, 156 packets, 1723709 bytes\n2026-03-10T18:18:39.736399Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.73\n2026-03-10T18:18:40.697271Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.8 pps, 154 packets, 1741823 bytes\n2026-03-10T18:18:41.738388Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.47\n2026-03-10T18:18:42.703489Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1686592 bytes\n2026-03-10T18:18:43.749624Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.34\n2026-03-10T18:18:44.707922Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1708529 bytes\n2026-03-10T18:18:45.758831Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:18:46.716146Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.2 pps, 155 packets, 1622876 bytes\n2026-03-10T18:18:47.784764Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.12\n2026-03-10T18:18:48.720340Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.3 pps, 153 packets, 1813700 bytes\n2026-03-10T18:18:49.801585Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.24\n2026-03-10T18:18:50.725112Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 155 packets, 1670671 bytes\n2026-03-10T18:18:51.851690Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.27\n2026-03-10T18:18:52.747052Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.2 pps, 154 packets, 1692895 bytes\n2026-03-10T18:18:53.874540Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.65\n2026-03-10T18:18:54.750691Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1652923 bytes\n2026-03-10T18:18:55.881411Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:18:56.766068Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 156 packets, 1746618 bytes\n2026-03-10T18:18:57.941999Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.60\n2026-03-10T18:18:58.786060Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.2 pps, 154 packets, 1651703 bytes\n2026-03-10T18:18:59.950559Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.37\n2026-03-10T18:19:00.793901Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.7 pps, 154 packets, 1752737 bytes\n2026-03-10T18:19:01.962576Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.82\n2026-03-10T18:19:02.800940Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.7 pps, 156 packets, 1675456 bytes\n2026-03-10T18:19:03.962595Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:19:04.802177Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1679340 bytes\n2026-03-10T18:19:05.976546Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.29\n2026-03-10T18:19:06.810532Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1737561 bytes\n2026-03-10T18:19:07.980521Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:19:08.817924Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.2 pps, 155 packets, 1634936 bytes\n2026-03-10T18:19:09.981097Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:19:10.835420Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.3 pps, 154 packets, 1713755 bytes\n2026-03-10T18:19:11.991726Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:19:12.849856Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 155 packets, 1669900 bytes\n2026-03-10T18:19:14.044474Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.72\n2026-03-10T18:19:14.859918Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.6 pps, 154 packets, 1761298 bytes\n2026-03-10T18:19:16.071276Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.60\n2026-03-10T18:19:16.862474Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 77.4 pps, 155 packets, 1846212 bytes\n2026-03-10T18:19:18.073066Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.97\n2026-03-10T18:19:18.870124Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1661884 bytes\n2026-03-10T18:19:20.085387Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.82\n2026-03-10T18:19:20.891393Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 156 packets, 1719730 bytes\n2026-03-10T18:19:22.149851Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.52\n2026-03-10T18:19:22.902919Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 75.6 pps, 152 packets, 1608067 bytes\n2026-03-10T18:19:24.177364Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.59\n2026-03-10T18:19:24.921506Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.8 pps, 157 packets, 1693386 bytes\n2026-03-10T18:19:26.251364Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:19:26.925935Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.3 pps, 155 packets, 1780220 bytes\n2026-03-10T18:19:28.261002Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.35\n2026-03-10T18:19:28.929049Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1669576 bytes\n2026-03-10T18:19:30.264300Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:19:30.943933Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.4 pps, 154 packets, 1624846 bytes\n2026-03-10T18:19:32.275851Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:19:32.951451Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1660028 bytes\n2026-03-10T18:19:34.286555Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:19:34.964238Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 155 packets, 1746116 bytes\n2026-03-10T18:19:36.352724Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.52\n2026-03-10T18:19:36.975627Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 77.1 pps, 155 packets, 1853514 bytes\n2026-03-10T18:19:38.355422Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.46\n2026-03-10T18:19:38.976162Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1672877 bytes\n2026-03-10T18:19:40.365585Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.35\n2026-03-10T18:19:40.994878Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.1 Mbps, 76.8 pps, 155 packets, 1546675 bytes\n2026-03-10T18:19:42.379342Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:19:43.011407Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.9 pps, 155 packets, 1808494 bytes\n2026-03-10T18:19:44.417432Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:19:45.016872Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.5 Mbps, 77.3 pps, 155 packets, 1877216 bytes\n2026-03-10T18:19:46.455282Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:19:47.022346Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.8 Mbps, 76.3 pps, 153 packets, 1442779 bytes\n2026-03-10T18:19:48.474855Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.71\n2026-03-10T18:19:49.114810Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 69.8 pps, 146 packets, 1636783 bytes\n2026-03-10T18:19:50.624691Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 26.51\n2026-03-10T18:19:51.258465Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.1 pps, 161 packets, 1785734 bytes\n2026-03-10T18:19:52.639824Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 32.75\n2026-03-10T18:19:53.380784Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 78.2 pps, 166 packets, 1810126 bytes\n2026-03-10T18:19:54.808839Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 27.66\n2026-03-10T18:19:55.388194Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 75.2 pps, 151 packets, 1612012 bytes\n2026-03-10T18:19:56.811726Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.45\n2026-03-10T18:19:57.492515Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.0 pps, 160 packets, 1726600 bytes\n2026-03-10T18:19:58.839859Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 32.54\n2026-03-10T18:19:59.503026Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.6 Mbps, 87.5 pps, 176 packets, 1913321 bytes\n2026-03-10T18:20:00.840819Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:20:01.511944Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1712634 bytes\n2026-03-10T18:20:02.887236Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.32\n2026-03-10T18:20:03.512566Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.5 pps, 155 packets, 1682612 bytes\n2026-03-10T18:20:04.991853Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:20:05.514377Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.4 pps, 153 packets, 1724921 bytes\n2026-03-10T18:20:06.993179Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:20:07.526781Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 155 packets, 1704011 bytes\n2026-03-10T18:20:09.087235Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.61\n2026-03-10T18:20:09.527797Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1705022 bytes\n2026-03-10T18:20:11.153942Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:20:11.530119Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 153 packets, 1695326 bytes\n2026-03-10T18:20:13.172008Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.72\n2026-03-10T18:20:13.548196Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 155 packets, 1695547 bytes\n2026-03-10T18:20:15.181875Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:20:15.561657Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 155 packets, 1706256 bytes\n2026-03-10T18:20:17.188441Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:20:17.573317Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.1 pps, 155 packets, 1745483 bytes\n2026-03-10T18:20:19.231565Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:20:19.582746Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.6 pps, 154 packets, 1761682 bytes\n2026-03-10T18:20:21.242099Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:20:21.584364Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.4 pps, 155 packets, 1649940 bytes\n2026-03-10T18:20:23.254699Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.31\n2026-03-10T18:20:23.587380Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1686728 bytes\n2026-03-10T18:20:25.255426Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:20:25.597573Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1705322 bytes\n2026-03-10T18:20:27.273655Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.22\n2026-03-10T18:20:27.598116Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 154 packets, 1643558 bytes\n2026-03-10T18:20:29.277456Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:20:29.601112Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1725353 bytes\n2026-03-10T18:20:31.351492Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.41\n2026-03-10T18:20:31.608025Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1647402 bytes\n2026-03-10T18:20:33.380480Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.56\n2026-03-10T18:20:33.617372Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.1 pps, 155 packets, 1738743 bytes\n2026-03-10T18:20:35.380777Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:20:35.618248Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.5 pps, 153 packets, 1667119 bytes\n2026-03-10T18:20:37.446348Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.53\n2026-03-10T18:20:37.626309Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1714225 bytes\n2026-03-10T18:20:39.477900Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.52\n2026-03-10T18:20:39.640893Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 155 packets, 1678693 bytes\n2026-03-10T18:20:41.486580Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:20:41.641543Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 153 packets, 1704518 bytes\n2026-03-10T18:20:43.528899Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:20:43.648722Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1722803 bytes\n2026-03-10T18:20:45.557851Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.57\n2026-03-10T18:20:45.657026Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1684925 bytes\n2026-03-10T18:20:47.568236Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:20:47.664441Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1701437 bytes\n2026-03-10T18:20:49.578941Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:20:49.680723Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.9 pps, 157 packets, 1705709 bytes\n2026-03-10T18:20:51.579750Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:20:51.687210Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1718350 bytes\n2026-03-10T18:20:53.642814Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.57\n2026-03-10T18:20:53.687917Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1691614 bytes\n2026-03-10T18:20:55.646268Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:20:55.694149Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1662997 bytes\n2026-03-10T18:20:57.658175Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:20:57.708250Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 154 packets, 1721053 bytes\n2026-03-10T18:20:59.681476Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.15\n2026-03-10T18:20:59.725179Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 156 packets, 1737306 bytes\n2026-03-10T18:21:01.726407Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 154 packets, 1645365 bytes\n2026-03-10T18:21:01.750239Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.49\n2026-03-10T18:21:03.746834Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.2 pps, 154 packets, 1703233 bytes\n2026-03-10T18:21:03.773157Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.65\n2026-03-10T18:21:05.758334Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.1 pps, 155 packets, 1796117 bytes\n2026-03-10T18:21:05.780886Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:21:07.768173Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1684109 bytes\n2026-03-10T18:21:07.850347Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T18:21:09.778126Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.6 pps, 154 packets, 1660224 bytes\n2026-03-10T18:21:09.880237Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.54\n2026-03-10T18:21:11.793282Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.4 pps, 156 packets, 1705290 bytes\n2026-03-10T18:21:11.963525Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.28\n2026-03-10T18:21:13.796749Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 153 packets, 1684718 bytes\n2026-03-10T18:21:13.982186Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.72\n2026-03-10T18:21:15.811251Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.4 pps, 156 packets, 1635940 bytes\n2026-03-10T18:21:15.992755Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.84\n2026-03-10T18:21:17.813463Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.4 pps, 153 packets, 1728551 bytes\n2026-03-10T18:21:18.076325Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.28\n2026-03-10T18:21:19.820950Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.7 pps, 154 packets, 1631184 bytes\n2026-03-10T18:21:20.154224Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:21:21.825875Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 76.8 pps, 154 packets, 1852862 bytes\n2026-03-10T18:21:22.173716Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.71\n2026-03-10T18:21:23.849372Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.6 pps, 155 packets, 1653957 bytes\n2026-03-10T18:21:24.176597Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.96\n2026-03-10T18:21:25.870675Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.2 pps, 156 packets, 1735640 bytes\n2026-03-10T18:21:26.292037Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.84\n2026-03-10T18:21:27.877084Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1682238 bytes\n2026-03-10T18:21:28.328225Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.94\n2026-03-10T18:21:29.890420Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.5 pps, 156 packets, 1705894 bytes\n2026-03-10T18:21:30.351398Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.66\n2026-03-10T18:21:31.897712Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 75.7 pps, 152 packets, 1714260 bytes\n2026-03-10T18:21:32.379733Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.57\n2026-03-10T18:21:33.907853Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.6 pps, 156 packets, 1699088 bytes\n2026-03-10T18:21:34.439673Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.61\n2026-03-10T18:21:35.908061Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.5 pps, 155 packets, 1654412 bytes\n2026-03-10T18:21:36.451719Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.82\n2026-03-10T18:21:37.917808Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.6 pps, 154 packets, 1785183 bytes\n2026-03-10T18:21:38.458983Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:21:39.946936Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.4 pps, 155 packets, 1661065 bytes\n2026-03-10T18:21:40.460321Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:21:41.957357Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1673383 bytes\n2026-03-10T18:21:42.485427Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.62\n2026-03-10T18:21:43.978628Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.2 pps, 154 packets, 1717195 bytes\n2026-03-10T18:21:44.567775Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.29\n2026-03-10T18:21:45.992403Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 78.0 pps, 157 packets, 1693599 bytes\n2026-03-10T18:21:46.572357Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.93\n2026-03-10T18:21:48.003126Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.1 pps, 153 packets, 1691278 bytes\n2026-03-10T18:21:48.638592Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.52\n2026-03-10T18:21:50.012049Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1671887 bytes\n2026-03-10T18:21:50.639759Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:21:52.032469Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.2 pps, 156 packets, 1738331 bytes\n2026-03-10T18:21:52.646899Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:21:54.042096Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1684055 bytes\n2026-03-10T18:21:54.676461Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.55\n2026-03-10T18:21:56.048951Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.2 pps, 155 packets, 1747859 bytes\n2026-03-10T18:21:56.678356Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:21:58.065487Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 75.9 pps, 153 packets, 1616827 bytes\n2026-03-10T18:21:58.733779Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.68\n2026-03-10T18:22:00.073236Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.7 pps, 156 packets, 1774043 bytes\n2026-03-10T18:22:00.738095Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:22:02.082216Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1666883 bytes\n2026-03-10T18:22:02.750767Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.81\n2026-03-10T18:22:04.085168Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1738895 bytes\n2026-03-10T18:22:04.750806Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.50\n2026-03-10T18:22:06.090826Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.8 pps, 152 packets, 1673982 bytes\n2026-03-10T18:22:06.774091Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.15\n2026-03-10T18:22:08.092211Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.9 pps, 156 packets, 1732210 bytes\n2026-03-10T18:22:08.783856Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:22:10.101981Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.6 pps, 154 packets, 1654511 bytes\n2026-03-10T18:22:10.847541Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.56\n2026-03-10T18:22:12.108850Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1693660 bytes\n2026-03-10T18:22:12.877354Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.54\n2026-03-10T18:22:14.128726Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.2 pps, 154 packets, 1689975 bytes\n2026-03-10T18:22:14.878938Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:22:16.159101Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 156 packets, 1718677 bytes\n2026-03-10T18:22:16.945937Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.51\n2026-03-10T18:22:18.168824Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.6 pps, 156 packets, 1782770 bytes\n2026-03-10T18:22:18.975169Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.55\n2026-03-10T18:22:20.175143Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1717895 bytes\n2026-03-10T18:22:21.049098Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.41\n2026-03-10T18:22:22.176303Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1681989 bytes\n2026-03-10T18:22:23.051637Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:22:24.182934Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.7 pps, 154 packets, 1640260 bytes\n2026-03-10T18:22:25.051647Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.50\n2026-03-10T18:22:26.196602Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 154 packets, 1701598 bytes\n2026-03-10T18:22:27.074181Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.16\n2026-03-10T18:22:28.209642Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.0 pps, 155 packets, 1793618 bytes\n2026-03-10T18:22:29.076891Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:22:30.237806Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.4 pps, 155 packets, 1640661 bytes\n2026-03-10T18:22:31.137942Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.60\n2026-03-10T18:22:32.261583Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 76.6 pps, 155 packets, 1592433 bytes\n2026-03-10T18:22:33.144020Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:22:34.262394Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1685248 bytes\n2026-03-10T18:22:35.151628Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:22:36.264448Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 77.4 pps, 155 packets, 1818880 bytes\n2026-03-10T18:22:37.173611Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.17\n2026-03-10T18:22:38.276808Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.5 pps, 154 packets, 1670755 bytes\n2026-03-10T18:22:39.182066Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:22:40.316493Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.5 pps, 156 packets, 1755001 bytes\n2026-03-10T18:22:41.266138Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.27\n2026-03-10T18:22:42.319759Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.4 pps, 155 packets, 1758678 bytes\n2026-03-10T18:22:43.271930Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.91\n2026-03-10T18:22:44.327623Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1668885 bytes\n2026-03-10T18:22:45.277152Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:22:46.354526Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.0 pps, 154 packets, 1670940 bytes\n2026-03-10T18:22:47.354813Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.36\n2026-03-10T18:22:48.367685Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.0 pps, 155 packets, 1632364 bytes\n2026-03-10T18:22:49.375116Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.69\n2026-03-10T18:22:50.373554Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.8 pps, 156 packets, 1817278 bytes\n2026-03-10T18:22:51.386914Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.83\n2026-03-10T18:22:52.374877Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.5 pps, 153 packets, 1591776 bytes\n2026-03-10T18:22:53.428084Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.86\n2026-03-10T18:22:54.389732Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.4 pps, 156 packets, 1773159 bytes\n2026-03-10T18:22:55.438336Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:22:56.390994Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.0 pps, 154 packets, 1771293 bytes\n2026-03-10T18:22:57.439904Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T18:22:58.402345Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.6 pps, 154 packets, 1647644 bytes\n2026-03-10T18:22:59.454024Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:23:00.411115Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.2 pps, 155 packets, 1787647 bytes\n2026-03-10T18:23:01.465148Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.83\n2026-03-10T18:23:02.423490Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.5 pps, 154 packets, 1787853 bytes\n2026-03-10T18:23:03.472271Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:23:04.430684Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 76.7 pps, 154 packets, 1571832 bytes\n2026-03-10T18:23:05.479344Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:23:06.432773Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1728429 bytes\n2026-03-10T18:23:07.530407Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.74\n2026-03-10T18:23:08.445813Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.5 pps, 154 packets, 1681556 bytes\n2026-03-10T18:23:09.532385Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:23:10.457741Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 154 packets, 1710968 bytes\n2026-03-10T18:23:11.539484Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:23:12.467324Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1672173 bytes\n2026-03-10T18:23:13.546799Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:23:14.467778Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 154 packets, 1723909 bytes\n2026-03-10T18:23:15.567742Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.68\n2026-03-10T18:23:16.470169Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1650697 bytes\n2026-03-10T18:23:17.575249Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.89\n2026-03-10T18:23:18.484664Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.4 pps, 156 packets, 1774409 bytes\n2026-03-10T18:23:19.650439Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:23:20.490274Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.3 pps, 155 packets, 1763755 bytes\n2026-03-10T18:23:21.665780Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.76\n2026-03-10T18:23:22.509680Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 77.3 pps, 156 packets, 1627624 bytes\n2026-03-10T18:23:23.668850Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.45\n2026-03-10T18:23:24.509982Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 153 packets, 1695674 bytes\n2026-03-10T18:23:25.676382Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:23:26.519266Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.7 Mbps, 72.2 pps, 145 packets, 1437452 bytes\n2026-03-10T18:23:27.682260Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.92\n2026-03-10T18:23:28.567972Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 71.8 pps, 147 packets, 1712334 bytes\n2026-03-10T18:23:29.811866Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.64\n2026-03-10T18:23:30.590728Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.5 Mbps, 81.6 pps, 165 packets, 1901838 bytes\n2026-03-10T18:23:31.815242Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.45\n2026-03-10T18:23:32.607663Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 156 packets, 1681686 bytes\n2026-03-10T18:23:33.965417Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.77\n2026-03-10T18:23:34.623634Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.4 pps, 152 packets, 1695048 bytes\n2026-03-10T18:23:35.967224Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 32.97\n2026-03-10T18:23:36.654787Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 82.7 pps, 168 packets, 1784441 bytes\n2026-03-10T18:23:37.971776Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.93\n2026-03-10T18:23:38.659432Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.3 pps, 155 packets, 1757199 bytes\n2026-03-10T18:23:40.019046Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:23:40.662404Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1676628 bytes\n2026-03-10T18:23:42.050863Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.53\n2026-03-10T18:23:42.669266Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1688702 bytes\n2026-03-10T18:23:44.070538Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.70\n2026-03-10T18:23:44.672220Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1711854 bytes\n2026-03-10T18:23:46.082070Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:23:46.676100Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1681086 bytes\n2026-03-10T18:23:48.097164Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.78\n2026-03-10T18:23:48.682091Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1667915 bytes\n2026-03-10T18:23:50.172300Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.40\n2026-03-10T18:23:50.685115Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1700318 bytes\n2026-03-10T18:23:52.194256Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.66\n2026-03-10T18:23:52.694863Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1717534 bytes\n2026-03-10T18:23:54.277977Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.27\n2026-03-10T18:23:54.696301Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.9 pps, 152 packets, 1675925 bytes\n2026-03-10T18:23:56.361575Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.24\n2026-03-10T18:23:56.703020Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.7 pps, 156 packets, 1690564 bytes\n2026-03-10T18:23:58.371724Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.84\n2026-03-10T18:23:58.818469Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.6 Mbps, 62.4 pps, 132 packets, 1472825 bytes\n2026-03-10T18:24:00.465057Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.14\n2026-03-10T18:24:00.820641Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 8.1 Mbps, 92.4 pps, 185 packets, 2027926 bytes\n2026-03-10T18:24:02.482846Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.73\n2026-03-10T18:24:02.823160Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1682649 bytes\n2026-03-10T18:24:04.496514Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:24:04.838765Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 154 packets, 1683077 bytes\n2026-03-10T18:24:06.550280Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.70\n2026-03-10T18:24:06.842841Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1701484 bytes\n2026-03-10T18:24:08.566433Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.76\n2026-03-10T18:24:08.851173Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1686960 bytes\n2026-03-10T18:24:10.629692Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.53\n2026-03-10T18:24:10.856893Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.3 pps, 155 packets, 1811214 bytes\n2026-03-10T18:24:12.647136Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.74\n2026-03-10T18:24:12.873435Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.9 pps, 155 packets, 1637245 bytes\n2026-03-10T18:24:14.649480Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:24:14.881148Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1688366 bytes\n2026-03-10T18:24:16.653409Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:24:16.890879Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1699563 bytes\n2026-03-10T18:24:18.670512Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.74\n2026-03-10T18:24:18.892588Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1709071 bytes\n2026-03-10T18:24:20.675198Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:24:20.897228Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 155 packets, 1706263 bytes\n2026-03-10T18:24:22.687777Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.32\n2026-03-10T18:24:22.900623Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 75.9 pps, 152 packets, 1619335 bytes\n2026-03-10T18:24:24.735561Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.28\n2026-03-10T18:24:24.906441Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.8 pps, 156 packets, 1764863 bytes\n2026-03-10T18:24:26.788886Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.22\n2026-03-10T18:24:26.910754Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1731612 bytes\n2026-03-10T18:24:28.829952Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.87\n2026-03-10T18:24:28.913947Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1645899 bytes\n2026-03-10T18:24:30.836408Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:24:30.920875Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.7 pps, 154 packets, 1760141 bytes\n2026-03-10T18:24:32.850376Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:24:32.921374Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1665493 bytes\n2026-03-10T18:24:34.861838Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.82\n2026-03-10T18:24:34.938409Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.4 pps, 154 packets, 1635777 bytes\n2026-03-10T18:24:36.870312Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:24:36.942505Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1732967 bytes\n2026-03-10T18:24:38.871140Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:24:38.945310Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1708130 bytes\n2026-03-10T18:24:40.940878Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.47\n2026-03-10T18:24:40.956327Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1679423 bytes\n2026-03-10T18:24:42.945418Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.43\n2026-03-10T18:24:42.970544Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.5 pps, 156 packets, 1746500 bytes\n2026-03-10T18:24:44.947111Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:24:44.974133Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1655151 bytes\n2026-03-10T18:24:46.954819Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:24:46.986226Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 155 packets, 1740129 bytes\n2026-03-10T18:24:48.960952Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.41\n2026-03-10T18:24:48.989071Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1699608 bytes\n2026-03-10T18:24:50.964669Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:24:50.994936Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1685987 bytes\n2026-03-10T18:24:52.968210Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:24:52.999971Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 155 packets, 1702772 bytes\n2026-03-10T18:24:55.004106Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.3 pps, 153 packets, 1647244 bytes\n2026-03-10T18:24:55.046758Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.35\n2026-03-10T18:24:57.021036Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 155 packets, 1707001 bytes\n2026-03-10T18:24:57.055402Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.37\n2026-03-10T18:24:59.048362Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.5 pps, 155 packets, 1694880 bytes\n2026-03-10T18:24:59.070647Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.27\n2026-03-10T18:25:01.079216Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.37\n2026-03-10T18:25:01.079605Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.3 pps, 155 packets, 1764275 bytes\n2026-03-10T18:25:03.087752Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 78.2 pps, 157 packets, 1660828 bytes\n2026-03-10T18:25:03.154478Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:25:05.094545Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1703900 bytes\n2026-03-10T18:25:05.169605Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.77\n2026-03-10T18:25:07.103684Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.1 pps, 155 packets, 1752385 bytes\n2026-03-10T18:25:07.226384Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.66\n2026-03-10T18:25:09.108707Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1690490 bytes\n2026-03-10T18:25:09.229972Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:25:11.112226Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.9 pps, 154 packets, 1636320 bytes\n2026-03-10T18:25:11.235781Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:25:13.132608Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.2 pps, 154 packets, 1734803 bytes\n2026-03-10T18:25:13.242702Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:25:15.145072Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 155 packets, 1727067 bytes\n2026-03-10T18:25:15.263271Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.68\n2026-03-10T18:25:17.156758Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.1 pps, 155 packets, 1725630 bytes\n2026-03-10T18:25:17.264232Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.49\n2026-03-10T18:25:19.161285Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.8 pps, 154 packets, 1769693 bytes\n2026-03-10T18:25:19.268798Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.43\n2026-03-10T18:25:21.175768Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.9 pps, 155 packets, 1614241 bytes\n2026-03-10T18:25:21.335096Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.52\n2026-03-10T18:25:23.184638Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1684213 bytes\n2026-03-10T18:25:23.335220Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:25:25.188307Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1713935 bytes\n2026-03-10T18:25:25.340729Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:25:27.197786Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.6 pps, 154 packets, 1655720 bytes\n2026-03-10T18:25:27.343032Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:25:29.202463Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.8 pps, 154 packets, 1766822 bytes\n2026-03-10T18:25:29.343492Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.49\n2026-03-10T18:25:31.207368Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1736333 bytes\n2026-03-10T18:25:31.350719Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.39\n2026-03-10T18:25:33.214251Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1646359 bytes\n2026-03-10T18:25:33.363773Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.80\n2026-03-10T18:25:35.219156Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1695443 bytes\n2026-03-10T18:25:35.431112Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.51\n2026-03-10T18:25:37.220828Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1650824 bytes\n2026-03-10T18:25:37.440884Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:25:39.236882Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 154 packets, 1662698 bytes\n2026-03-10T18:25:39.446831Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:25:41.249914Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.5 pps, 154 packets, 1668741 bytes\n2026-03-10T18:25:41.468023Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.67\n2026-03-10T18:25:43.262267Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.5 pps, 156 packets, 1782200 bytes\n2026-03-10T18:25:43.475818Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:25:45.268059Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 155 packets, 1715409 bytes\n2026-03-10T18:25:45.479468Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.95\n2026-03-10T18:25:47.273136Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1738014 bytes\n2026-03-10T18:25:47.534378Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.66\n2026-03-10T18:25:49.280274Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.2 pps, 153 packets, 1668282 bytes\n2026-03-10T18:25:49.539634Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:25:51.283810Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.4 pps, 155 packets, 1667114 bytes\n2026-03-10T18:25:51.558148Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.23\n2026-03-10T18:25:53.288696Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1715857 bytes\n2026-03-10T18:25:53.566939Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:25:55.296441Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 75.7 pps, 152 packets, 1717370 bytes\n2026-03-10T18:25:55.569143Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.97\n2026-03-10T18:25:57.297189Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1689738 bytes\n2026-03-10T18:25:57.570912Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:25:59.302309Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.8 pps, 156 packets, 1683904 bytes\n2026-03-10T18:25:59.571555Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:26:01.305597Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1728712 bytes\n2026-03-10T18:26:01.623101Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.73\n2026-03-10T18:26:03.310822Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.3 pps, 153 packets, 1666744 bytes\n2026-03-10T18:26:03.638828Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.77\n2026-03-10T18:26:05.342464Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 155 packets, 1691256 bytes\n2026-03-10T18:26:05.647900Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:26:07.355100Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 155 packets, 1714523 bytes\n2026-03-10T18:26:07.657751Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:26:09.355713Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.5 pps, 155 packets, 1761230 bytes\n2026-03-10T18:26:09.668495Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:26:11.365444Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1683633 bytes\n2026-03-10T18:26:11.682037Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:26:13.381490Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 156 packets, 1745782 bytes\n2026-03-10T18:26:13.742460Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.61\n2026-03-10T18:26:15.387951Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1647086 bytes\n2026-03-10T18:26:15.780516Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:26:17.392445Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1671022 bytes\n2026-03-10T18:26:17.849382Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T18:26:19.398905Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1684527 bytes\n2026-03-10T18:26:19.863944Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.28\n2026-03-10T18:26:21.402870Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1698298 bytes\n2026-03-10T18:26:21.879568Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.26\n2026-03-10T18:26:23.406669Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.9 pps, 154 packets, 1759767 bytes\n2026-03-10T18:26:23.884026Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.43\n2026-03-10T18:26:25.412744Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.8 pps, 154 packets, 1618932 bytes\n2026-03-10T18:26:25.889028Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:26:27.418654Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 155 packets, 1666587 bytes\n2026-03-10T18:26:27.976515Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.70\n2026-03-10T18:26:29.419236Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 154 packets, 1714883 bytes\n2026-03-10T18:26:29.981614Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:26:31.444460Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.0 pps, 154 packets, 1689554 bytes\n2026-03-10T18:26:32.079792Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.55\n2026-03-10T18:26:33.468330Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.1 pps, 156 packets, 1751110 bytes\n2026-03-10T18:26:34.080152Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.99\n2026-03-10T18:26:35.486100Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 156 packets, 1687491 bytes\n2026-03-10T18:26:36.127891Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:26:37.488673Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 76.9 pps, 154 packets, 1827728 bytes\n2026-03-10T18:26:38.131375Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:26:39.494132Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1671316 bytes\n2026-03-10T18:26:40.141572Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.35\n2026-03-10T18:26:41.498028Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.4 pps, 155 packets, 1667821 bytes\n2026-03-10T18:26:42.153851Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.32\n2026-03-10T18:26:43.504701Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1715568 bytes\n2026-03-10T18:26:44.158622Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.93\n2026-03-10T18:26:45.510916Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.8 pps, 154 packets, 1621844 bytes\n2026-03-10T18:26:46.168412Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:26:47.518576Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1663419 bytes\n2026-03-10T18:26:48.178200Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:26:49.537156Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 154 packets, 1681574 bytes\n2026-03-10T18:26:50.241950Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.56\n2026-03-10T18:26:51.544318Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1691839 bytes\n2026-03-10T18:26:52.267826Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.60\n2026-03-10T18:26:53.558342Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 77.5 pps, 156 packets, 1838929 bytes\n2026-03-10T18:26:54.279029Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:26:55.571878Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 155 packets, 1720183 bytes\n2026-03-10T18:26:56.323899Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:26:57.586713Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.9 pps, 155 packets, 1605269 bytes\n2026-03-10T18:26:58.327768Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:26:59.588520Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1683671 bytes\n2026-03-10T18:27:00.355864Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.58\n2026-03-10T18:27:01.592828Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.8 pps, 154 packets, 1639953 bytes\n2026-03-10T18:27:02.358515Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.46\n2026-03-10T18:27:03.605488Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 155 packets, 1721140 bytes\n2026-03-10T18:27:04.367000Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.37\n2026-03-10T18:27:05.608990Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1696126 bytes\n2026-03-10T18:27:06.374063Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:27:07.644819Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.1 pps, 155 packets, 1732444 bytes\n2026-03-10T18:27:08.423233Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.77\n2026-03-10T18:27:09.649420Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.8 Mbps, 76.8 pps, 154 packets, 1964194 bytes\n2026-03-10T18:27:10.433913Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:27:11.671154Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.7 pps, 155 packets, 1642212 bytes\n2026-03-10T18:27:12.446854Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.81\n2026-03-10T18:27:13.682193Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.2 Mbps, 77.1 pps, 155 packets, 1554963 bytes\n2026-03-10T18:27:14.450690Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.44\n2026-03-10T18:27:15.700835Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 156 packets, 1678774 bytes\n2026-03-10T18:27:16.459695Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:27:17.702422Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1700613 bytes\n2026-03-10T18:27:18.474838Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.27\n2026-03-10T18:27:19.705708Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1705414 bytes\n2026-03-10T18:27:20.484730Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:27:21.727882Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.7 pps, 155 packets, 1649579 bytes\n2026-03-10T18:27:22.536896Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.72\n2026-03-10T18:27:23.737727Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.6 pps, 154 packets, 1761373 bytes\n2026-03-10T18:27:24.537592Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.49\n2026-03-10T18:27:25.749240Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.1 pps, 155 packets, 1664494 bytes\n2026-03-10T18:27:26.568765Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.05\n2026-03-10T18:27:27.756104Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.2 pps, 155 packets, 1724765 bytes\n2026-03-10T18:27:28.575458Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.90\n2026-03-10T18:27:29.758740Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1667577 bytes\n2026-03-10T18:27:30.622546Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:27:31.767424Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.7 pps, 154 packets, 1754490 bytes\n2026-03-10T18:27:32.635887Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:27:33.779946Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.5 pps, 154 packets, 1775546 bytes\n2026-03-10T18:27:34.660706Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.63\n2026-03-10T18:27:35.794941Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.4 pps, 156 packets, 1807936 bytes\n2026-03-10T18:27:36.660915Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.00\n2026-03-10T18:27:37.808512Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 77.0 pps, 155 packets, 1607224 bytes\n2026-03-10T18:27:38.675079Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:27:39.835535Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.2 Mbps, 76.5 pps, 155 packets, 1573828 bytes\n2026-03-10T18:27:40.734189Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.62\n2026-03-10T18:27:41.859544Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.1 pps, 156 packets, 1744182 bytes\n2026-03-10T18:27:42.761440Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.58\n2026-03-10T18:27:43.871656Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 155 packets, 1667425 bytes\n2026-03-10T18:27:44.766859Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:27:45.876418Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.3 pps, 153 packets, 1703013 bytes\n2026-03-10T18:27:46.774743Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:27:47.895555Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 156 packets, 1705528 bytes\n2026-03-10T18:27:48.778190Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:27:49.912130Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.4 pps, 156 packets, 1699501 bytes\n2026-03-10T18:27:50.825690Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:27:51.916880Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 153 packets, 1686819 bytes\n2026-03-10T18:27:52.856964Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.54\n2026-03-10T18:27:53.922961Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1724086 bytes\n2026-03-10T18:27:54.868106Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.33\n2026-03-10T18:27:55.948703Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.5 pps, 155 packets, 1674133 bytes\n2026-03-10T18:27:56.890769Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.16\n2026-03-10T18:27:57.957595Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.7 pps, 156 packets, 1728706 bytes\n2026-03-10T18:27:58.931643Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:27:59.966488Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1685715 bytes\n2026-03-10T18:28:00.941404Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:28:01.977421Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.1 pps, 155 packets, 1769905 bytes\n2026-03-10T18:28:02.962245Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.68\n2026-03-10T18:28:04.001212Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 75.6 pps, 153 packets, 1635919 bytes\n2026-03-10T18:28:04.965539Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:28:06.010865Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1682487 bytes\n2026-03-10T18:28:06.971559Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:28:08.024091Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 78.5 pps, 158 packets, 1722144 bytes\n2026-03-10T18:28:08.977169Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:28:10.050165Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.0 pps, 154 packets, 1696525 bytes\n2026-03-10T18:28:11.070827Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.14\n2026-03-10T18:28:12.077761Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 155 packets, 1734262 bytes\n2026-03-10T18:28:13.120049Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.74\n2026-03-10T18:28:14.090820Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 78.0 pps, 157 packets, 1742634 bytes\n2026-03-10T18:28:15.126759Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:28:16.096531Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1703918 bytes\n2026-03-10T18:28:17.130807Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:28:18.115045Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 156 packets, 1708747 bytes\n2026-03-10T18:28:19.160625Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.56\n2026-03-10T18:28:20.116888Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1670837 bytes\n2026-03-10T18:28:21.172213Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.82\n2026-03-10T18:28:22.137090Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.2 pps, 154 packets, 1676379 bytes\n2026-03-10T18:28:23.175979Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.95\n2026-03-10T18:28:24.141041Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1720874 bytes\n2026-03-10T18:28:25.232590Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.63\n2026-03-10T18:28:26.150075Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1719470 bytes\n2026-03-10T18:28:27.245110Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.81\n2026-03-10T18:28:28.177255Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.5 pps, 155 packets, 1697646 bytes\n2026-03-10T18:28:29.276136Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.53\n2026-03-10T18:28:30.184912Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1731392 bytes\n2026-03-10T18:28:31.342112Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.53\n2026-03-10T18:28:32.194959Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 78.1 pps, 157 packets, 1688067 bytes\n2026-03-10T18:28:33.343591Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:28:34.201621Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1707844 bytes\n2026-03-10T18:28:35.348048Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:28:36.202786Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 154 packets, 1654495 bytes\n2026-03-10T18:28:37.348953Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:28:38.209379Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.2 pps, 155 packets, 1722239 bytes\n2026-03-10T18:28:39.364349Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.27\n2026-03-10T18:28:40.216865Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1657901 bytes\n2026-03-10T18:28:41.365450Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.98\n2026-03-10T18:28:42.259585Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.9 pps, 155 packets, 1712486 bytes\n2026-03-10T18:28:43.365982Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:28:44.265017Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.3 pps, 155 packets, 1760730 bytes\n2026-03-10T18:28:45.442632Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.37\n2026-03-10T18:28:46.276099Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1688882 bytes\n2026-03-10T18:28:47.472555Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.54\n2026-03-10T18:28:48.280725Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 155 packets, 1707847 bytes\n2026-03-10T18:28:49.540841Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.49\n2026-03-10T18:28:50.297140Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 154 packets, 1660998 bytes\n2026-03-10T18:28:51.557610Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.74\n2026-03-10T18:28:52.311309Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.5 pps, 156 packets, 1794070 bytes\n2026-03-10T18:28:53.566042Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.38\n2026-03-10T18:28:54.339072Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 75.9 pps, 154 packets, 1637955 bytes\n2026-03-10T18:28:55.576324Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:28:56.344929Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1698364 bytes\n2026-03-10T18:28:57.584060Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:28:58.354203Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1709233 bytes\n2026-03-10T18:28:59.624509Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:29:00.357140Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.9 pps, 156 packets, 1744490 bytes\n2026-03-10T18:29:01.635467Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:29:02.395364Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.0 pps, 155 packets, 1734752 bytes\n2026-03-10T18:29:03.637245Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:29:04.427487Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 77.3 pps, 157 packets, 1859095 bytes\n2026-03-10T18:29:05.666444Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.55\n2026-03-10T18:29:06.428799Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1676937 bytes\n2026-03-10T18:29:07.672362Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:29:08.435855Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1672441 bytes\n2026-03-10T18:29:09.759996Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.22\n2026-03-10T18:29:10.437078Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1686978 bytes\n2026-03-10T18:29:11.760602Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.49\n2026-03-10T18:29:12.449207Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.5 pps, 154 packets, 1681859 bytes\n2026-03-10T18:29:13.766164Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:29:14.458572Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.1 pps, 155 packets, 1775073 bytes\n2026-03-10T18:29:15.849461Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.28\n2026-03-10T18:29:16.473472Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 154 packets, 1676161 bytes\n2026-03-10T18:29:17.871639Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.66\n2026-03-10T18:29:18.492395Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.8 pps, 157 packets, 1726148 bytes\n2026-03-10T18:29:19.871683Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:29:20.497995Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 75.8 pps, 152 packets, 1732597 bytes\n2026-03-10T18:29:21.963491Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.16\n2026-03-10T18:29:22.507282Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 78.1 pps, 157 packets, 1624285 bytes\n2026-03-10T18:29:23.968971Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.92\n2026-03-10T18:29:24.518894Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.1 pps, 155 packets, 1730783 bytes\n2026-03-10T18:29:25.969250Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:29:26.534095Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 155 packets, 1695342 bytes\n2026-03-10T18:29:27.981396Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.82\n2026-03-10T18:29:28.547359Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.5 pps, 154 packets, 1733634 bytes\n2026-03-10T18:29:30.044415Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.57\n2026-03-10T18:29:30.587427Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.0 pps, 155 packets, 1740811 bytes\n2026-03-10T18:29:32.044847Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.49\n2026-03-10T18:29:32.587917Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1701485 bytes\n2026-03-10T18:29:34.048384Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:29:34.609150Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.2 pps, 154 packets, 1675982 bytes\n2026-03-10T18:29:36.067037Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.23\n2026-03-10T18:29:36.615824Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 78.7 pps, 158 packets, 1720355 bytes\n2026-03-10T18:29:38.071493Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.93\n2026-03-10T18:29:38.640791Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.1 pps, 154 packets, 1692836 bytes\n2026-03-10T18:29:40.154857Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.28\n2026-03-10T18:29:40.645201Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.8 pps, 154 packets, 1749720 bytes\n2026-03-10T18:29:42.166579Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.82\n2026-03-10T18:29:42.650347Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1661953 bytes\n2026-03-10T18:29:44.172065Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:29:44.661793Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.1 pps, 155 packets, 1755653 bytes\n2026-03-10T18:29:46.235587Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.56\n2026-03-10T18:29:46.665517Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 153 packets, 1664234 bytes\n2026-03-10T18:29:48.248558Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.30\n2026-03-10T18:29:48.672723Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.7 pps, 156 packets, 1621109 bytes\n2026-03-10T18:29:50.277030Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.07\n2026-03-10T18:29:50.673083Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 153 packets, 1699990 bytes\n2026-03-10T18:29:52.318374Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:29:52.702757Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 156 packets, 1704815 bytes\n2026-03-10T18:29:54.333870Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.77\n2026-03-10T18:29:54.722266Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.6 Mbps, 77.2 pps, 156 packets, 1921419 bytes\n2026-03-10T18:29:56.340485Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.40\n2026-03-10T18:29:56.746504Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 155 packets, 1722783 bytes\n2026-03-10T18:29:58.347987Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:29:58.759740Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 77.5 pps, 156 packets, 1610177 bytes\n2026-03-10T18:30:00.355771Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:30:00.765710Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 76.8 pps, 154 packets, 1584084 bytes\n2026-03-10T18:30:02.407304Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.25\n2026-03-10T18:30:02.781281Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 155 packets, 1704081 bytes\n2026-03-10T18:30:04.467534Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.61\n2026-03-10T18:30:04.789779Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.2 pps, 153 packets, 1689335 bytes\n2026-03-10T18:30:06.479044Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.82\n2026-03-10T18:30:06.795413Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.8 pps, 156 packets, 1694311 bytes\n2026-03-10T18:30:08.481093Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:30:08.833666Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.0 pps, 155 packets, 1696656 bytes\n2026-03-10T18:30:10.537868Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.66\n2026-03-10T18:30:10.861084Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 155 packets, 1715889 bytes\n2026-03-10T18:30:12.546145Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.37\n2026-03-10T18:30:12.864154Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.4 pps, 155 packets, 1763480 bytes\n2026-03-10T18:30:14.548581Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:30:14.870454Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.8 pps, 156 packets, 1694286 bytes\n2026-03-10T18:30:16.558634Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:30:16.878896Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 76.7 pps, 154 packets, 1842209 bytes\n2026-03-10T18:30:18.560185Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:30:18.884005Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.1 Mbps, 75.8 pps, 152 packets, 1534245 bytes\n2026-03-10T18:30:20.561312Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:30:20.886819Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.9 pps, 156 packets, 1693311 bytes\n2026-03-10T18:30:22.587298Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.63\n2026-03-10T18:30:22.890197Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.4 pps, 155 packets, 1666030 bytes\n2026-03-10T18:30:24.593796Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.40\n2026-03-10T18:30:24.898706Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.7 pps, 154 packets, 1806370 bytes\n2026-03-10T18:30:26.629781Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.45\n2026-03-10T18:30:26.900541Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.4 pps, 153 packets, 1610508 bytes\n2026-03-10T18:30:28.661955Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.51\n2026-03-10T18:30:28.909912Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.1 pps, 155 packets, 1808115 bytes\n2026-03-10T18:30:30.664597Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.46\n2026-03-10T18:30:30.911684Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1640425 bytes\n2026-03-10T18:30:32.729796Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.02\n2026-03-10T18:30:32.915484Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1701495 bytes\n2026-03-10T18:30:34.746646Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.75\n2026-03-10T18:30:34.930826Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 154 packets, 1673808 bytes\n2026-03-10T18:30:36.755142Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:30:36.941840Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1718201 bytes\n2026-03-10T18:30:38.784268Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.56\n2026-03-10T18:30:38.946841Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1646298 bytes\n2026-03-10T18:30:40.857493Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.42\n2026-03-10T18:30:40.962835Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.9 pps, 157 packets, 1791755 bytes\n2026-03-10T18:30:42.859260Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.97\n2026-03-10T18:30:42.966315Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1720611 bytes\n2026-03-10T18:30:44.862717Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:30:44.970412Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.3 pps, 153 packets, 1707356 bytes\n2026-03-10T18:30:46.873650Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:30:46.981421Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1678965 bytes\n2026-03-10T18:30:48.874830Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T18:30:48.981788Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1695713 bytes\n2026-03-10T18:30:50.929083Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.18\n2026-03-10T18:30:50.991085Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.1 pps, 155 packets, 1719861 bytes\n2026-03-10T18:30:52.939883Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:30:52.997718Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.7 pps, 154 packets, 1642492 bytes\n2026-03-10T18:30:54.945995Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:30:55.001586Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1688119 bytes\n2026-03-10T18:30:56.956499Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:30:57.031765Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.3 pps, 155 packets, 1739022 bytes\n2026-03-10T18:30:58.956716Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:30:59.039804Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1702867 bytes\n2026-03-10T18:31:01.032578Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.39\n2026-03-10T18:31:01.043763Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1695689 bytes\n2026-03-10T18:31:03.034370Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:31:03.052969Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.6 pps, 154 packets, 1621367 bytes\n2026-03-10T18:31:05.036652Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.47\n2026-03-10T18:31:05.061788Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.7 pps, 156 packets, 1769565 bytes\n2026-03-10T18:31:07.041194Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:31:07.066350Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1734136 bytes\n2026-03-10T18:31:09.041213Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:31:09.074415Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1652865 bytes\n2026-03-10T18:31:11.041323Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:31:11.091156Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 155 packets, 1717996 bytes\n2026-03-10T18:31:13.058990Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.23\n2026-03-10T18:31:13.107555Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.4 pps, 156 packets, 1785658 bytes\n2026-03-10T18:31:15.069158Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.85\n2026-03-10T18:31:15.112575Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.8 pps, 154 packets, 1766335 bytes\n2026-03-10T18:31:17.143383Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 75.8 pps, 154 packets, 1728223 bytes\n2026-03-10T18:31:17.172906Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:31:19.151415Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.1 Mbps, 77.7 pps, 156 packets, 1538797 bytes\n2026-03-10T18:31:19.266283Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.09\n2026-03-10T18:31:21.163147Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.0 pps, 155 packets, 1772982 bytes\n2026-03-10T18:31:21.317449Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.71\n2026-03-10T18:31:23.177459Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.5 pps, 154 packets, 1619194 bytes\n2026-03-10T18:31:23.341327Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.65\n2026-03-10T18:31:25.180596Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.4 pps, 155 packets, 1801817 bytes\n2026-03-10T18:31:25.354359Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.80\n2026-03-10T18:31:27.192261Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1677644 bytes\n2026-03-10T18:31:27.357655Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:31:29.196011Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1642250 bytes\n2026-03-10T18:31:29.359793Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:31:31.198773Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.4 pps, 155 packets, 1668643 bytes\n2026-03-10T18:31:31.365269Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:31:33.203588Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 153 packets, 1683493 bytes\n2026-03-10T18:31:33.430950Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.53\n2026-03-10T18:31:35.211492Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1713707 bytes\n2026-03-10T18:31:35.437512Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.40\n2026-03-10T18:31:37.212771Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 154 packets, 1721357 bytes\n2026-03-10T18:31:37.441720Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.44\n2026-03-10T18:31:39.236580Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.1 pps, 154 packets, 1686225 bytes\n2026-03-10T18:31:39.447211Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:31:41.251997Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.4 pps, 156 packets, 1757247 bytes\n2026-03-10T18:31:41.460687Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:31:43.252880Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1671677 bytes\n2026-03-10T18:31:43.468668Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:31:45.264980Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1678534 bytes\n2026-03-10T18:31:45.509622Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:31:47.266571Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.4 pps, 153 packets, 1742642 bytes\n2026-03-10T18:31:47.519552Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:31:49.270177Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.4 pps, 155 packets, 1698488 bytes\n2026-03-10T18:31:49.533674Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:31:51.276883Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1644813 bytes\n2026-03-10T18:31:51.540910Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.39\n2026-03-10T18:31:53.278272Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1710713 bytes\n2026-03-10T18:31:53.541021Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:31:55.286729Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1682449 bytes\n2026-03-10T18:31:55.551146Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:31:57.287156Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.0 pps, 154 packets, 1752001 bytes\n2026-03-10T18:31:57.564647Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.30\n2026-03-10T18:31:59.296071Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1659803 bytes\n2026-03-10T18:31:59.565322Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:32:01.300188Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.3 pps, 153 packets, 1716395 bytes\n2026-03-10T18:32:01.621039Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.67\n2026-03-10T18:32:03.304538Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 77.3 pps, 155 packets, 1862026 bytes\n2026-03-10T18:32:03.636980Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.76\n2026-03-10T18:32:05.310691Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 76.8 pps, 154 packets, 1582454 bytes\n2026-03-10T18:32:05.654090Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.75\n2026-03-10T18:32:07.313089Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1656109 bytes\n2026-03-10T18:32:07.658797Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.93\n2026-03-10T18:32:09.334318Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.2 pps, 154 packets, 1657173 bytes\n2026-03-10T18:32:09.663314Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:32:11.346087Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1695322 bytes\n2026-03-10T18:32:11.665895Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:32:13.366128Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.2 pps, 154 packets, 1733974 bytes\n2026-03-10T18:32:13.735601Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.47\n2026-03-10T18:32:15.379221Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 155 packets, 1701862 bytes\n2026-03-10T18:32:15.761499Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.60\n2026-03-10T18:32:17.390150Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.6 pps, 156 packets, 1681319 bytes\n2026-03-10T18:32:17.770605Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:32:19.392829Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1697731 bytes\n2026-03-10T18:32:19.770861Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:32:21.398619Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1739498 bytes\n2026-03-10T18:32:21.772163Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T18:32:23.401137Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1736424 bytes\n2026-03-10T18:32:23.827945Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.16\n2026-03-10T18:32:25.407642Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.8 pps, 154 packets, 1639728 bytes\n2026-03-10T18:32:25.841923Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.29\n2026-03-10T18:32:27.411394Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1680835 bytes\n2026-03-10T18:32:27.844818Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:32:29.448962Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.1 pps, 155 packets, 1695783 bytes\n2026-03-10T18:32:29.859109Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.28\n2026-03-10T18:32:31.526517Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.2 Mbps, 69.8 pps, 145 packets, 1619186 bytes\n2026-03-10T18:32:31.866397Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 27.40\n2026-03-10T18:32:33.668065Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 74.2 pps, 159 packets, 1724966 bytes\n2026-03-10T18:32:34.015463Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.85\n2026-03-10T18:32:35.676536Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 79.7 pps, 160 packets, 1838696 bytes\n2026-03-10T18:32:36.215642Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:32:37.892151Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 72.7 pps, 161 packets, 1765484 bytes\n2026-03-10T18:32:38.216084Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.99\n2026-03-10T18:32:39.903680Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 83.5 pps, 168 packets, 1823702 bytes\n2026-03-10T18:32:40.216182Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.00\n2026-03-10T18:32:41.914867Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 82.5 pps, 166 packets, 1801366 bytes\n2026-03-10T18:32:42.230986Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 32.26\n2026-03-10T18:32:43.933914Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 155 packets, 1721343 bytes\n2026-03-10T18:32:44.241405Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:32:45.949327Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.4 pps, 156 packets, 1756969 bytes\n2026-03-10T18:32:46.254543Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.30\n2026-03-10T18:32:47.959188Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.1 pps, 153 packets, 1672857 bytes\n2026-03-10T18:32:48.256133Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:32:49.967892Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.7 pps, 156 packets, 1684611 bytes\n2026-03-10T18:32:50.263101Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.39\n2026-03-10T18:32:51.971978Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 153 packets, 1673187 bytes\n2026-03-10T18:32:52.326220Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.57\n2026-03-10T18:32:53.973044Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.5 pps, 155 packets, 1713526 bytes\n2026-03-10T18:32:54.326333Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:32:55.976671Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1674791 bytes\n2026-03-10T18:32:56.335610Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:32:57.981452Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1732206 bytes\n2026-03-10T18:32:58.364100Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.07\n2026-03-10T18:32:59.987892Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 76.8 pps, 154 packets, 1822027 bytes\n2026-03-10T18:33:00.368997Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:33:01.995753Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 77.2 pps, 155 packets, 1579925 bytes\n2026-03-10T18:33:02.420125Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.74\n2026-03-10T18:33:04.001035Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.8 pps, 154 packets, 1763084 bytes\n2026-03-10T18:33:04.427011Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:33:06.004498Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 76.9 pps, 154 packets, 1589501 bytes\n2026-03-10T18:33:06.454525Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.59\n2026-03-10T18:33:08.009713Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1723540 bytes\n2026-03-10T18:33:08.460409Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.41\n2026-03-10T18:33:10.010635Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1663740 bytes\n2026-03-10T18:33:10.478846Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.22\n2026-03-10T18:33:12.036901Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.0 pps, 154 packets, 1730068 bytes\n2026-03-10T18:33:12.543906Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.54\n2026-03-10T18:33:14.048744Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.5 pps, 156 packets, 1791289 bytes\n2026-03-10T18:33:14.546534Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.46\n2026-03-10T18:33:16.054955Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1656259 bytes\n2026-03-10T18:33:16.566673Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.21\n2026-03-10T18:33:18.055432Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1676971 bytes\n2026-03-10T18:33:18.571187Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.43\n2026-03-10T18:33:20.071034Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 154 packets, 1697648 bytes\n2026-03-10T18:33:20.681553Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.38\n2026-03-10T18:33:22.087877Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 156 packets, 1731125 bytes\n2026-03-10T18:33:22.723233Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.86\n2026-03-10T18:33:24.095392Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1697381 bytes\n2026-03-10T18:33:24.752718Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.56\n2026-03-10T18:33:26.097631Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1735302 bytes\n2026-03-10T18:33:26.763467Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.83\n2026-03-10T18:33:28.107861Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.6 pps, 154 packets, 1655041 bytes\n2026-03-10T18:33:28.825614Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.58\n2026-03-10T18:33:30.108118Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1681472 bytes\n2026-03-10T18:33:30.831521Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:33:32.111157Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1698482 bytes\n2026-03-10T18:33:32.858848Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.58\n2026-03-10T18:33:34.129427Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 154 packets, 1692328 bytes\n2026-03-10T18:33:34.873686Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.78\n2026-03-10T18:33:36.157282Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 155 packets, 1723678 bytes\n2026-03-10T18:33:36.936962Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.56\n2026-03-10T18:33:38.176919Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.7 pps, 157 packets, 1805507 bytes\n2026-03-10T18:33:38.952039Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.77\n2026-03-10T18:33:40.180933Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.8 pps, 154 packets, 1639549 bytes\n2026-03-10T18:33:40.960105Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:33:42.184814Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.9 pps, 154 packets, 1634925 bytes\n2026-03-10T18:33:43.031764Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.45\n2026-03-10T18:33:44.187687Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 76.9 pps, 154 packets, 1835525 bytes\n2026-03-10T18:33:45.063597Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.51\n2026-03-10T18:33:46.194317Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1672631 bytes\n2026-03-10T18:33:47.067193Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:33:48.196644Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 153 packets, 1661682 bytes\n2026-03-10T18:33:49.112591Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.82\n2026-03-10T18:33:50.198765Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 153 packets, 1688457 bytes\n2026-03-10T18:33:51.143103Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.55\n2026-03-10T18:33:52.225680Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.0 pps, 156 packets, 1817282 bytes\n2026-03-10T18:33:53.159588Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.75\n2026-03-10T18:33:54.236374Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.9 Mbps, 77.1 pps, 155 packets, 1486954 bytes\n2026-03-10T18:33:55.166226Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.40\n2026-03-10T18:33:56.279768Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 75.9 pps, 155 packets, 1830429 bytes\n2026-03-10T18:33:57.246191Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.81\n2026-03-10T18:33:58.285997Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 155 packets, 1658228 bytes\n2026-03-10T18:33:59.248765Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:34:00.301817Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.9 pps, 157 packets, 1773580 bytes\n2026-03-10T18:34:01.255204Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.90\n2026-03-10T18:34:02.309973Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1647369 bytes\n2026-03-10T18:34:03.296672Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:34:04.319018Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1721071 bytes\n2026-03-10T18:34:05.328624Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.53\n2026-03-10T18:34:06.333461Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 154 packets, 1716553 bytes\n2026-03-10T18:34:07.341216Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.31\n2026-03-10T18:34:08.340079Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1693158 bytes\n2026-03-10T18:34:09.357797Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.25\n2026-03-10T18:34:10.350805Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.1 pps, 153 packets, 1608607 bytes\n2026-03-10T18:34:11.364851Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.40\n2026-03-10T18:34:12.356866Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 78.8 pps, 158 packets, 1778772 bytes\n2026-03-10T18:34:13.424378Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.10\n2026-03-10T18:34:14.382033Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.1 pps, 152 packets, 1697119 bytes\n2026-03-10T18:34:15.429384Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:34:16.386112Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 78.3 pps, 157 packets, 1671035 bytes\n2026-03-10T18:34:17.436386Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.39\n2026-03-10T18:34:18.391389Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1732758 bytes\n2026-03-10T18:34:19.448836Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.32\n2026-03-10T18:34:20.397741Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 153 packets, 1675560 bytes\n2026-03-10T18:34:21.456140Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.89\n2026-03-10T18:34:22.417496Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 76.7 pps, 155 packets, 1831606 bytes\n2026-03-10T18:34:23.521867Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.53\n2026-03-10T18:34:24.418687Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1691146 bytes\n2026-03-10T18:34:25.551713Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.56\n2026-03-10T18:34:26.418801Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.1 Mbps, 76.5 pps, 153 packets, 1517083 bytes\n2026-03-10T18:34:27.555698Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.94\n2026-03-10T18:34:28.428622Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 77.1 pps, 155 packets, 1847442 bytes\n2026-03-10T18:34:29.558782Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:34:30.454774Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.0 pps, 154 packets, 1731174 bytes\n2026-03-10T18:34:31.634499Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.39\n2026-03-10T18:34:32.462611Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1668257 bytes\n2026-03-10T18:34:33.653151Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.71\n2026-03-10T18:34:34.486647Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.1 pps, 154 packets, 1691482 bytes\n2026-03-10T18:34:35.661053Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:34:36.496138Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.6 Mbps, 78.6 pps, 158 packets, 1913605 bytes\n2026-03-10T18:34:37.733931Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.43\n2026-03-10T18:34:38.501868Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 75.8 pps, 152 packets, 1571574 bytes\n2026-03-10T18:34:39.740524Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.40\n2026-03-10T18:34:40.508028Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 78.8 pps, 158 packets, 1687062 bytes\n2026-03-10T18:34:41.754411Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.30\n2026-03-10T18:34:42.509141Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1710905 bytes\n2026-03-10T18:34:43.839863Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.21\n2026-03-10T18:34:44.511166Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1711661 bytes\n2026-03-10T18:34:45.864643Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.62\n2026-03-10T18:34:46.526322Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 154 packets, 1670877 bytes\n2026-03-10T18:34:47.927845Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.57\n2026-03-10T18:34:48.534563Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 154 packets, 1709761 bytes\n2026-03-10T18:34:49.933670Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.41\n2026-03-10T18:34:50.536435Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 153 packets, 1671599 bytes\n2026-03-10T18:34:51.962471Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.07\n2026-03-10T18:34:52.554762Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 155 packets, 1722248 bytes\n2026-03-10T18:34:53.970581Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:34:54.561974Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.2 pps, 155 packets, 1738266 bytes\n2026-03-10T18:34:56.019532Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.77\n2026-03-10T18:34:56.571729Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.6 pps, 156 packets, 1719716 bytes\n2026-03-10T18:34:58.021553Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:34:58.576631Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1667710 bytes\n2026-03-10T18:35:00.025702Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:35:00.605435Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 75.9 pps, 154 packets, 1791601 bytes\n2026-03-10T18:35:02.029668Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:35:02.610921Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 77.3 pps, 155 packets, 1600845 bytes\n2026-03-10T18:35:04.044815Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.27\n2026-03-10T18:35:04.617818Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.7 pps, 156 packets, 1686835 bytes\n2026-03-10T18:35:06.054139Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.36\n2026-03-10T18:35:06.623810Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1716399 bytes\n2026-03-10T18:35:08.127935Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.41\n2026-03-10T18:35:08.640214Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.9 pps, 153 packets, 1688903 bytes\n2026-03-10T18:35:10.133958Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:35:10.663653Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.1 pps, 156 packets, 1807833 bytes\n2026-03-10T18:35:12.193297Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.14\n2026-03-10T18:35:12.678353Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.4 pps, 156 packets, 1659311 bytes\n2026-03-10T18:35:14.234049Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.87\n2026-03-10T18:35:14.689635Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 75.6 pps, 152 packets, 1730233 bytes\n2026-03-10T18:35:16.267434Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.51\n2026-03-10T18:35:16.693061Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1657734 bytes\n2026-03-10T18:35:18.367003Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.01\n2026-03-10T18:35:18.693708Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 78.5 pps, 157 packets, 1653787 bytes\n2026-03-10T18:35:20.373201Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.90\n2026-03-10T18:35:20.697981Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1741138 bytes\n2026-03-10T18:35:22.378056Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:35:22.706814Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1721050 bytes\n2026-03-10T18:35:24.458713Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.32\n2026-03-10T18:35:24.724953Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.3 pps, 154 packets, 1666898 bytes\n2026-03-10T18:35:26.483323Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.62\n2026-03-10T18:35:26.725929Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1685593 bytes\n2026-03-10T18:35:28.525880Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:35:28.728954Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1721766 bytes\n2026-03-10T18:35:30.533005Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.39\n2026-03-10T18:35:30.741358Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 155 packets, 1722596 bytes\n2026-03-10T18:35:32.554721Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.17\n2026-03-10T18:35:32.743980Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 153 packets, 1641713 bytes\n2026-03-10T18:35:34.574819Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.71\n2026-03-10T18:35:34.753075Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.6 pps, 156 packets, 1737752 bytes\n2026-03-10T18:35:36.626973Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.70\n2026-03-10T18:35:36.757972Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1675152 bytes\n2026-03-10T18:35:38.633477Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:35:38.774417Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 155 packets, 1729262 bytes\n2026-03-10T18:35:40.633675Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.50\n2026-03-10T18:35:40.774824Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1706172 bytes\n2026-03-10T18:35:42.645648Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:35:42.779169Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 155 packets, 1644905 bytes\n2026-03-10T18:35:44.659996Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.79\n2026-03-10T18:35:44.785783Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.2 pps, 153 packets, 1705437 bytes\n2026-03-10T18:35:46.660053Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.00\n2026-03-10T18:35:46.806757Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 155 packets, 1697294 bytes\n2026-03-10T18:35:48.724365Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.55\n2026-03-10T18:35:48.826521Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 155 packets, 1752980 bytes\n2026-03-10T18:35:50.724924Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.49\n2026-03-10T18:35:50.837077Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.6 pps, 154 packets, 1748995 bytes\n2026-03-10T18:35:52.732982Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:35:52.840140Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1688247 bytes\n2026-03-10T18:35:54.766969Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.01\n2026-03-10T18:35:54.843565Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1679779 bytes\n2026-03-10T18:35:56.768094Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:35:56.859505Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.4 pps, 156 packets, 1782751 bytes\n2026-03-10T18:35:58.769155Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:35:58.865527Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 155 packets, 1647219 bytes\n2026-03-10T18:36:00.821440Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.21\n2026-03-10T18:36:00.866438Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1711416 bytes\n2026-03-10T18:36:02.852866Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.54\n2026-03-10T18:36:02.880705Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 75.5 pps, 152 packets, 1639816 bytes\n2026-03-10T18:36:04.869762Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.74\n2026-03-10T18:36:04.899153Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 78.3 pps, 158 packets, 1675760 bytes\n2026-03-10T18:36:06.909085Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.6 pps, 154 packets, 1730647 bytes\n2026-03-10T18:36:06.927581Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.64\n2026-03-10T18:36:08.911151Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1697933 bytes\n2026-03-10T18:36:08.935442Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:36:10.917021Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1681544 bytes\n2026-03-10T18:36:10.960029Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.64\n2026-03-10T18:36:12.935135Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 155 packets, 1685931 bytes\n2026-03-10T18:36:13.037232Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.33\n2026-03-10T18:36:14.949094Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.5 pps, 154 packets, 1823760 bytes\n2026-03-10T18:36:15.052519Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.77\n2026-03-10T18:36:16.961765Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1685375 bytes\n2026-03-10T18:36:17.052556Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.00\n2026-03-10T18:36:18.980002Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 156 packets, 1668825 bytes\n2026-03-10T18:36:19.058305Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:36:20.984131Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1701688 bytes\n2026-03-10T18:36:21.109055Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.75\n2026-03-10T18:36:22.991914Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1644960 bytes\n2026-03-10T18:36:23.112814Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:36:24.994334Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.4 pps, 153 packets, 1728736 bytes\n2026-03-10T18:36:25.118876Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:36:27.003960Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.5 Mbps, 77.1 pps, 155 packets, 1894672 bytes\n2026-03-10T18:36:27.124350Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:36:29.005699Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1639885 bytes\n2026-03-10T18:36:29.151904Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.58\n2026-03-10T18:36:31.006974Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.0 pps, 154 packets, 1740012 bytes\n2026-03-10T18:36:31.158362Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.91\n2026-03-10T18:36:33.031467Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.8 Mbps, 76.1 pps, 154 packets, 1469356 bytes\n2026-03-10T18:36:33.253795Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.07\n2026-03-10T18:36:35.039896Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.2 pps, 155 packets, 1749313 bytes\n2026-03-10T18:36:35.324356Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.43\n2026-03-10T18:36:37.076179Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.1 pps, 155 packets, 1755001 bytes\n2026-03-10T18:36:37.348029Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.65\n2026-03-10T18:36:39.077530Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 77.4 pps, 155 packets, 1610432 bytes\n2026-03-10T18:36:39.354881Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:36:41.080412Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.4 pps, 155 packets, 1673380 bytes\n2026-03-10T18:36:41.364235Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:36:43.086744Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1740940 bytes\n2026-03-10T18:36:43.452665Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.17\n2026-03-10T18:36:45.092838Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1686546 bytes\n2026-03-10T18:36:45.570748Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.74\n2026-03-10T18:36:47.108423Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 155 packets, 1712182 bytes\n2026-03-10T18:36:47.659465Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.16\n2026-03-10T18:36:49.121567Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1686062 bytes\n2026-03-10T18:36:49.697905Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.91\n2026-03-10T18:36:51.141483Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 76.7 pps, 155 packets, 1857113 bytes\n2026-03-10T18:36:51.726048Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.58\n2026-03-10T18:36:53.171740Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.3 pps, 155 packets, 1657760 bytes\n2026-03-10T18:36:53.727607Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:36:55.179707Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.7 pps, 156 packets, 1630374 bytes\n2026-03-10T18:36:55.729095Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:36:57.180798Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.0 pps, 154 packets, 1777857 bytes\n2026-03-10T18:36:57.731761Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:36:59.192275Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1690295 bytes\n2026-03-10T18:36:59.737178Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.42\n2026-03-10T18:37:01.198856Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1665875 bytes\n2026-03-10T18:37:01.748721Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.82\n2026-03-10T18:37:03.206191Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1736263 bytes\n2026-03-10T18:37:03.757625Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.87\n2026-03-10T18:37:05.242120Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.1 pps, 155 packets, 1629550 bytes\n2026-03-10T18:37:05.827552Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.44\n2026-03-10T18:37:07.259718Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.8 pps, 157 packets, 1790008 bytes\n2026-03-10T18:37:07.840174Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.81\n2026-03-10T18:37:09.265603Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1672984 bytes\n2026-03-10T18:37:09.854676Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.78\n2026-03-10T18:37:11.388377Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.1 Mbps, 70.2 pps, 149 packets, 1614449 bytes\n2026-03-10T18:37:11.965508Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 27.00\n2026-03-10T18:37:13.523896Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 73.5 pps, 157 packets, 1859759 bytes\n2026-03-10T18:37:13.972260Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.90\n2026-03-10T18:37:15.549595Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 82.9 pps, 168 packets, 1782648 bytes\n2026-03-10T18:37:15.972395Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.00\n2026-03-10T18:37:17.658950Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 5.8 Mbps, 72.1 pps, 152 packets, 1538548 bytes\n2026-03-10T18:37:18.176568Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.04\n2026-03-10T18:37:19.666772Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1734312 bytes\n2026-03-10T18:37:20.183129Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.40\n2026-03-10T18:37:21.673885Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.7 Mbps, 86.7 pps, 174 packets, 1919698 bytes\n2026-03-10T18:37:22.229148Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 33.24\n2026-03-10T18:37:23.685581Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1708310 bytes\n2026-03-10T18:37:24.238934Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:37:25.697598Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1677334 bytes\n2026-03-10T18:37:26.261340Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.67\n2026-03-10T18:37:27.721369Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 155 packets, 1707906 bytes\n2026-03-10T18:37:28.353210Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.12\n2026-03-10T18:37:29.741236Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 155 packets, 1669948 bytes\n2026-03-10T18:37:30.353465Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.00\n2026-03-10T18:37:31.746850Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.3 pps, 155 packets, 1777238 bytes\n2026-03-10T18:37:32.360573Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:37:33.752692Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1665889 bytes\n2026-03-10T18:37:34.365590Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:37:35.756448Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.4 pps, 155 packets, 1697291 bytes\n2026-03-10T18:37:36.430812Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.54\n2026-03-10T18:37:37.757710Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.5 pps, 153 packets, 1741946 bytes\n2026-03-10T18:37:38.436703Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:37:39.760208Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 153 packets, 1707753 bytes\n2026-03-10T18:37:40.446773Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.35\n2026-03-10T18:37:41.775154Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.4 pps, 156 packets, 1646167 bytes\n2026-03-10T18:37:42.460518Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.29\n2026-03-10T18:37:43.782534Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 154 packets, 1739076 bytes\n2026-03-10T18:37:44.464786Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:37:45.806879Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 156 packets, 1702804 bytes\n2026-03-10T18:37:46.521579Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.66\n2026-03-10T18:37:47.838018Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.3 pps, 155 packets, 1667205 bytes\n2026-03-10T18:37:48.550856Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.55\n2026-03-10T18:37:49.863327Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 76.5 pps, 155 packets, 1858602 bytes\n2026-03-10T18:37:50.556310Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:37:51.889881Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.2 Mbps, 76.0 pps, 154 packets, 1561890 bytes\n2026-03-10T18:37:52.625324Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T18:37:53.895808Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 78.8 pps, 158 packets, 1704009 bytes\n2026-03-10T18:37:54.631238Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:37:55.897521Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.4 pps, 153 packets, 1753685 bytes\n2026-03-10T18:37:56.678841Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.28\n2026-03-10T18:37:57.900987Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1664775 bytes\n2026-03-10T18:37:58.710661Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.02\n2026-03-10T18:37:59.902846Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1649390 bytes\n2026-03-10T18:38:00.727879Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.74\n2026-03-10T18:38:01.920474Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 155 packets, 1707945 bytes\n2026-03-10T18:38:02.733066Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:38:03.937694Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.3 pps, 154 packets, 1660005 bytes\n2026-03-10T18:38:04.750581Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.24\n2026-03-10T18:38:05.944124Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 155 packets, 1684940 bytes\n2026-03-10T18:38:06.814813Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.52\n2026-03-10T18:38:07.974429Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.3 pps, 155 packets, 1738779 bytes\n2026-03-10T18:38:08.828935Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.79\n2026-03-10T18:38:09.974793Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 78.0 pps, 156 packets, 1692228 bytes\n2026-03-10T18:38:10.840714Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:38:11.979186Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.8 pps, 154 packets, 1788748 bytes\n2026-03-10T18:38:12.861862Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.18\n2026-03-10T18:38:13.988574Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.1 pps, 155 packets, 1649651 bytes\n2026-03-10T18:38:14.884001Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.67\n2026-03-10T18:38:15.993771Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.3 pps, 153 packets, 1737503 bytes\n2026-03-10T18:38:16.922424Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:38:18.004377Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.1 pps, 155 packets, 1669340 bytes\n2026-03-10T18:38:18.924259Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:38:20.037102Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.7 pps, 156 packets, 1823234 bytes\n2026-03-10T18:38:20.925040Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:38:22.049307Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 77.0 pps, 155 packets, 1611407 bytes\n2026-03-10T18:38:22.936745Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:38:24.056281Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.2 pps, 155 packets, 1765436 bytes\n2026-03-10T18:38:24.949360Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.31\n2026-03-10T18:38:26.057741Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.4 pps, 153 packets, 1597698 bytes\n2026-03-10T18:38:26.950447Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:38:28.067224Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.1 pps, 155 packets, 1691347 bytes\n2026-03-10T18:38:28.955638Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:38:30.069896Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.9 pps, 154 packets, 1741704 bytes\n2026-03-10T18:38:31.019714Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.55\n2026-03-10T18:38:32.071758Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1677841 bytes\n2026-03-10T18:38:33.023322Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.45\n2026-03-10T18:38:34.084564Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.5 pps, 154 packets, 1729398 bytes\n2026-03-10T18:38:35.030768Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:38:36.089025Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.3 pps, 155 packets, 1712172 bytes\n2026-03-10T18:38:37.033219Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:38:38.090809Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 153 packets, 1669706 bytes\n2026-03-10T18:38:39.039118Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:38:40.096479Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 155 packets, 1688941 bytes\n2026-03-10T18:38:41.065176Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.12\n2026-03-10T18:38:42.100971Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.8 pps, 154 packets, 1610067 bytes\n2026-03-10T18:38:43.151370Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.20\n2026-03-10T18:38:44.108918Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.7 pps, 154 packets, 1812084 bytes\n2026-03-10T18:38:45.154198Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:38:46.124338Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 75.9 pps, 153 packets, 1635368 bytes\n2026-03-10T18:38:47.163435Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.86\n2026-03-10T18:38:48.132719Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1656506 bytes\n2026-03-10T18:38:49.222260Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.63\n2026-03-10T18:38:50.136476Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1729972 bytes\n2026-03-10T18:38:51.224792Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.46\n2026-03-10T18:38:52.137539Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.0 pps, 154 packets, 1646836 bytes\n2026-03-10T18:38:53.237374Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.81\n2026-03-10T18:38:54.148654Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.5 Mbps, 77.1 pps, 155 packets, 1895263 bytes\n2026-03-10T18:38:55.237862Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:38:56.155238Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1681011 bytes\n2026-03-10T18:38:57.251213Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.30\n2026-03-10T18:38:58.166344Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.1 pps, 155 packets, 1633721 bytes\n2026-03-10T18:38:59.252277Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:39:00.178017Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.6 pps, 154 packets, 1725867 bytes\n2026-03-10T18:39:01.315395Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.57\n2026-03-10T18:39:02.199131Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.2 pps, 156 packets, 1646436 bytes\n2026-03-10T18:39:03.326607Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:39:04.203979Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1714474 bytes\n2026-03-10T18:39:05.333337Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.40\n2026-03-10T18:39:06.231863Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.4 pps, 155 packets, 1718318 bytes\n2026-03-10T18:39:07.353988Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.19\n2026-03-10T18:39:08.244187Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 155 packets, 1704900 bytes\n2026-03-10T18:39:09.444510Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.18\n2026-03-10T18:39:10.245048Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1684096 bytes\n2026-03-10T18:39:11.460722Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.75\n2026-03-10T18:39:12.247869Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 75.9 pps, 152 packets, 1664298 bytes\n2026-03-10T18:39:13.530595Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.47\n2026-03-10T18:39:14.253217Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.8 pps, 156 packets, 1747148 bytes\n2026-03-10T18:39:15.544921Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.78\n2026-03-10T18:39:16.283591Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.3 pps, 155 packets, 1627060 bytes\n2026-03-10T18:39:17.559956Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.78\n2026-03-10T18:39:18.289796Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.3 pps, 153 packets, 1718352 bytes\n2026-03-10T18:39:19.560004Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.00\n2026-03-10T18:39:20.309642Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 78.2 pps, 158 packets, 1764120 bytes\n2026-03-10T18:39:21.606707Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.78\n2026-03-10T18:39:22.312996Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1656219 bytes\n2026-03-10T18:39:23.610134Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:39:24.332591Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.3 pps, 154 packets, 1648746 bytes\n2026-03-10T18:39:25.643470Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.51\n2026-03-10T18:39:26.365467Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.2 pps, 155 packets, 1811629 bytes\n2026-03-10T18:39:27.649433Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:39:28.376881Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 78.1 pps, 157 packets, 1628807 bytes\n2026-03-10T18:39:29.715875Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.49\n2026-03-10T18:39:30.380737Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1734008 bytes\n2026-03-10T18:39:31.723357Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:39:32.387215Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.3 pps, 153 packets, 1742146 bytes\n2026-03-10T18:39:33.736257Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.81\n2026-03-10T18:39:34.403867Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.9 pps, 155 packets, 1775764 bytes\n2026-03-10T18:39:35.741534Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:39:36.407571Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1712390 bytes\n2026-03-10T18:39:37.752125Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.34\n2026-03-10T18:39:38.411154Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1728694 bytes\n2026-03-10T18:39:39.837596Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.73\n2026-03-10T18:39:40.415923Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1703743 bytes\n2026-03-10T18:39:41.849314Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.82\n2026-03-10T18:39:42.427582Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.6 pps, 154 packets, 1626697 bytes\n2026-03-10T18:39:43.856698Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:39:44.439909Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.5 pps, 154 packets, 1726593 bytes\n2026-03-10T18:39:45.869863Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:39:46.445735Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1677328 bytes\n2026-03-10T18:39:47.921689Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.73\n2026-03-10T18:39:48.449260Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.9 pps, 156 packets, 1775100 bytes\n2026-03-10T18:39:49.925639Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.44\n2026-03-10T18:39:50.453907Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.8 pps, 154 packets, 1731758 bytes\n2026-03-10T18:39:51.928726Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:39:52.478441Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.6 pps, 155 packets, 1637318 bytes\n2026-03-10T18:39:53.949304Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.19\n2026-03-10T18:39:54.483021Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 155 packets, 1663706 bytes\n2026-03-10T18:39:55.956588Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:39:56.488215Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1700470 bytes\n2026-03-10T18:39:57.956885Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.00\n2026-03-10T18:39:58.495085Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.2 pps, 155 packets, 1744984 bytes\n2026-03-10T18:39:59.964400Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.39\n2026-03-10T18:40:00.524151Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.9 pps, 154 packets, 1687834 bytes\n2026-03-10T18:40:02.068487Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.47\n2026-03-10T18:40:02.531109Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1653133 bytes\n2026-03-10T18:40:04.117310Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.75\n2026-03-10T18:40:04.535180Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.3 pps, 155 packets, 1781595 bytes\n2026-03-10T18:40:06.150872Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.49\n2026-03-10T18:40:06.541562Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1656242 bytes\n2026-03-10T18:40:08.224405Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.42\n2026-03-10T18:40:08.547259Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.8 pps, 154 packets, 1801461 bytes\n2026-03-10T18:40:10.230116Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.91\n2026-03-10T18:40:10.553945Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1666543 bytes\n2026-03-10T18:40:12.235405Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.42\n2026-03-10T18:40:12.559593Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.8 pps, 154 packets, 1664509 bytes\n2026-03-10T18:40:14.236030Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:40:14.562605Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1683804 bytes\n2026-03-10T18:40:16.252899Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.25\n2026-03-10T18:40:16.566337Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.9 pps, 154 packets, 1690312 bytes\n2026-03-10T18:40:18.254226Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.98\n2026-03-10T18:40:18.569858Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 76.9 pps, 154 packets, 1773954 bytes\n2026-03-10T18:40:20.255888Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.98\n2026-03-10T18:40:20.585504Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 154 packets, 1652886 bytes\n2026-03-10T18:40:22.314893Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.63\n2026-03-10T18:40:22.602438Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.3 pps, 156 packets, 1665854 bytes\n2026-03-10T18:40:24.316504Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.48\n2026-03-10T18:40:24.603026Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1699544 bytes\n2026-03-10T18:40:26.321087Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:40:26.607112Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.3 pps, 153 packets, 1697234 bytes\n2026-03-10T18:40:28.328712Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.89\n2026-03-10T18:40:28.633151Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 155 packets, 1729841 bytes\n2026-03-10T18:40:30.337832Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:40:30.643330Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.6 pps, 156 packets, 1677196 bytes\n2026-03-10T18:40:32.351819Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.29\n2026-03-10T18:40:32.653541Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1714670 bytes\n2026-03-10T18:40:34.364727Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.81\n2026-03-10T18:40:34.655190Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.4 pps, 155 packets, 1766180 bytes\n2026-03-10T18:40:36.365323Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:40:36.658301Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.4 pps, 153 packets, 1654606 bytes\n2026-03-10T18:40:38.415410Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.73\n2026-03-10T18:40:38.658850Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1667407 bytes\n2026-03-10T18:40:40.431087Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.77\n2026-03-10T18:40:40.672195Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.5 pps, 154 packets, 1714171 bytes\n2026-03-10T18:40:42.435245Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.44\n2026-03-10T18:40:42.675983Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.4 pps, 155 packets, 1800599 bytes\n2026-03-10T18:40:44.448703Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:40:44.689706Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.0 Mbps, 76.0 pps, 153 packets, 1517474 bytes\n2026-03-10T18:40:46.456318Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:40:46.691941Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.4 pps, 155 packets, 1755829 bytes\n2026-03-10T18:40:48.468042Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:40:48.712087Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 156 packets, 1660243 bytes\n2026-03-10T18:40:50.470675Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:40:50.738315Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.5 pps, 155 packets, 1680285 bytes\n2026-03-10T18:40:52.479841Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.86\n2026-03-10T18:40:52.743626Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 153 packets, 1672955 bytes\n2026-03-10T18:40:54.513347Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.00\n2026-03-10T18:40:54.753487Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.6 pps, 156 packets, 1758510 bytes\n2026-03-10T18:40:56.518636Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:40:56.755733Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1695208 bytes\n2026-03-10T18:40:58.521383Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:40:58.757125Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1717242 bytes\n2026-03-10T18:41:00.550222Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.56\n2026-03-10T18:41:00.764468Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.2 pps, 155 packets, 1704159 bytes\n2026-03-10T18:41:02.604571Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.69\n2026-03-10T18:41:02.773690Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.6 pps, 154 packets, 1714953 bytes\n2026-03-10T18:41:04.621505Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.75\n2026-03-10T18:41:04.779066Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.4 Mbps, 76.8 pps, 154 packets, 1846550 bytes\n2026-03-10T18:41:06.629035Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.39\n2026-03-10T18:41:06.785284Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.3 pps, 155 packets, 1769875 bytes\n2026-03-10T18:41:08.657909Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.07\n2026-03-10T18:41:08.788493Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1723843 bytes\n2026-03-10T18:41:10.713611Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.67\n2026-03-10T18:41:10.788695Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.2 Mbps, 76.5 pps, 153 packets, 1558880 bytes\n2026-03-10T18:41:12.754786Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.39\n2026-03-10T18:41:12.796768Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1675995 bytes\n2026-03-10T18:41:14.806053Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.23\n2026-03-10T18:41:14.806455Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 74.6 pps, 150 packets, 1646477 bytes\n2026-03-10T18:41:16.822918Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.24\n2026-03-10T18:41:16.835129Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.9 pps, 158 packets, 1721515 bytes\n2026-03-10T18:41:18.825598Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.46\n2026-03-10T18:41:18.840990Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.8 pps, 156 packets, 1690761 bytes\n2026-03-10T18:41:20.830119Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:41:20.845008Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1696345 bytes\n2026-03-10T18:41:22.832169Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:41:22.846434Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.4 pps, 155 packets, 1708728 bytes\n2026-03-10T18:41:24.848457Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.25\n2026-03-10T18:41:24.848751Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.4 pps, 153 packets, 1750654 bytes\n2026-03-10T18:41:26.854497Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1717700 bytes\n2026-03-10T18:41:26.945189Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.09\n2026-03-10T18:41:28.857914Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 76.4 pps, 153 packets, 1590129 bytes\n2026-03-10T18:41:29.015032Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.44\n2026-03-10T18:41:30.865349Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.2 pps, 153 packets, 1609893 bytes\n2026-03-10T18:41:31.018417Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:41:32.872656Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.2 pps, 153 packets, 1687919 bytes\n2026-03-10T18:41:33.019241Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:41:34.874955Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 78.4 pps, 157 packets, 1710207 bytes\n2026-03-10T18:41:35.023074Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:41:36.875908Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 72.0 pps, 144 packets, 1627465 bytes\n2026-03-10T18:41:37.090007Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 27.58\n2026-03-10T18:41:38.987930Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 72.4 pps, 153 packets, 1659757 bytes\n2026-03-10T18:41:39.101115Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.83\n2026-03-10T18:41:41.018327Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 156 packets, 1710364 bytes\n2026-03-10T18:41:41.115341Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.28\n2026-03-10T18:41:43.019008Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 79.0 pps, 158 packets, 1801319 bytes\n2026-03-10T18:41:43.303508Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.79\n2026-03-10T18:41:45.076954Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 79.2 pps, 163 packets, 1807088 bytes\n2026-03-10T18:41:45.310190Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.40\n2026-03-10T18:41:47.156045Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 71.7 pps, 149 packets, 1687560 bytes\n2026-03-10T18:41:47.511679Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.16\n2026-03-10T18:41:49.353565Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.4 pps, 170 packets, 1794617 bytes\n2026-03-10T18:41:49.716371Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.48\n2026-03-10T18:41:51.374267Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 155 packets, 1718096 bytes\n2026-03-10T18:41:51.718355Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 31.97\n2026-03-10T18:41:53.397535Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 78.1 pps, 158 packets, 1764556 bytes\n2026-03-10T18:41:53.728214Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.85\n2026-03-10T18:41:55.402950Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 85.8 pps, 172 packets, 1821697 bytes\n2026-03-10T18:41:55.731606Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 32.44\n2026-03-10T18:41:57.414677Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.6 pps, 154 packets, 1670756 bytes\n2026-03-10T18:41:57.735354Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:41:59.419987Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 76.8 pps, 154 packets, 1814756 bytes\n2026-03-10T18:41:59.743328Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:42:01.428124Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.7 pps, 154 packets, 1599228 bytes\n2026-03-10T18:42:01.752350Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:42:03.428567Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 154 packets, 1729981 bytes\n2026-03-10T18:42:03.815493Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.57\n2026-03-10T18:42:05.432449Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1695452 bytes\n2026-03-10T18:42:05.818404Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.46\n2026-03-10T18:42:07.435259Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.9 pps, 154 packets, 1652011 bytes\n2026-03-10T18:42:07.822974Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:42:09.439871Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1720421 bytes\n2026-03-10T18:42:09.831889Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.87\n2026-03-10T18:42:11.442202Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1700585 bytes\n2026-03-10T18:42:11.832675Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:42:13.460331Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 75.8 pps, 153 packets, 1698556 bytes\n2026-03-10T18:42:13.849977Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.24\n2026-03-10T18:42:15.472132Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 78.0 pps, 157 packets, 1707402 bytes\n2026-03-10T18:42:15.850499Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.99\n2026-03-10T18:42:17.480891Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.7 pps, 154 packets, 1693516 bytes\n2026-03-10T18:42:17.897135Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:42:19.485728Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1741139 bytes\n2026-03-10T18:42:19.903778Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:42:21.488508Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.4 pps, 153 packets, 1665616 bytes\n2026-03-10T18:42:21.918870Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.78\n2026-03-10T18:42:23.501423Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 155 packets, 1678438 bytes\n2026-03-10T18:42:23.923153Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:42:25.533598Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.3 pps, 155 packets, 1712024 bytes\n2026-03-10T18:42:25.944128Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.68\n2026-03-10T18:42:27.541835Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.7 pps, 156 packets, 1734043 bytes\n2026-03-10T18:42:27.955699Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:42:29.569758Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 75.4 pps, 153 packets, 1726081 bytes\n2026-03-10T18:42:30.041691Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.24\n2026-03-10T18:42:31.576998Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 78.2 pps, 157 packets, 1682112 bytes\n2026-03-10T18:42:32.139253Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.03\n2026-03-10T18:42:33.586109Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1657769 bytes\n2026-03-10T18:42:34.140792Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.98\n2026-03-10T18:42:35.587383Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 77.0 pps, 154 packets, 1695030 bytes\n2026-03-10T18:42:36.148790Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.88\n2026-03-10T18:42:37.595801Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 155 packets, 1666597 bytes\n2026-03-10T18:42:38.223022Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.41\n2026-03-10T18:42:39.597534Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.9 pps, 154 packets, 1705306 bytes\n2026-03-10T18:42:40.236773Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.80\n2026-03-10T18:42:41.625085Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.0 pps, 154 packets, 1659186 bytes\n2026-03-10T18:42:42.246471Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.85\n2026-03-10T18:42:43.634360Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.3 Mbps, 77.6 pps, 156 packets, 1844375 bytes\n2026-03-10T18:42:44.250519Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.94\n2026-03-10T18:42:45.634463Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.5 pps, 153 packets, 1662304 bytes\n2026-03-10T18:42:46.294439Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.84\n2026-03-10T18:42:47.634827Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.5 pps, 153 packets, 1629628 bytes\n2026-03-10T18:42:48.304065Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.35\n2026-03-10T18:42:49.639220Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.2 Mbps, 77.3 pps, 155 packets, 1800567 bytes\n2026-03-10T18:42:50.308405Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.44\n2026-03-10T18:42:51.645064Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1685176 bytes\n2026-03-10T18:42:52.310784Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.96\n2026-03-10T18:42:53.658093Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 77.5 pps, 156 packets, 1633416 bytes\n2026-03-10T18:42:54.313920Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:42:55.668232Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.1 pps, 153 packets, 1744591 bytes\n2026-03-10T18:42:56.337842Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.14\n2026-03-10T18:42:57.689648Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.2 pps, 156 packets, 1667872 bytes\n2026-03-10T18:42:58.349830Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:42:59.702284Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.0 pps, 155 packets, 1728510 bytes\n2026-03-10T18:43:00.353031Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.95\n2026-03-10T18:43:01.724408Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.7 pps, 155 packets, 1706453 bytes\n2026-03-10T18:43:02.414186Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.60\n2026-03-10T18:43:03.732531Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.5 Mbps, 76.7 pps, 154 packets, 1642449 bytes\n2026-03-10T18:43:04.420657Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.90\n2026-03-10T18:43:05.736310Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.4 pps, 155 packets, 1721421 bytes\n2026-03-10T18:43:06.425053Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:43:07.742122Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.3 pps, 153 packets, 1755817 bytes\n2026-03-10T18:43:08.432268Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.39\n2026-03-10T18:43:09.744178Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 77.4 pps, 155 packets, 1658231 bytes\n2026-03-10T18:43:10.454580Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.16\n2026-03-10T18:43:11.749153Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.1 Mbps, 77.3 pps, 155 packets, 1772147 bytes\n2026-03-10T18:43:12.514090Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.62\n2026-03-10T18:43:13.757923Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.7 pps, 154 packets, 1654070 bytes\n2026-03-10T18:43:14.521817Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.38\n2026-03-10T18:43:15.776352Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.6 Mbps, 76.3 pps, 154 packets, 1675652 bytes\n2026-03-10T18:43:16.549564Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.08\n2026-03-10T18:43:17.781957Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 77.3 pps, 155 packets, 1732001 bytes\n2026-03-10T18:43:18.565060Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 28.78\n2026-03-10T18:43:19.795062Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.3 Mbps, 77.0 pps, 155 packets, 1574629 bytes\n2026-03-10T18:43:20.619738Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.66\n2026-03-10T18:43:21.796793Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 76.9 pps, 154 packets, 1749978 bytes\n2026-03-10T18:43:22.665334Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.31\n2026-03-10T18:43:23.797617Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.0 pps, 154 packets, 1671214 bytes\n2026-03-10T18:43:24.669719Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.93\n2026-03-10T18:43:25.817155Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.3 pps, 154 packets, 1721254 bytes\n2026-03-10T18:43:26.701650Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.02\n2026-03-10T18:43:27.826511Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.6 pps, 154 packets, 1727632 bytes\n2026-03-10T18:43:28.716230Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.78\n2026-03-10T18:43:29.831800Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1670325 bytes\n2026-03-10T18:43:30.718266Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.97\n2026-03-10T18:43:31.843428Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 7.0 Mbps, 77.6 pps, 156 packets, 1769616 bytes\n2026-03-10T18:43:32.730470Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.32\n2026-03-10T18:43:33.851185Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.2 pps, 155 packets, 1687308 bytes\n2026-03-10T18:43:34.744480Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.29\n2026-03-10T18:43:35.861389Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1696035 bytes\n2026-03-10T18:43:36.749967Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.92\n2026-03-10T18:43:37.882648Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.7 pps, 155 packets, 1744159 bytes\n2026-03-10T18:43:38.776084Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.61\n2026-03-10T18:43:39.886810Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 77.3 pps, 155 packets, 1681689 bytes\n2026-03-10T18:43:40.821215Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.83\n2026-03-10T18:43:41.893004Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.8 pps, 154 packets, 1687553 bytes\n2026-03-10T18:43:42.848034Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.59\n2026-03-10T18:43:43.896484Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.9 Mbps, 76.9 pps, 154 packets, 1726418 bytes\n2026-03-10T18:43:44.922876Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.40\n2026-03-10T18:43:45.913354Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.4 Mbps, 76.4 pps, 154 packets, 1610818 bytes\n2026-03-10T18:43:46.972864Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 30.24\n2026-03-10T18:43:47.917460Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.8 Mbps, 76.8 pps, 154 packets, 1696906 bytes\n2026-03-10T18:43:49.041582Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.49\n2026-03-10T18:43:49.928639Z DEBUG ThreadId(531) zap_stream_core::metrics: RTMP: 6.7 Mbps, 76.6 pps, 154 packets, 1677641 bytes\n2026-03-10T18:43:51.158975Z DEBUG ThreadId(531) zap_stream_core::pipeline::runner: Average fps: 29.75\n" \ No newline at end of file diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh new file mode 100755 index 0000000..de08983 --- /dev/null +++ b/scripts/run-e2e.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# run-e2e.sh — Build, start infrastructure, and run the LNVPS E2E test suite. +# +# Usage: +# ./scripts/run-e2e.sh [OPTIONS] +# +# Options: +# --no-build Skip cargo build step +# --no-cleanup Leave API servers and DB running after the run +# --filter FILTER Pass a test-name filter to cargo test (e.g. lifecycle) +# --run-id ID Override the run ID (default: timestamp) +# +# Environment variables (all optional): +# LNVPS_E2E_RUN_ID Override the run ID +# LNVPS_DB_BASE_URL DB server URL without DB name (default: mysql://root:root@localhost:3377) +# COMPOSE_FILE docker-compose file to use (default: docker-compose.e2e.yaml) +# LNVPS_API_URL User API base URL (default: http://localhost:8000) +# LNVPS_ADMIN_API_URL Admin API base URL (default: http://localhost:8001) +# +# Examples: +# # Full run (start docker, build, run tests, stop docker) +# ./scripts/run-e2e.sh +# +# # Run only the lifecycle test without rebuilding +# ./scripts/run-e2e.sh --no-build --filter lifecycle + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- +SKIP_BUILD=0 +SKIP_CLEANUP=0 +FILTER="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-build) SKIP_BUILD=1; shift ;; + --no-cleanup) SKIP_CLEANUP=1; shift ;; + --filter) FILTER="$2"; shift 2 ;; + --run-id) + export LNVPS_E2E_RUN_ID="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +# --------------------------------------------------------------------------- +# Resolve paths +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.e2e.yaml}" +DB_BASE="${LNVPS_DB_BASE_URL:-mysql://root:root@localhost:3377}" +export LNVPS_DB_BASE_URL="$DB_BASE" + +# Extract host/port from DB_BASE for CLI access (strips the mysql:// scheme) +# mysql://root:root@localhost:3377 → host=localhost port=3377 user=root pass=root +DB_HOST=$(echo "$DB_BASE" | sed -E 's|mysql://[^@]+@([^:/]+).*|\1|') +DB_PORT=$(echo "$DB_BASE" | sed -E 's|.*:([0-9]+)$|\1|') +DB_USER=$(echo "$DB_BASE" | sed -E 's|mysql://([^:]+):.*|\1|') +DB_PASS=$(echo "$DB_BASE" | sed -E 's|mysql://[^:]+:([^@]+)@.*|\1|') + +# --------------------------------------------------------------------------- +# mysql_exec SQL — run a SQL statement via local client or docker exec fallback +# --------------------------------------------------------------------------- +mysql_exec() { + local sql="$1" + if command -v mysql >/dev/null 2>&1; then + mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" "-p${DB_PASS}" \ + -e "$sql" 2>/dev/null + elif command -v mariadb >/dev/null 2>&1; then + mariadb -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" "-p${DB_PASS}" \ + -e "$sql" 2>/dev/null + else + # Fall back to docker exec into a running MariaDB/MySQL container + local container + container=$(docker ps --filter "publish=${DB_PORT}" --format "{{.Names}}" | head -1) + if [[ -z "$container" ]]; then + echo "ERROR: no mysql/mariadb client found and no container listening on port ${DB_PORT}" >&2 + return 1 + fi + docker exec "$container" mariadb -u "$DB_USER" "-p${DB_PASS}" -e "$sql" 2>/dev/null + fi +} + +# --------------------------------------------------------------------------- +# Trap: stop API servers on exit (always) +# --------------------------------------------------------------------------- +API_PID_FILE="/tmp/lnvps-e2e-api.pid" +ADMIN_PID_FILE="/tmp/lnvps-e2e-admin-api.pid" + +cleanup() { + local exit_code=$? + echo "" + echo "=== Cleanup ===" + if [[ -f "$API_PID_FILE" ]]; then + api_pid=$(cat "$API_PID_FILE") + kill "$api_pid" 2>/dev/null || true + wait "$api_pid" 2>/dev/null || true + rm -f "$API_PID_FILE" + echo "Stopped user API" + fi + if [[ -f "$ADMIN_PID_FILE" ]]; then + admin_pid=$(cat "$ADMIN_PID_FILE") + kill "$admin_pid" 2>/dev/null || true + wait "$admin_pid" 2>/dev/null || true + rm -f "$ADMIN_PID_FILE" + echo "Stopped admin API" + fi + if [[ "$SKIP_CLEANUP" -eq 0 ]]; then + docker compose -f "$COMPOSE_FILE" down -v + echo "Stopped docker infrastructure" + fi + exit "$exit_code" +} + +if [[ "$SKIP_CLEANUP" -eq 0 ]]; then + trap cleanup EXIT +fi + +# --------------------------------------------------------------------------- +# 1. Start docker infrastructure +# --------------------------------------------------------------------------- +echo "=== Starting infrastructure ($COMPOSE_FILE) ===" +docker compose -f "$COMPOSE_FILE" up -d + +# --------------------------------------------------------------------------- +# 2. Wait for LND (if present in compose file) and copy credentials +# --------------------------------------------------------------------------- +if grep -q "^ lnd:" "$COMPOSE_FILE" 2>/dev/null; then + echo "=== Waiting for LND ===" + .github/e2e/wait-for-lnd.sh 120 +fi + +# --------------------------------------------------------------------------- +# 3. Generate run ID and create per-run test database +# --------------------------------------------------------------------------- +if [[ -z "${LNVPS_E2E_RUN_ID:-}" ]]; then + export LNVPS_E2E_RUN_ID="$(date +%s%3N)" +fi +DB_NAME="lnvps_e2e_${LNVPS_E2E_RUN_ID}" +echo "=== Run ID: ${LNVPS_E2E_RUN_ID} | Database: ${DB_NAME} ===" + +# Wait for MariaDB to accept connections (first-time volume init can take >30s in CI) +DB_READY_TIMEOUT=600 +echo "Waiting for MariaDB at ${DB_HOST}:${DB_PORT} (timeout: ${DB_READY_TIMEOUT}s)..." +for i in $(seq 1 "$DB_READY_TIMEOUT"); do + if mysql_exec "SELECT 1" >/dev/null 2>&1; then + echo "MariaDB ready after ${i}s" + break + fi + if [[ "$i" -eq "$DB_READY_TIMEOUT" ]]; then + echo "ERROR: MariaDB did not become ready within ${DB_READY_TIMEOUT}s" >&2 + exit 1 + fi + sleep 1 +done + +mysql_exec "CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\`;" +echo "Created test database: ${DB_NAME}" + +# --------------------------------------------------------------------------- +# 4. Write per-run DB URL into API configs (work on temp copies) +# --------------------------------------------------------------------------- +DB_URL="${DB_BASE}/${DB_NAME}" +TMP_API_CONFIG="/tmp/lnvps-e2e-api-config.yaml" +TMP_ADMIN_CONFIG="/tmp/lnvps-e2e-admin-config.yaml" + +sed "s|db: \"mysql://.*\"|db: \"${DB_URL}\"|g" \ + .github/e2e/api-config.yaml > "$TMP_API_CONFIG" + +sed "s|db: \"mysql://.*\"|db: \"${DB_URL}\"|g" \ + .github/e2e/admin-config.yaml > "$TMP_ADMIN_CONFIG" + +echo "API configs written with DB: ${DB_URL}" + +# --------------------------------------------------------------------------- +# 5. Build API servers +# --------------------------------------------------------------------------- +if [[ "$SKIP_BUILD" -eq 0 ]]; then + echo "=== Building API servers ===" + cargo build -p lnvps_api -p lnvps_api_admin +fi + +# --------------------------------------------------------------------------- +# 6. Start user API +# --------------------------------------------------------------------------- +echo "=== Starting user API ===" +LNVPS_NO_DEV_SETUP=1 cargo run -p lnvps_api -- --config "$TMP_API_CONFIG" \ + > /tmp/lnvps-e2e-api.log 2>&1 & +echo $! > "$API_PID_FILE" + +for i in $(seq 1 90); do + if curl -sf "${LNVPS_API_URL:-http://localhost:8000}/" >/dev/null 2>&1; then + echo "User API ready after ${i}s" + break + fi + if [[ "$i" -eq 90 ]]; then + echo "ERROR: User API failed to start within 90s" >&2 + echo "--- User API log ---" >&2 + tail -30 /tmp/lnvps-e2e-api.log >&2 + exit 1 + fi + sleep 1 +done + +# --------------------------------------------------------------------------- +# 7. Start admin API +# --------------------------------------------------------------------------- +echo "=== Starting admin API ===" +LNVPS_NO_DEV_SETUP=1 cargo run -p lnvps_api_admin --bin lnvps_api_admin -- --config "$TMP_ADMIN_CONFIG" \ + > /tmp/lnvps-e2e-admin-api.log 2>&1 & +echo $! > "$ADMIN_PID_FILE" + +for i in $(seq 1 90); do + if curl -sf "${LNVPS_ADMIN_API_URL:-http://localhost:8001}/" >/dev/null 2>&1; then + echo "Admin API ready after ${i}s" + break + fi + if [[ "$i" -eq 90 ]]; then + echo "ERROR: Admin API failed to start within 90s" >&2 + echo "--- Admin API log ---" >&2 + tail -30 /tmp/lnvps-e2e-admin-api.log >&2 + exit 1 + fi + sleep 1 +done + +# --------------------------------------------------------------------------- +# 8. Run E2E tests +# --------------------------------------------------------------------------- +echo "=== Running E2E tests ===" +TEST_CMD="cargo test -p lnvps_e2e -- --test-threads=1" +if [[ -n "$FILTER" ]]; then + TEST_CMD="$TEST_CMD $FILTER" +fi +eval "$TEST_CMD" diff --git a/work/db-schema-improvements.md b/work/db-schema-improvements.md new file mode 100644 index 0000000..95fc056 --- /dev/null +++ b/work/db-schema-improvements.md @@ -0,0 +1,234 @@ +# Database Schema Improvements + +**Status:** in-progress +**Started:** 2026-03-10 +**Last updated:** 2026-03-10 (EXPLAIN review pass) + +## Goal + +Improve database performance, correctness, and maintainability by adding missing indexes, +removing redundant ones, fixing data integrity gaps, resolving the nullable +`subscription_line_item_id` issue, and cleaning up structural anti-patterns. + +## Findings + +All SQL queries are in `lnvps_db/src/mysql.rs`. Key findings confirmed by running `EXPLAIN` against +the live MariaDB container (`lnvps-db-1`) on 2026-03-10. + +### Full table scans confirmed by EXPLAIN (`type: ALL`) + +| Query | Table | Root cause | +|---|---|---| +| `SELECT * FROM users WHERE email_verify_token = ?` | `users` | No index on `email_verify_token` | +| `SELECT * FROM vm WHERE deleted = 0` | `vm` | No index on `deleted` | +| `SELECT * FROM vm_host_region WHERE enabled=1` | `vm_host_region` | No index on `enabled` (tiny table, low priority) | +| `SELECT * FROM ip_range WHERE enabled = 1` | `ip_range` | No index on `enabled` | +| `SELECT * FROM vm_ip_assignment WHERE ip=? AND deleted=0` | `vm_ip_assignment` | No index on `ip` | +| `SELECT * FROM vm_payment WHERE external_id=?` | `vm_payment` | No index on `external_id` | +| `SELECT * FROM vm_payment WHERE is_paid=true ORDER BY created DESC` | `vm_payment` | No index on `is_paid` or `created` | +| `SELECT * FROM available_ip_space ORDER BY created DESC` | `available_ip_space` | No index on `created` for sort | +| `SELECT * FROM payment_method_config ORDER BY company_id, payment_method, name` | `payment_method_config` | `idx_company_id` single-col, no composite for ORDER BY | +| `referral unpaid vm count` (`WHERE v.ref_code = ?`) | `vm` | No index on `ref_code` | +| `vm GROUP BY user_id` (in admin user list derived subquery) | `vm` | Full scan, grouped without index | + +### Queries with `Using filesort` confirmed by EXPLAIN + +| Query | Table | Fix | +|---|---|---| +| `vm_payment WHERE vm_id=? ORDER BY created DESC` | `vm_payment` | Composite `(vm_id, created DESC)` | +| `vm_payment WHERE vm_id=? AND … ORDER BY created DESC` | `vm_payment` | Same composite | +| `vm_history WHERE vm_id=? ORDER BY timestamp DESC` | `vm_history` | Composite `(vm_id, timestamp DESC)` | +| `subscription_payment WHERE subscription_id=? ORDER BY created DESC` | `subscription_payment` | Composite `(subscription_id, created DESC)` | +| `subscription_payment WHERE user_id=? ORDER BY created DESC` | `subscription_payment` | Composite `(user_id, created DESC)` | +| `subscription_payment WHERE is_paid=1 ORDER BY created DESC` | `subscription_payment` | Composite `(is_paid, created DESC)` | +| `payment_method_config WHERE company_id=? ORDER BY payment_method, name` | `payment_method_config` | Composite `(company_id, payment_method, name)` | + +### Queries with `Using temporary` confirmed by EXPLAIN + +| Query | Note | +|---|---| +| `admin permissions via role join` (DISTINCT) | Small RBAC tables; acceptable | +| `referral revenue` (window function `ROW_NUMBER() OVER`) | Window always materialises; acceptable | +| `users with active VMs contactable` (DISTINCT + full scan of `vm`) | Needs index on `vm.deleted` | +| `admin user list` derived subquery `GROUP BY user_id` on `vm` | Full scan; needs `(deleted, user_id)` or `(user_id, deleted)` | + +### Queries that already use good indexes (EXPLAIN confirmed) +- `vm WHERE user_id = ? AND deleted = 0` — uses `fk_vm_user` (`type: ref`) ✓ +- `vm WHERE host_id = ? AND deleted = 0` — uses `fk_vm_host` (`type: ref`) ✓ +- `vm WHERE subscription_line_item_id = ?` — uses `idx_vm_subscription_line_item` ✓ +- `vm_ip_assignment WHERE vm_id = ? AND deleted = 0` — uses `fk_vm_ip_assignment_vm` ✓ +- `vm_ip_assignment WHERE ip_range_id = ? AND deleted = 0` — uses `fk_vm_ip_range` ✓ +- `subscription WHERE is_active = 1 AND expires < NOW()` — uses `idx_subscription_active` (`type: ref`) with `Using where` post-filter on `expires` ✓ (composite would be better but not critical) +- `subscription_payment WHERE subscription_id = ?` — uses `idx_subscription_payment_subscription` ✓ +- `subscription_payment WHERE external_id = ?` — uses `idx_subscription_payment_external_id` ✓ +- `payment_method_config WHERE company_id = ?` — uses `idx_company_id` ✓ +- `nostr_domain WHERE activation_hash = ?` — uses `ix_nostr_domain_activation_hash` ✓ +- `vm expired via subscription JOIN` — uses `idx_subscription_expires`, `idx_line_item_subscription`, `idx_vm_subscription_line_item` ✓ +- `base_currency for vm` (4-table JOIN by PK) — all `const` lookups ✓ +- `ip_range_subscription by subscription_id` (via sli JOIN) — uses `idx_ip_range_subscription_line_item` ✓ + +### Corrections to prior findings +- **`vm WHERE user_id`** — EXPLAIN shows `fk_vm_user` index IS used (`type: ref`). The prior finding + that there was no index on `user_id` was incorrect; the FK implicitly creates the index. +- **`vm WHERE host_id`** — Same: `fk_vm_host` index IS used. No new index needed for these two. +- **`payment_method_config WHERE company_id = ? AND enabled = TRUE`** — uses `idx_company_id` + (`type: ref`) then filters `enabled` with `Using where`. A composite `(company_id, enabled)` would + cover both predicates but the current plan is already a ref lookup; lower priority than originally + assessed. The real gap is the ORDER BY sort, not the filter. +- **`vm_payment WHERE vm_id = ?`** — `fk_vm_payment_vm` index already exists and is used ✓. + The prior task "Add index `vm_id` on `vm_payment`" was wrong; index already present. +- **`user_ssh_key WHERE user_id = ?`** — `fk_ssh_key_user` index already exists ✓. + Prior task "Add index `user_id` on `user_ssh_key`" was wrong. +- **`ip_range WHERE region_id = ?`** — `fk_ip_range_region` already used ✓. + "Add composite `(region_id, enabled)` on `ip_range`" still valid for the `enabled` filter. +- **`nostr_domain WHERE owner_id = ?`** — `fk_nostr_domain_user` already used ✓. + "Add index `owner_id` on `nostr_domain`" is wrong; index already present. +- **`admin_roles` `idx_name`** — EXPLAIN shows `admin_roles` by name uses the UNIQUE KEY. + Confirmed duplicate; drop task remains valid. +- **`admin_role_assignments` `idx_user_id`** — prefix of UNIQUE KEY confirmed; drop task valid. +- **`admin_role_permissions` `idx_role_id`** — confirmed prefix of unique composite; drop task valid. +- **`ix_vm_history_action_type`** — confirmed unused by EXPLAIN; drop task valid. + +### Correctness bug +- `get_subscription_base_currency` in `mysql.rs` uses an incorrect JOIN: + `JOIN company c ON u.id = c.id` should be `JOIN company c ON s.company_id = c.id`. + This returns wrong or no results on every call. Must be fixed before any payment-related work. + +### N+1 patterns identified +1. `admin_list_hosts_with_regions_paginated` (`mysql.rs` ~line 3459): fetches N hosts then loops, + issuing one `SELECT FROM vm_host_disk WHERE host_id = ?` per host. +2. `check_vms` worker (`worker.rs` ~line 612): fetches all active VMs then issues 2 round-trips per + VM (`get_subscription_line_item` + `get_subscription`) to check `is_setup`. +3. `vm_expires()` (`worker.rs` ~line 443): does `get_subscription_line_item` + `get_subscription` + per VM on every non-hypervisor-found VM — not batched. +4. `handle_subscription_state` (`worker.rs` ~line 238): O(N×M) subscription → line-item → VM chain; + acceptable today but worth batching if subscription counts grow. + +### Indexes confirmed useless / to be dropped +- `ix_user_email` on `users.email` — column is encrypted ciphertext, never used in a WHERE clause. + Also invalid DDL: `20260220165223` recreated this index on a `TEXT` column without a prefix length, + which is invalid in MariaDB. The index likely does not exist on any DB that ran migrations in order. +- Duplicate `idx_name` on `admin_roles` — the UNIQUE KEY already creates this B-tree index. +- `idx_role_id` on `admin_role_permissions` — prefix of the composite UNIQUE KEY `(role_id, resource, action)`. +- `idx_user_id` on `admin_role_assignments` — prefix of the UNIQUE KEY `(user_id, role_id)`. +- `idx_active` on `admin_role_assignments` — the `is_active` column was dropped by `20250809000000`; + verify MariaDB auto-dropped this index (it should when the column is dropped). +- `ix_vm_history_action_type` on `vm_history` — all `vm_history` queries filter only by `vm_id`; + this index is never used and adds write overhead (EXPLAIN confirmed). + +### Data integrity issues +- `vm.subscription_line_item_id` is `NULL`-able in the DB (added nullable by `20260302151134`) but + mapped as non-optional `u64` in the Rust `Vm` struct — any row with NULL causes a deserialization + panic. A NOT NULL migration must be applied after verifying the data migration binary has run. +- `VmForMigration` struct in `model.rs` still references `expires` and `auto_renewal_enabled`, both + of which were dropped by `20260304000000`. This struct will panic at deserialization if used + post-migration. +- `vm` has no CHECK constraint enforcing exactly one of `template_id` / `custom_template_id`. +- `vm.ref_code` has no FK to `referral.code` — referral attribution can silently diverge. + Also confirmed: no index on `vm.ref_code` (EXPLAIN shows full scan on referral revenue query). + +### Anti-patterns (lower priority) +- `vm_payment.id` / `subscription_payment.id` declared with UNIQUE INDEX instead of PRIMARY KEY — + InnoDB uses a hidden rowid as the clustered key with an extra pointer dereference on every lookup. +- `vm_payment.rate` and `subscription_payment.rate` both stored as `FLOAT` — precision risk in + monetary calculations. +- `payment_method_config.supported_currencies` and `nostr_domain*.relays` stored as comma-separated + strings — never SQL-filtered so no index is possible; filtering/splitting done entirely in Rust. +- Broken FK in `init.sql`: `fk_template_region` on `vm_template` points at `vm_template.id` instead + of `vm_host_region.id`. Corrected in migration `20250306113236` but the init migration is + permanently wrong on a fresh replay. + +### Confirmed NOT needed (re-verified by EXPLAIN) +- Additional index on `vm.user_id` — already covered by `fk_vm_user` FK index. +- Additional index on `vm.host_id` — already covered by `fk_vm_host` FK index. +- Additional index on `vm_payment.vm_id` — already covered by `fk_vm_payment_vm` FK index. +- Additional index on `user_ssh_key.user_id` — already covered by `fk_ssh_key_user` FK index. +- Additional index on `nostr_domain.owner_id` — already covered by `fk_nostr_domain_user` FK index. +- Additional index on `nostr_domain_handle.domain_id` — covered by leftmost prefix of `UNIQUE KEY ix_domain_handle_unique (domain_id, handle)`. +- Additional index on `nostr_domain.activation_hash` — already `ix_nostr_domain_activation_hash`. +- Additional index on `subscription.user_id` — already `idx_subscription_user`. +- Additional index on `subscription_payment.external_id` — already created in `20260127000000`. +- Additional index on `referral_payout.referral_id` — covered by FK implicit index. +- Index on `referral_payout.is_paid` — not used as a WHERE filter anywhere. +- Composite `(region_id, enabled)` on `vm_host` — hosts are a tiny table; full scan is fine. +- Normalizing `supported_currencies` or `relays` columns — opaque to SQL; no query benefit. +- Index on `subscription.company_id` — not filtered directly on the subscription table. + +## Tasks + +### Increment 0 — Correctness bug: get_subscription_base_currency wrong JOIN +- [ ] Fix `get_subscription_base_currency` in `mysql.rs`: change `JOIN company c ON u.id = c.id` to `JOIN company c ON s.company_id = c.id` + +### Increment 1 — Critical: subscription_line_item_id NOT NULL migration +- [ ] Fix `VmForMigration` struct in `model.rs`: remove `expires` and `auto_renewal_enabled` fields (dropped by `20260304000000`) +- [ ] Verify data migration binary has been run on all environments (all `vm` rows have a non-NULL `subscription_line_item_id`) +- [ ] Create migration to add the NOT NULL constraint on `vm.subscription_line_item_id` (the nullable column was added by `20260302151134`) + +### Increment 2 — High-priority indexes (full table scans on hot paths) +*EXPLAIN confirmed — these are the real full-scan gaps after correcting the prior analysis.* +- [ ] Add index `email_verify_token` on `users` — full scan on every email verification click +- [ ] Add index `deleted` on `vm` — full scan on bulk VM queries and admin derived subqueries +- [ ] Add index `ref_code` on `vm` — full scan on referral revenue and unpaid-vm-count queries +- [ ] Add index `ip` on `vm_ip_assignment` — full scan on IP conflict checks before insert/update +- [ ] Add index `external_id` on `vm_payment` — full scan on legacy payment lookup by external id +- [ ] Add composite index `(is_paid, created)` on `vm_payment` — eliminates full scan + filesort on `WHERE is_paid=true ORDER BY created DESC` +- [ ] Add index `enabled` on `ip_range` — full scan on `WHERE enabled = 1` (range listing) +- [ ] Add composite index `(company_id, payment_method, name)` on `payment_method_config` — eliminates full scan + filesort on list-all query; also covers existing `WHERE company_id=?` and `WHERE company_id=? AND payment_method=?` lookups (replaces `idx_company_id`) + +### Increment 3 — Medium-priority: sort indexes (filesort elimination) +*EXPLAIN confirmed — index exists on filter column but ORDER BY column not covered.* +- [ ] Add composite index `(vm_id, created)` on `vm_payment` — eliminates filesort on `WHERE vm_id=? ORDER BY created DESC` +- [ ] Add composite index `(vm_id, timestamp)` on `vm_history` — eliminates filesort on `WHERE vm_id=? ORDER BY timestamp DESC`; also makes `ix_vm_history_vm_id` and `ix_vm_history_timestamp` redundant (drop in Increment 4) +- [ ] Add composite index `(subscription_id, created)` on `subscription_payment` — eliminates filesort on payment history queries +- [ ] Add composite index `(user_id, created)` on `subscription_payment` — eliminates filesort on user payment history +- [ ] Add composite index `(is_paid, created)` on `subscription_payment` — eliminates filesort on latest-paid lookup +- [ ] Add composite index `(is_active, expires)` on `subscription` — replaces filter+post-scan on background worker expiry loop; makes `idx_subscription_active` and `idx_subscription_expires` redundant (drop in Increment 4) +- [ ] Add index `created` on `available_ip_space` — eliminates filesort on `ORDER BY created DESC` full list + +### Increment 4 — Remove redundant / invalid indexes +*EXPLAIN confirmed — all of these are either unused or prefixes of composite keys.* +- [ ] Drop `ix_user_email` on `users` (indexes encrypted ciphertext; never used in WHERE; invalid DDL without prefix length — index may already be absent on migrated DBs) +- [ ] Drop `idx_name` on `admin_roles` (duplicate of UNIQUE KEY — EXPLAIN confirms UNIQUE KEY is used) +- [ ] Drop `idx_role_id` on `admin_role_permissions` (prefix of composite UNIQUE KEY `(role_id, resource, action)` — EXPLAIN confirms composite is used) +- [ ] Drop `idx_user_id` on `admin_role_assignments` (prefix of UNIQUE KEY `(user_id, role_id)` — EXPLAIN confirms UNIQUE KEY is used) +- [ ] Verify and drop (if present) `idx_active` on `admin_role_assignments` (column `is_active` was dropped by `20250809000000`) +- [ ] Drop `ix_vm_history_action_type` on `vm_history` (EXPLAIN confirmed: never used; all history queries filter by `vm_id` only) +- [ ] Drop `ix_vm_history_vm_id` and `ix_vm_history_timestamp` after adding composite `(vm_id, timestamp)` in Increment 3 +- [ ] Drop `idx_subscription_active` and `idx_subscription_expires` after adding composite `(is_active, expires)` in Increment 3 +- [ ] Drop `idx_company_id` on `payment_method_config` after adding composite `(company_id, payment_method, name)` in Increment 2 + +### Increment 5 — Data integrity: vm table +- [ ] Add CHECK constraint on `vm`: exactly one of `template_id` / `custom_template_id` is non-NULL (requires MariaDB 10.2.1+) +- [ ] Add FK `vm.ref_code → referral.code` or document intentional denormalization with a comment + +### Increment 6 — N+1 query fixes +- [ ] Fix `admin_list_hosts_with_regions_paginated`: batch-load `vm_host_disk` rows with `WHERE host_id IN (...)` instead of per-host loop +- [ ] Fix `check_vms` worker: replace 2-per-VM round-trips with a single JOIN query (`vm → subscription_line_item → subscription`) to read `is_setup` in one shot +- [ ] Fix `vm_expires()` in worker: replace `get_subscription_line_item` + `get_subscription` round-trips with a single JOIN query to get `subscription.expires` for a VM + +### Increment 7+8 — vm_payment / subscription_payment primary key promotion and rate precision +> **Note:** Combine into one `ALGORITHM=COPY` table rebuild per table to avoid two full rebuilds. +- [ ] Promote `vm_payment.id` from UNIQUE INDEX to PRIMARY KEY and change `vm_payment.rate` from `FLOAT` to `DECIMAL(18, 8)` in the same migration (table rebuild required; low-traffic window) +- [ ] Promote `subscription_payment.id` from UNIQUE INDEX to PRIMARY KEY and change `subscription_payment.rate` from `FLOAT` to `DECIMAL(18, 8)` in the same migration + +## Notes + +- All migrations must follow project conventions: `NOT NULL DEFAULT ` for new columns, + pure DDL only (no DML in migrations). See `docs/agents/migrations.md`. +- Increments 2–4 are all `ALGORITHM=INPLACE, LOCK=NONE` safe on MariaDB InnoDB — they can be + deployed without downtime. +- Increment 7+8 requires a full table rebuild (`ALGORITHM=COPY`); plan for a maintenance window. +- The FLOAT→BIGINT×100 conversion in migration `20260217100000` may have silently corrupted BTC- + denominated `vm_cost_plan` rows due to IEEE 754 rounding. Verify before proceeding with any payment-related changes. +- `payment_method_config.supported_currencies` and `nostr_domain*.relays` are confirmed opaque CSV + strings filtered entirely in Rust — normalization would require API changes and is not prioritised. +- Increment 0 (JOIN bug fix) must precede Increment 7+8 (exchange rate precision work) since rate + calculations depend on correctly resolving the base currency. +- Increment 1's NOT NULL constraint is a hard gate: the data migration binary must be verified + complete on all environments before applying the DDL. +- **Prior task corrections (2026-03-10 EXPLAIN pass):** The tasks "Add index `vm_id` on `vm_payment`", + "Add index `user_id` on `user_ssh_key`", "Add composite `(user_id, deleted)` on `vm`", + "Add composite `(host_id, deleted)` on `vm`", "Add index `owner_id` on `nostr_domain`", + "Add composite `(region_id, enabled)` on `vm_host`", and "Add composite `(region_id, enabled)` + on `ip_range`" were all removed because EXPLAIN confirmed the required indexes already exist via FK + constraints or prior migrations. Increment 2 and 3 now reflect only the genuine gaps. diff --git a/work/vm-payment-to-subscription.md b/work/vm-payment-to-subscription.md index b86f5e1..d618360 100644 --- a/work/vm-payment-to-subscription.md +++ b/work/vm-payment-to-subscription.md @@ -2,11 +2,12 @@ **Status:** in-progress **Started:** 2026-02-23 -**Last updated:** 2026-02-23 +**Last updated:** 2026-03-04 +**Phase 2+3 status:** All increments 11–19 complete ## Goal -Consolidate `vm_payment` into `subscription_payment` so there is a single unified payment table. VMs link to subscriptions via `vm.subscription_id`. Drop `vm_payment` when complete. +Consolidate `vm_payment` into `subscription_payment` so there is a single unified payment table. VMs link to subscriptions via `vm.subscription_line_item_id` (mirroring the `ip_range_subscription` → `subscription_line_item` pattern), so a single subscription can contain VMs, extra IPs, and other products as line items. Drop `vm_payment` when complete. Full plan details captured in this work file. @@ -22,102 +23,207 @@ Full plan details captured in this work file. ## Tasks -### Increment 0: Rename VmCostPlanIntervalType → IntervalType -- [ ] Rename `VmCostPlanIntervalType` → `IntervalType` in `lnvps_db/src/model.rs` -- [ ] Add type alias `pub type VmCostPlanIntervalType = IntervalType;` -- [ ] Rename `ApiVmCostPlanIntervalType` → `ApiIntervalType` in `lnvps_api_common/src/model.rs` -- [ ] Add type alias `pub type ApiVmCostPlanIntervalType = ApiIntervalType;` -- [ ] Update all direct references to use new names (incremental via alias) -- [ ] Verify build + tests pass +### Increment 0: Rename VmCostPlanIntervalType → IntervalType ✓ +- [x] Rename `VmCostPlanIntervalType` → `IntervalType` in `lnvps_db/src/model.rs` +- [x] Rename `ApiVmCostPlanIntervalType` → `ApiIntervalType` in `lnvps_api_common/src/model.rs` +- [x] Update all direct references across codebase to use new names (no aliases) +- [x] Verify build + tests pass ### Increment 1: Schema migration + database layer -- [ ] Create SQL migration: add `time_value`, `metadata` to `subscription_payment` -- [ ] Create SQL migration: re-add `interval_amount`, `interval_type` to `subscription` -- [ ] Create SQL migration: add `subscription_id` to `vm` (nullable) -- [ ] Create SQL migration: add `subscription_id` to `ip_range_subscription` (nullable) -- [ ] Backfill existing subscriptions with `interval_amount=1, interval_type=1` (Month) -- [ ] Add `VmRenewal=3`, `VmUpgrade=4` to `SubscriptionType` enum -- [ ] Add `Upgrade=2` to `PaymentType` enum (rename from `SubscriptionPaymentType`) -- [ ] Update `SubscriptionPayment` struct: add `time_value`, `metadata` -- [ ] Update `Subscription` struct: add `interval_amount`, `interval_type` -- [ ] Update `Vm` struct: add `subscription_id` -- [ ] Update `subscription_payment_paid()`: VM path (extend by time_value) + regular path (read interval from subscription) -- [ ] Add `list_vm_payments()` query (via vm.subscription_id) -- [ ] Add `get_vm_by_subscription()` query -- [ ] Verify build + tests pass - -### Increment 2: Data migration tool -- [ ] Create `lnvps_db/src/data_migrations/mod.rs` with registry -- [ ] Create `lnvps_db/src/data_migrations/migrate_vm_to_subscriptions.rs` -- [ ] Handle standard VMs (pricing from vm_cost_plan) -- [ ] Handle custom VMs (pricing computed from vm_custom_pricing) -- [ ] Handle VMs with neither template (log error, skip) -- [ ] Implement dry-run mode -- [ ] Implement validation step -- [ ] Add `data-migrate` CLI subcommand with `--name` and `--dry-run` flags +- [x] Create SQL migration `20260302151134_vm_subscription_link.sql`: re-add `interval_amount`, `interval_type` to `subscription`; add `time_value`, `metadata` to `subscription_payment`; add `subscription_id` to `vm` +- [x] Backfill via DEFAULT values (interval_amount=1, interval_type=1=Month) +- [x] Add `VmRenewal=3`, `VmUpgrade=4` to `SubscriptionType` enum +- [x] Add `Upgrade=2` to `SubscriptionPaymentType` enum +- [x] Update `SubscriptionPayment` / `SubscriptionPaymentWithCompany` structs: add `time_value`, `metadata` +- [x] Update `Subscription` struct: add `interval_amount`, `interval_type` +- [x] Update `Vm` struct: add `subscription_id` (nullable) +- [x] Fix `subscription_payment_paid()` transaction bug; add VM path (time_value) + regular path (interval from subscription) +- [x] Add `get_vm_by_subscription()` and `list_vm_subscription_payments()` to trait + MySQL + mock +- [x] Update `insert_vm` / `update_vm` SQL to include `subscription_id` +- [x] Propagate new fields through all API models (admin + user-facing) +- [x] Fix all `Subscription {}` / `SubscriptionPayment {}` / `Vm {}` struct literals in source + tests +- [x] Verify build + tests pass + +### Increment 2: Data migration tool ✓ +- [x] Create `lnvps_api_admin/src/bin/migrate_vm_subscriptions.rs` standalone binary +- [x] Handle standard VMs (interval + amount from cost_plan) +- [x] Handle custom VMs (1-Month interval, amount=0 pending custom pricing) +- [x] Handle VMs with neither template (bail with warning) +- [x] Implement dry-run mode (--dry-run flag) +- [x] Idempotent: VMs with subscription_id already set are skipped +- [x] Fix `insert_subscription` / `insert_subscription_with_line_items` / `update_subscription` SQL to bind `interval_amount` and `interval_type` - [ ] Test against local backup: `~/Downloads/lnvps_lnvps-20250316020007.sql.gz` -- [ ] Verify idempotency (run twice, same result) - -### Increment 3: VM payment creation updates -- [ ] Update `renew()` / `renew_intervals()` to create `SubscriptionPayment` with `vm.subscription_id` -- [ ] Update `create_upgrade_payment()` to create `SubscriptionPayment` with `payment_type=Upgrade`, `metadata` -- [ ] Update `GET /api/v1/vm/{id}/renew` to return SubscriptionPayment -- [ ] Update `GET /api/v1/vm/{id}/invoice/{payment_id}` to query subscription_payment -- [ ] Update `GET /api/v1/vm/{id}/invoices` to query via vm.subscription_id -- [ ] Verify build + tests pass - -### Increment 4: Payment processing updates -- [ ] Update Lightning webhook handler to use `subscription_payment` -- [ ] Update Revolut webhook handler to use `subscription_payment` -- [ ] Handle upgrades: check `metadata.upgrade_params`, look up VM via `get_vm_by_subscription()` -- [ ] Verify build + tests pass - -### Increment 5: VM upgrade updates subscription & line item -- [ ] Update `convert_to_custom_template()` to update subscription interval to `1 Month` -- [ ] Update `convert_to_custom_template()` to update line item amount + configuration -- [ ] Verify build + tests pass - -### Increment 6: Admin API updates -- [ ] Update `GET /api/admin/v1/vms/{id}/payments` to query via vm.subscription_id -- [ ] Update `GET /api/admin/v1/vm_payments/{id}` to query subscription_payment -- [ ] Verify build + tests pass - -### Increment 7: Reporting updates -- [ ] Update revenue report queries to use subscription_payment -- [ ] Update company report queries -- [ ] Update referral cost tracking to join via vm.subscription_id -- [ ] Verify build + tests pass - -### Increment 8: Subscription creation for new VMs -- [ ] Update standard VM provisioning to create subscription + line item -- [ ] Update custom VM provisioning to create subscription + line item -- [ ] Update IP range subscription creation to explicitly set interval on subscription -- [ ] Verify build + tests pass - -### Increment 9: Testing & validation -- [ ] Unit tests: subscription_payment_paid() for VMs -- [ ] Unit tests: subscription_payment_paid() for regular subscriptions -- [ ] Unit tests: interval computation from subscription -- [ ] Unit tests: standard vs custom VM subscription creation -- [ ] Integration tests: VM renewal flow -- [ ] Integration tests: VM upgrade flow (standard → custom) -- [ ] Integration tests: webhook processing + +### Increment 3 + 4: VM payment creation + payment processing ✓ +- [x] `vm.subscription_id` changed from `Option` to `u64` (NOT NULL) +- [x] Migration `20260302154256_vm_subscription_not_null.sql` to enforce NOT NULL +- [x] `provision()` creates Subscription + SubscriptionLineItem(VmRenewal) before inserting VM +- [x] `provision_custom()` does the same with 1-Month interval +- [x] `CostResult::Existing` changed to hold `SubscriptionPayment` (deduplication via `list_vm_subscription_payments`) +- [x] `price_to_payment_with_type` rewritten to create `SubscriptionPayment` (uses `vm.subscription_id`) +- [x] `renew()` / `renew_intervals()` return `SubscriptionPayment` via `renew_subscription(vm.subscription_id)` +- [x] `renew_amount()` returns `SubscriptionPayment` +- [x] `create_upgrade_payment()` uses `SubscriptionPaymentType::Upgrade`, stores config in `metadata` JSON +- [x] `auto_renew_via_nwc()` returns `SubscriptionPayment` +- [x] `handle_upgrade()` updated to accept `SubscriptionPayment`, reads `metadata` +- [x] Lightning invoice handler uses `get_subscription_payment` + `subscription_payment_paid` +- [x] Revolut handler uses `get_subscription_payment_by_ext_id` + `subscription_payment_paid` +- [x] Both handlers look up VM via `get_vm_by_subscription(subscription_id)` for history logging +- [x] Cancel other upgrade payments via `list_vm_subscription_payments` + `update_subscription_payment` +- [x] `v1_renew_vm` → `ApiVmPayment::from_subscription_payment` +- [x] `v1_get_payment` → `get_subscription_payment` +- [x] `v1_get_payment_invoice` → `get_subscription_payment` + `from_subscription_payment` +- [x] `v1_payment_history` → `list_vm_subscription_payments` +- [x] `v1_vm_upgrade` → `ApiVmPayment::from_subscription_payment` +- [x] `ApiInvoiceItem::from_subscription_payment` added +- [x] `insert_subscription` / `insert_subscription_with_line_items` mock fixed to actually insert +- [x] Test helpers updated to create subscriptions for VMs +- [x] Verify build + all 214 unit tests pass + +### Increment 5: VM upgrade updates subscription & line item ✓ +- [x] Update `convert_to_custom_template()` to update subscription interval to `1 Month` +- [x] Update `convert_to_custom_template()` to update line item `subscription_type` → `VmRenewal` and store config +- [x] Verify build + tests pass + +### Increment 6: Admin API updates ✓ +- [x] `admin_list_vm_payments` — use `list_vm_subscription_payments` with manual pagination +- [x] `admin_get_vm_payment` — use `get_subscription_payment` + `get_vm_by_subscription` for ownership check +- [x] `admin_complete_vm_payment` — use `subscription_payment_paid`; read upgrade config from `metadata` +- [x] `AdminVmPaymentInfo::from_subscription_payment()` added to model +- [x] Verify build + all 214 unit tests pass + +### Increment 7: Reporting updates ✓ +- [x] Update revenue report queries to use subscription_payment +- [x] Update company report queries +- [x] Update referral cost tracking to join via vm.subscription_id +- [x] Verify build + tests pass + +### Increment 8: Subscription creation for new VMs ✓ +- [x] Update standard VM provisioning to create subscription + line item (done in Inc 3+4) +- [x] Update custom VM provisioning to create subscription + line item (done in Inc 3+4) +- [x] Update IP range subscription creation to explicitly set interval on subscription (already correct) +- [x] Verify build + tests pass + +### Increment 9: Testing & validation ✓ +- [x] Unit tests: subscription_payment_paid() for VMs (time_value path) +- [x] Unit tests: subscription_payment_paid() for regular subscriptions (interval path) +- [x] Unit tests: interval computation from subscription (Day/Month/Year) +- [x] Unit tests: standard vs custom VM subscription creation (provision/provision_custom) +- [x] Unit tests: consecutive payment stacking +- [x] Unit tests: list_vm_subscription_payments_paginated pagination +- [x] Unit tests: NodeInvoiceHandler::mark_payment_paid (Renewal + Upgrade paths) +- [x] Fix Bug 1 (double-conversion in renew_subscription): collect full NewPaymentInfo from get_vm_cost_for_intervals; do not pass already-converted BTC amounts through get_amount_and_rate again +- [x] Fix Bug 2 (time_value: None): set time_value from summed NewPaymentInfo.time_value values on created SubscriptionPayment +- [x] Add amount/time_value assertions to all 4 renew tests - [ ] Data migration tests against backup - [ ] Validation endpoint: VMs without subscriptions, missing time_value, duplicates -### Increment 10: Documentation & cleanup -- [ ] Update API_DOCUMENTATION.md -- [ ] Update API_CHANGELOG.md -- [ ] Add migration notes to docs/agents/migrations.md -- [ ] Remove deprecated vm_payment code after finalization migration +### Increment 10: Documentation & cleanup ✓ +- [x] Update API_CHANGELOG.md +- [x] Add migration notes to docs/agents/migrations.md +- [ ] Remove deprecated vm_payment code after finalization migration (blocked on production verification) ### Finalization (after production verification) - [ ] Apply finalization migration: `ALTER TABLE vm MODIFY subscription_id NOT NULL` - [ ] Apply finalization migration: `DROP TABLE vm_payment` +--- + +## Phase 2: General-Purpose Subscription Lifecycle + +The lifecycle worker currently has VM-specific logic (`check_vms`, `handle_vm_state`). The goal is to generalise it so that *any* subscription product (IP ranges, ASN sponsoring, DNS hosting, future products) benefits from the same expiry detection, auto-renewal, suspension, and deletion behaviour. + +### Context + +- `Subscription.expires` is already extended atomically by `subscription_payment_paid()` for all product types (VM and non-VM). +- `Subscription.auto_renewal_enabled` exists on the subscription record but is only read for VMs today. +- Non-VM subscriptions (e.g. `IpRangeSubscription`) have `is_active` / `ended_at` fields that serve as the "suspension" state, but nothing flips them today. +- `check_vms` and `handle_vm_state` in `worker.rs` are the only lifecycle enforcement points; they must be extended or their logic extracted. +- VM lifecycle decisions read `vm.expires` directly. After this phase, `vm.expires` should remain authoritative for hypervisor decisions, but it must continue to be driven by `subscription.expires` (already the case via `subscription_payment_paid`). + +### Increment 11: DB layer — subscription lifecycle queries ✓ +- [x] Add `list_expiring_subscriptions(within_seconds: u64) -> Vec` to DB trait + MySQL + mock +- [x] Add `list_expired_subscriptions() -> Vec` to DB trait + MySQL + mock +- [x] Add `deactivate_subscription(id: u64)` to DB trait + MySQL + mock: sets `is_active = false` + flips `ip_range_subscription.ended_at` +- [x] Implement all `ip_range_subscription` mock methods (were `todo!()`); add `ip_range_subscriptions` field to `MockDb` +- [x] Verify build + 116 unit tests pass + +### Increment 12: Worker — generalised `check_subscriptions` loop ✓ +- [x] Add `WorkJob::CheckSubscriptions` variant + `can_skip` + `Display` to `lnvps_api_common/src/work/mod.rs` +- [x] Add `check_subscriptions()` to `Worker`: iterates all active subscriptions, calls `handle_subscription_state` +- [x] Add `handle_subscription_state(sub, last_check)`: expiring-soon NWC attempt / notify; expired non-VM deactivation; grace-period cancellation notify +- [x] Add `get_last_check_subscriptions` / `set_last_check_subscriptions` KV helpers +- [x] Wire `WorkJob::CheckSubscriptions` into `try_job` +- [x] Schedule at 30-second interval in `bin/api.rs` +- [x] Verify build + 116 unit tests pass + +### Increment 13: VM lifecycle — drive from subscription.expires ✓ +- [x] Add `vm_expires(vm)` helper: resolves `vm.subscription_line_item_id → subscription.expires`, falls back to `vm.expires` +- [x] Rewrite `handle_vm_state`: uses `vm_expires()` for stop/delete decisions; remove NWC auto-renewal path (now owned by `handle_subscription_state`) +- [x] Update `check_vm` and `check_vms_on_host` spawn guards to use `vm_expires()` +- [x] Verify build + 116 unit tests pass + +### Increment 14: IP range deactivation on expiry ✓ +- [x] `deactivate_subscription` (Inc 11) sets `ip_range_subscription.is_active = false` + `ended_at = NOW()` for all linked rows in a transaction +- [x] `handle_subscription_state` (Inc 12) calls `deactivate_subscription` for non-VM expired subscriptions and sends "expired and deactivated" notification +- [x] Expiring-soon notification fires for all subscription types including IP range (same 1-day window) +- [x] All covered by Inc 11–12 implementation; no additional code needed + +### Increment 15: Unit tests for generalised lifecycle ✓ +- [x] Test `list_expiring_subscriptions`: returns soon-expiring active subscriptions; excludes far-future +- [x] Test `list_expired_subscriptions`: returns past-expiry active subscriptions; excludes not-yet-expired +- [x] Test `deactivate_subscription`: flips `is_active = false` on subscription +- [x] Test `deactivate_subscription`: sets `is_active = false` + `ended_at` on linked `ip_range_subscription` rows +- [x] 122 unit tests pass (6 new) + +--- + +## Phase 3: Generic Payment Completion Pipeline + +Currently `NodeInvoiceHandler` and `RevolutPaymentHandler` each independently duplicate the same post-payment sequence (mark paid → fetch VM before/after → log history → dispatch WorkJob). Neither handler can complete a non-VM payment (both call `get_vm_by_subscription` unconditionally, which returns `RowNotFound` for IP range subscriptions). Stripe is a stub. Admin handlers duplicate the pattern a third and fourth time without dispatching work jobs. + +This phase extracts a single `on_payment_complete` pipeline that is product-agnostic and payment-method-agnostic. + +### Context + +- `subscription_payment_paid()` in the DB layer is already product-agnostic — it extends `subscription.expires` and optionally `vm.expires` for VM subscriptions. No changes needed there. +- The VM-specific post-payment actions (logging, `CheckVm` dispatch) need to be moved into a product handler abstraction. +- IP range subscriptions have no post-payment actions today; this phase adds CIDR allocation + `ip_range_subscription.is_active` flip. +- Cancel-competing-upgrades logic is also duplicated per payment method and must be centralised. + +### Increment 16 + 17: `PaymentCompletionHandler` trait + centralised `complete_payment` ✓ +- [x] Define `PaymentCompletionHandler` trait in `lnvps_api/src/payments/mod.rs` +- [x] Implement `VmPaymentCompletionHandler`: fetches VM before/after, logs history, dispatches `CheckVm`/`ProcessVmUpgrade` +- [x] Implement `NonVmPaymentCompletionHandler`: dispatches `CheckSubscriptions` +- [x] Implement `make_completion_handler` dispatcher: selects handler by `subscription_type` +- [x] Extract `complete_payment(db, payment, handler, cancel_fn)` free function +- [x] Refactor `NodeInvoiceHandler`: replaces `mark_payment_paid(vm_id)` with `complete(payment)` — removes all duplicated VM logic and the `get_vm_by_subscription` call +- [x] Refactor `RevolutPaymentHandler::try_complete_payment` — removes duplicated VM history logging, uses `complete_payment`; also removes `VmHistoryLogger` from struct +- [x] `admin_complete_subscription_payment`: add `CheckSubscriptions` WorkJob dispatch (was missing) +- [x] Remove `VmHistoryLogger` from both handler structs (moved into `VmPaymentCompletionHandler`) +- [x] 203 unit tests pass (81 lnvps_api + 122 lnvps_api_common) + +### Increment 18: Stripe handler implementation ✓ +- [x] Implement `StripePaymentHandler` struct with `StripeApi`, `db`, `tx`, `config_id` +- [x] Implement `try_complete_payment`: looks up payment by ext_id, calls `complete_payment` + `make_completion_handler` +- [x] Implement cancel-competing-upgrades via `api.cancel_payment_intent` +- [x] Implement `listen()`: subscribes to `WEBHOOK_BRIDGE`, filters Stripe endpoint, verifies signature, handles `payment_intent.succeeded` +- [x] Wire Stripe handler into `listen_all_payments` (behind `#[cfg(feature = "stripe")]`) +- [x] Add `/api/v1/webhook/stripe` route to `webhook.rs` +- [x] Stripe payment creation (`bail!` in provisioner) left as-is — checkout session creation is out of scope for this phase +- [x] Verified build with `--features stripe` + +### Increment 19: Unit tests for generic payment pipeline ✓ +- [x] Test `complete` (VM renewal): marks paid, dispatches `CheckVm` +- [x] Test `complete` (VM upgrade): dispatches `ProcessVmUpgrade` +- [x] Test `complete` (non-VM IpRange renewal): marks paid, dispatches `CheckSubscriptions` (not `CheckVm`) +- [x] 204 unit tests pass (82 lnvps_api + 122 lnvps_api_common) + ## Notes - Test database backup: `~/Downloads/lnvps_lnvps-20250316020007.sql.gz` - `VmCostPlanIntervalType` has ~50 references — rename via type alias for incremental migration - Custom VMs always use 1 Month interval; standard VMs copy from cost plan - All line items on a subscription share the same interval (interval lives on subscription, not line item) +- Phase 2 key invariant: `vm.expires` stays on the `vm` table for hypervisor decisions; `subscription.expires` is the billing/policy source of truth that drives it +- Phase 3 key invariant: payment methods know nothing about products; product handlers know nothing about payment methods; `complete_payment` is the only join point