Add LAMP web dashboard: bot→MySQL publisher, PHP API, numbers-only page

- webapp/: deployable LAMP app for /var/www/ — ingest.php (token-gated
  writes), api.php (public read-only), schema.sql (live_price +
  signal_reports), vanilla-JS dashboard with plain-English indicator
  readings, .htaccess, deploy README
- web_publisher.py: fire-and-forget publisher (bounded queue + daemon
  thread) with plain-English payload builder; public API contractually
  never raises; disabled unless WEBAPP_INGEST_URL/TOKEN are set
- telegram_bot.py: publish hooks (tick in WS loop, report in
  _update_level_cache); _candle_loop per-iteration guard so a failing
  scan can never kill an auto-scan loop; quiet heartbeat teardown
- test_all.py: Module 13 — 6 resilience tests (never-raise contract,
  worker survival, candle-loop survival); suite 189/189
- .gitignore: webapp/config.php (server-side secrets)

Verified end-to-end on a scratch MariaDB 10.11 + PHP 8.3 built-in
server incl. headless-Firefox render of the live page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYNEHBuwwy6sgRM9f9sPx5
This commit is contained in:
Harwinder Sekhon
2026-07-17 11:41:03 -04:00
parent 85b7567677
commit 85a5ba09b8
13 changed files with 1437 additions and 4 deletions
+116
View File
@@ -2493,6 +2493,122 @@ def _():
assert args2.cp is False
# ══════════════════════════════════════════════════════════════════════
# MODULE 13: web_publisher resilience — dashboard publishing must
# NEVER be able to break the Telegram bot (no-raise contract) and a
# failing scan must never kill an auto-scan loop.
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 13: web_publisher resilience ═══")
import asyncio as _aio
import threading as _threading
import web_publisher as wp
@test("publish_* are silent no-ops when not configured")
def _():
old = wp.INGEST_URL, wp.INGEST_TOKEN
wp.INGEST_URL, wp.INGEST_TOKEN = "", ""
try:
assert not wp.enabled()
before = wp._queue.qsize()
wp.publish_tick("XAU/USD", 4000.0, 3999.5)
wp.publish_report(object()) # junk input must not even be touched
assert wp._queue.qsize() == before
finally:
wp.INGEST_URL, wp.INGEST_TOKEN = old
@test("publish_tick never raises: enqueue failure and junk input swallowed")
def _():
old = wp.INGEST_URL, wp.INGEST_TOKEN
old_enq = wp._enqueue
wp.INGEST_URL, wp.INGEST_TOKEN = "http://127.0.0.1:1/x", "t"
def boom(item):
raise RuntimeError("forced enqueue failure")
wp._enqueue = boom
try:
wp._last_tick_pub.clear()
wp.publish_tick("XAU/USD", 4000.0, None) # enqueue explodes
wp.publish_tick("TEST/A", "not-a-number", None) # float() explodes
finally:
wp._enqueue = old_enq
wp.INGEST_URL, wp.INGEST_TOKEN = old
@test("publish_report never raises: broken report and enqueue failure swallowed")
def _():
from signal_aggregator import SignalReport as _SR
old = wp.INGEST_URL, wp.INGEST_TOKEN
wp.INGEST_URL, wp.INGEST_TOKEN = "http://127.0.0.1:1/x", "t"
old_enq = wp._enqueue
try:
wp.publish_report(object()) # build_payload explodes internally
def boom(item):
raise RuntimeError("forced enqueue failure")
wp._enqueue = boom
r = _SR(symbol="XAU/USD", display_name="XAU/USD",
timestamp="2026-07-17 12:45:00+00:00")
wp.publish_report(r) # valid report, enqueue explodes
finally:
wp._enqueue = old_enq
wp.INGEST_URL, wp.INGEST_TOKEN = old
@test("build_payload works on a minimal report (no analysis, no setup)")
def _():
from signal_aggregator import SignalReport as _SR
r = _SR(symbol="XAU/USD", display_name="XAU/USD",
timestamp="2026-07-17 12:45:00+00:00")
p = wp.build_payload(r)
assert p["signal"] == "NEUTRAL" and p["trade_setup"] is None
assert p["bar_ts"] == "2026-07-17 12:45:00"
assert "summary" in p and p["indicators"] == {}
@test("worker thread survives a dead ingest endpoint (queue always drains)")
def _():
old = wp.INGEST_URL, wp.INGEST_TOKEN
# port 9 (discard) on localhost → immediate connection refused
wp.INGEST_URL, wp.INGEST_TOKEN = "http://127.0.0.1:9/ingest.php", "t"
try:
wp._last_tick_pub.clear()
wp.publish_tick("XAU/USD", 4001.0, None)
wp._queue.join() # hangs forever if task_done is ever skipped
workers = [t for t in _threading.enumerate() if t.name == "web-publisher"]
assert workers and workers[0].is_alive(), "publisher worker died"
finally:
wp.INGEST_URL, wp.INGEST_TOKEN = old
@test("_candle_loop survives a failing scan iteration and keeps looping")
def _():
import telegram_bot as tb
calls = {"n": 0}
async def fake_scan(bot, tf, confirms):
calls["n"] += 1
raise RuntimeError("forced scan failure")
orig = (tb._scan_timeframe, tb._seconds_until_boundary, tb._feed_active)
tb._scan_timeframe = fake_scan
tb._seconds_until_boundary = lambda m, buffer=10: 0.01
tb._feed_active = lambda: calls["n"] < 2
try:
_aio.run(_aio.wait_for(
tb._candle_loop(None, Timeframe.M5, 5), timeout=10))
assert calls["n"] == 2, \
f"loop died after a failing iteration (ran {calls['n']} of 2)"
finally:
(tb._scan_timeframe, tb._seconds_until_boundary,
tb._feed_active) = orig
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════