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:
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
// Public read-only API for the dashboard page.
|
||||
// GET api.php → latest price + latest report per timeframe
|
||||
// GET api.php?what=price → latest price only (cheap 2-second poll)
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
function fail(int $code, string $msg): never {
|
||||
http_response_code($code);
|
||||
echo json_encode(['ok' => false, 'error' => $msg]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
fail(405, 'GET only');
|
||||
}
|
||||
|
||||
$cfg = require __DIR__ . '/config.php';
|
||||
|
||||
$symbol = (string)($_GET['symbol'] ?? $cfg['symbols'][0]);
|
||||
if (!in_array($symbol, $cfg['symbols'], true)) {
|
||||
fail(400, 'unknown symbol');
|
||||
}
|
||||
$what = (string)($_GET['what'] ?? 'all');
|
||||
|
||||
try {
|
||||
$pdo = new PDO($cfg['db_dsn'], $cfg['db_user'], $cfg['db_pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
fail(500, 'db unavailable');
|
||||
}
|
||||
|
||||
$out = ['ok' => true, 'server_time' => gmdate('Y-m-d H:i:s')];
|
||||
|
||||
try {
|
||||
$st = $pdo->prepare(
|
||||
'SELECT price, prev_price, tick_at,
|
||||
TIMESTAMPDIFF(SECOND, tick_at, UTC_TIMESTAMP()) AS age_sec
|
||||
FROM live_price WHERE symbol = :s'
|
||||
);
|
||||
$st->execute([':s' => $symbol]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
$out['price'] = $row === false ? null : [
|
||||
'price' => (float)$row['price'],
|
||||
'prev_price' => $row['prev_price'] === null ? null : (float)$row['prev_price'],
|
||||
'tick_at' => $row['tick_at'],
|
||||
'age_sec' => (int)$row['age_sec'],
|
||||
];
|
||||
|
||||
if ($what !== 'price') {
|
||||
$st = $pdo->prepare(
|
||||
'SELECT r.timeframe, r.payload, r.updated_at,
|
||||
TIMESTAMPDIFF(SECOND, r.updated_at, UTC_TIMESTAMP()) AS age_sec
|
||||
FROM signal_reports r
|
||||
JOIN (SELECT timeframe, MAX(id) AS mid
|
||||
FROM signal_reports WHERE symbol = :s
|
||||
GROUP BY timeframe) x ON x.mid = r.id'
|
||||
);
|
||||
$st->execute([':s' => $symbol]);
|
||||
$reports = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$p = json_decode((string)$r['payload'], true);
|
||||
if (is_array($p)) {
|
||||
$p['stored_at'] = $r['updated_at'];
|
||||
$p['age_sec'] = (int)$r['age_sec'];
|
||||
$reports[$r['timeframe']] = $p;
|
||||
}
|
||||
}
|
||||
$out['reports'] = $reports;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('api.php: ' . $e->getMessage());
|
||||
fail(500, 'db error');
|
||||
}
|
||||
|
||||
echo json_encode($out, JSON_UNESCAPED_UNICODE);
|
||||
Reference in New Issue
Block a user