Streaming Protocol¶
Proxbox uses Server-Sent Events (SSE) as the primary transport for real-time sync progress. A secondary WebSocket channel carries broadcast messages for dashboard updates. This page documents both protocols in detail.
Two Sync Transport Modes¶
The plugin opens long-lived HTTP GET calls to backend stage paths such as proxbox-api/dcim/devices/create/stream or proxbox-api/proxmox/sdn/create/stream. The response is text/event-stream and carries SSE frames for each stage and object processed. Scheduled jobs call one backend /stream path per selected stage; browser proxy views may still stream a single backend path directly.
- Used by:
ProxboxSyncJob.run()viarun_sync_stream(), browser-facingStreamingHttpResponseproxy views - Advantages: real-time per-object progress, no polling needed, works through proxies with
X-Accel-Buffering: no - Timeout: HTTP between-chunk read timeout is 3600 s (
_SYNC_STREAM_READ_TIMEOUT). RQ job wall-clock limit is 7200 s.
The plugin sends a GET to proxbox-api/full-update and waits for a single JSON response containing all sync results.
- Used by:
sync_full_update_resource(), some older dashboard views - Advantages: simple request/response, easy to debug
- Disadvantages: no real-time progress, times out on large clusters
SSE Event Types¶
Every SSE event has the form:
event: <event_type>
data: <json_payload>
(Note the blank line between events — this is the SSE message boundary.)
discovery¶
Emitted once at the start of a scheduled full-update job or legacy full-update stream. Lists all stages that will be processed.
{
"event": "discovery",
"phase": "full-update",
"status": "discovered",
"message": "Discovered 13 sync stage(s) for full update",
"count": 13,
"items": [
{"name": "devices", "type": "stage"},
{"name": "storage", "type": "stage"},
...
],
"progress": {"current": 0, "total": 13, "percent": 0},
"metadata": {"operation_id": "550e8400-e29b-41d4-a716-446655440000"}
}
step¶
Emitted twice per stage: once when the stage starts, once when it finishes.
// Stage started
{"step": "virtual-machines", "status": "started", "message": "Starting virtual machines synchronization."}
// Stage completed
{"step": "virtual-machines", "status": "completed", "message": "Virtual machines synchronization finished.", "result": {"count": 47}}
substep¶
Emitted by individual sync services for sub-stage progress (e.g., per-VM progress within the VM stage).
{"substep": "vm_create", "status": "processing", "message": "Creating VM web-server-01", "vmid": 100}
item_progress¶
Carries per-object sync result for fine-grained frontend progress bars.
{"event": "item_progress", "name": "web-server-01", "status": "created", "type": "virtual_machine", "id": 42}
phase_summary¶
Emitted by some stages to summarize a batch of objects processed.
{"event": "phase_summary", "phase": "backups", "created": 12, "updated": 3, "skipped": 0, "errors": 0}
error_detail¶
Structured error event with category, suggestion, and detail text.
{
"event": "error_detail",
"phase": "virtual-machines",
"category": "internal",
"message": "Virtual machine sync failed",
"detail": "Connection refused to Proxmox VE",
"suggestion": "Check Proxmox endpoint connectivity and retry"
}
error¶
Short-form error event used alongside error_detail.
{"step": "full-update", "status": "failed", "error": "Error while syncing virtual machines.", "detail": "..."}
complete¶
Always the last event in a stream. ok: true on success, ok: false on failure.
// Success
{"ok": true, "message": "Full update sync completed.", "result": {"devices_count": 3, "virtual_machines_count": 47, ...}}
// Failure
{"ok": false, "message": "Error while syncing virtual machines.", "errors": [{"detail": "..."}]}
SSE Proxy Chain¶
The SSE stream passes through two hops before reaching the browser:
sequenceDiagram
participant Browser
participant NB as NetBox Plugin<br/>(Django view)
participant RQ as RQ Worker<br/>(ProxboxSyncJob)
participant API as proxbox-api<br/>(FastAPI)
Browser->>NB: GET /proxbox/sync/stream/
NB->>NB: Create StreamingHttpResponse
NB->>API: GET /<stage>/stream (via iter_backend_sse_lines)
API-->>NB: SSE frames (chunked)
NB-->>Browser: SSE frames (proxied)
note over RQ,API: Background job path (separate)
RQ->>API: GET /<stage>/stream (via run_sync_stream)
API-->>RQ: SSE frames consumed by on_frame callback
RQ->>RQ: Write progress to Job.log
The iter_backend_sse_lines() function in netbox_proxbox/services/backend_proxy.py opens a streaming requests.get() with stream=True and yields each raw SSE line. The Django view wraps this in a StreamingHttpResponse with Content-Type: text/event-stream.
For background jobs, run_sync_stream() consumes the SSE stream to completion using an on_frame callback that writes progress data to the NetBox Job record.
Background job observer lifetime¶
JobStreamSSEView observes the persisted NetBox Job row for browser job-detail
pages. It sends an SSE comment heartbeat at least every 15 seconds while no job
event is available and sets X-Accel-Buffering: no, ensuring Nginx flushes the
comment to the client. A disconnected browser therefore causes the WSGI write
to fail promptly instead of leaving a Gunicorn request thread blocked on an
empty queue. Closing the response also signals the polling producer through a
threading.Event; its poll waits are interruptible and the producer exits
without emitting a synthetic terminal event. The producer also brackets its
work with Django's close_old_connections() because it is a manually created
thread, and a warning is logged if it survives the bounded shutdown join.
This behavior is an availability invariant. Removing the heartbeat, enabling
proxy buffering, or restoring an unbounded queue.get() can strand one gthread
per disconnected browser and eventually make unrelated NetBox API requests
time out. During incident diagnosis, a growing set of CLOSE-WAIT sockets on
the NetBox WSGI port together with stacks in _stream_job_events() indicates
that this invariant has regressed.
WebSocketSSEBridge¶
proxbox-api uses an internal bridge class to decouple async sync work from SSE frame emission:
class WebSocketSSEBridge:
"""Connects an async sync function to SSE frame output."""
async def send(self, event: str, data: dict) -> None:
"""Called by sync service to emit a progress event."""
await self._queue.put(sse_event(event, data))
async def close(self) -> None:
"""Signal that no more events will be sent."""
await self._queue.put(None)
async def iter_sse(self) -> AsyncIterator[str]:
"""Yields SSE frames for the HTTP response generator."""
while True:
frame = await self._queue.get()
if frame is None:
break
yield frame
Each stage gets its own bridge instance. The full_update.py event generator runs the sync function as a background asyncio.Task, iterates the bridge's SSE output, then awaits the task result:
async def _run_vms_sync():
try:
return await create_virtual_machines(..., websocket=vm_bridge, use_websocket=True)
finally:
await vm_bridge.close() # always signal completion
vms_task = asyncio.create_task(_run_vms_sync())
async for frame in vm_bridge.iter_sse():
yield frame # proxy to HTTP response
sync_vms = await vms_task # collect result
Stream Lifecycle State Machine¶
stateDiagram-v2
[*] --> Connected: GET /<stage>/stream
Connected --> Discovering: event:discovery
Discovering --> Stage_1: event:step devices started
Stage_1 --> Stage_1: event:substep / item_progress
Stage_1 --> Stage_2: event:step devices completed
Stage_2 --> Stage_2: event:substep / item_progress
Stage_2 --> Stage_N: event:step ... (stages 3–11)
Stage_N --> Complete: event:step backup-routines completed\nevent:complete ok=true
Stage_N --> Failed: event:error_detail\nevent:error\nevent:complete ok=false
Complete --> [*]
Failed --> [*]
note right of Stage_N: Cancellation also\nleads to Failed state
WebSocket Channel¶
In addition to SSE, the plugin supports a WebSocket channel for broadcast messages. This is used by the home.js dashboard to receive sync-end notifications without polling.
Browser ──── ws://netbox-host/plugins/proxbox/ws/ ──── websocket_client.py ──── proxbox-api WS
The websocket.js script exposes:
onSyncEnd(listener) // register a callback for sync completion events
notifySyncEnd(obj) // trigger all registered listeners (called by sync.js)
Timeout Architecture¶
Proxbox involves three distinct timeout layers that must all be longer than the expected sync duration:
| Timeout | Value | Location | Controls |
|---|---|---|---|
| RQ job wall-clock | 7200 s | PROXBOX_SYNC_JOB_TIMEOUT in jobs.py |
Max time for an RQ worker to hold the job before killing it |
| HTTP read (between chunks) | 3600 s | _SYNC_STREAM_READ_TIMEOUT in backend_proxy.py |
Max time to wait for the next SSE chunk from proxbox-api |
| NetBox API request | 120 s | PROXBOX_NETBOX_TIMEOUT env var |
Per-request timeout for netbox-sdk calls inside proxbox-api |
Stuck jobs
pendingforever: no RQ worker is running, or the worker does not listen to thedefaultqueue.runningfor a long time: proxbox-api is still processing — check the Job log for SSE progress events.erroredwithJobTimeoutException: the RQ wall-clock limit was hit — increasePROXBOX_SYNC_JOB_TIMEOUT.
See Backend Integration for diagnosis steps.