councils.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. <?php
  2. // web/councils.php
  3. declare(strict_types=1);
  4. // ---- DB config from env ----
  5. $DB_HOST = getenv('MYSQL_HOST') ?: 'db';
  6. $DB_NAME = getenv('MYSQL_DATABASE') ?: 'councils';
  7. $DB_USER = getenv('MYSQL_USER') ?: 'root';
  8. $DB_PASS = getenv('MYSQL_PASSWORD') ?: getenv('MYSQL_ROOT_PASSWORD');
  9. $DB_PORT = (int)(getenv('MYSQL_PORT') ?: 3306);
  10. // ---- Connect ----
  11. $dsn = "mysql:host={$DB_HOST};port={$DB_PORT};dbname={$DB_NAME};charset=utf8mb4";
  12. $pdo = new PDO($dsn, $DB_USER, $DB_PASS, [
  13. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  14. PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  15. ]);
  16. function h($s): string { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
  17. function council_name_from_key(string $k): string {
  18. $base = preg_replace('/^da_/', '', $k);
  19. $base = str_replace('_', ' ', $base);
  20. $map = [
  21. 'flinders_council' => 'Flinders',
  22. 'southernmidlands' => 'Southern Midlands',
  23. ];
  24. if (isset($map[$base])) return $map[$base];
  25. return ucwords($base);
  26. }
  27. function tableExists(PDO $pdo, string $table): bool {
  28. $st = $pdo->prepare("SHOW TABLES LIKE ?");
  29. $st->execute([$table]);
  30. return (bool)$st->fetch(PDO::FETCH_NUM);
  31. }
  32. function tableHasColumn(PDO $pdo, string $table, string $col): bool {
  33. $q = $pdo->prepare("SHOW COLUMNS FROM `{$table}` LIKE ?");
  34. $q->execute([$col]);
  35. return (bool)$q->fetch();
  36. }
  37. function columnMeta(PDO $pdo, string $table, string $col): ?array {
  38. $q = $pdo->prepare("SHOW COLUMNS FROM `{$table}` LIKE ?");
  39. $q->execute([$col]);
  40. $r = $q->fetch();
  41. return $r ?: null;
  42. }
  43. function firstExistingCol(PDO $pdo, string $table, array $candidates): ?string {
  44. foreach ($candidates as $c) {
  45. if (tableHasColumn($pdo, $table, $c)) return $c;
  46. }
  47. return null;
  48. }
  49. function fmt_dt(?string $dt): string {
  50. if (!$dt) return '';
  51. try {
  52. $d = new DateTime($dt);
  53. return $d->format('Y-m-d H:i');
  54. } catch (Throwable $e) {
  55. return (string)$dt;
  56. }
  57. }
  58. function rel_age(?string $dt): string {
  59. if (!$dt) return '';
  60. try {
  61. $d = new DateTime($dt);
  62. $now = new DateTime('now');
  63. $diff = $now->getTimestamp() - $d->getTimestamp();
  64. if ($diff < 0) return 'in future';
  65. $mins = (int)floor($diff / 60);
  66. if ($mins < 60) return $mins . ' min';
  67. $hrs = (int)floor($mins / 60);
  68. if ($hrs < 48) return $hrs . ' hr';
  69. $days = (int)floor($hrs / 24);
  70. return $days . ' d';
  71. } catch (Throwable $e) {
  72. return '';
  73. }
  74. }
  75. // ---- Discover da_* tables ----
  76. $allTables = [];
  77. $st = $pdo->query("SHOW TABLES");
  78. while ($row = $st->fetch(PDO::FETCH_NUM)) {
  79. $t = $row[0];
  80. if (strpos($t, 'da_') === 0) $allTables[] = $t;
  81. }
  82. // Exclude tables you don't want
  83. $exclude = ['geo_cache', 'da_plandata', 'da_plan_data', 'da_dorset_stages'];
  84. $allTables = array_values(array_filter($allTables, fn($t) => !in_array($t, $exclude, true)));
  85. if (!$allTables) {
  86. http_response_code(200);
  87. echo "<h1>No da_* tables found</h1>";
  88. exit;
  89. }
  90. sort($allTables);
  91. // Optional sort controls
  92. $sort = (string)($_GET['sort'] ?? 'name'); // name|last|count|open
  93. $dir = strtolower((string)($_GET['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
  94. // Optional external scrape log table support
  95. // If you have a runs table already, this will attempt to use it.
  96. // Supported names: scrape_runs, scrape_log, runs
  97. $runsTable = null;
  98. foreach (['scrape_runs','scrape_log','runs'] as $cand) {
  99. if (tableExists($pdo, $cand)) { $runsTable = $cand; break; }
  100. }
  101. $runsCouncilCol = $runsTable ? firstExistingCol($pdo, $runsTable, ['council_key','council','table_name','source']) : null;
  102. $runsStatusCol = $runsTable ? firstExistingCol($pdo, $runsTable, ['status','state','result','ok']) : null;
  103. $runsTimeCol = $runsTable ? firstExistingCol($pdo, $runsTable, ['finished_at','ended_at','completed_at','run_finished_at','created_at','started_at','run_at','timestamp']) : null;
  104. $summary = [];
  105. foreach ($allTables as $t) {
  106. // counts
  107. $total = (int)$pdo->query("SELECT COUNT(*) AS c FROM `{$t}`")->fetch()['c'];
  108. $open = null;
  109. if (tableHasColumn($pdo, $t, 'on_notice_to')) {
  110. $open = (int)$pdo->query("SELECT COUNT(*) AS c FROM `{$t}` WHERE (`on_notice_to` IS NULL OR `on_notice_to` >= CURDATE())")->fetch()['c'];
  111. }
  112. // last successful scrape
  113. $lastScrape = null;
  114. // 1) Try a runs/log table (if present)
  115. if ($runsTable && $runsCouncilCol && $runsTimeCol) {
  116. $where = "WHERE `{$runsCouncilCol}` = ?";
  117. $params = [$t];
  118. if ($runsStatusCol) {
  119. $statusMeta = columnMeta($pdo, $runsTable, $runsStatusCol);
  120. $type = strtolower((string)($statusMeta['Type'] ?? ''));
  121. if (strpos($type, 'tinyint') !== false || strpos($type, 'int') !== false || $runsStatusCol === 'ok') {
  122. $where .= " AND `{$runsStatusCol}` = 1";
  123. } else {
  124. $where .= " AND `{$runsStatusCol}` IN ('ok','OK','success','successful','done','completed')";
  125. }
  126. }
  127. $sql = "SELECT `{$runsTimeCol}` AS ts FROM `{$runsTable}` {$where} ORDER BY `{$runsTimeCol}` DESC LIMIT 1";
  128. $q = $pdo->prepare($sql);
  129. $q->execute($params);
  130. $r = $q->fetch();
  131. if ($r && $r['ts']) $lastScrape = (string)$r['ts'];
  132. }
  133. // 2) Fallback to per-table timestamp columns
  134. if (!$lastScrape) {
  135. $tsCol = firstExistingCol($pdo, $t, [
  136. 'dev_timestamp',
  137. 'scraped_at',
  138. 'scrape_ts',
  139. 'last_scraped_at',
  140. 'fetched_at',
  141. 'updated_at',
  142. 'created_at',
  143. 'inserted_at',
  144. ]);
  145. if ($tsCol) {
  146. $meta = columnMeta($pdo, $t, $tsCol);
  147. $type = strtolower((string)($meta['Type'] ?? ''));
  148. if (strpos($type, 'int') !== false) {
  149. $r = $pdo->query("SELECT FROM_UNIXTIME(MAX(`{$tsCol}`)) AS ts FROM `{$t}`")->fetch();
  150. if ($r && $r['ts']) $lastScrape = (string)$r['ts'];
  151. } else {
  152. $r = $pdo->query("SELECT MAX(`{$tsCol}`) AS ts FROM `{$t}`")->fetch();
  153. if ($r && $r['ts']) $lastScrape = (string)$r['ts'];
  154. }
  155. }
  156. }
  157. // 3) Final fallback: latest date_received
  158. if (!$lastScrape && tableHasColumn($pdo, $t, 'date_received')) {
  159. $r = $pdo->query("SELECT MAX(`date_received`) AS ts FROM `{$t}`")->fetch();
  160. if ($r && $r['ts']) $lastScrape = (string)$r['ts'];
  161. }
  162. $summary[] = [
  163. 'council_key' => $t,
  164. 'council' => council_name_from_key($t),
  165. 'total' => $total,
  166. 'open' => $open,
  167. 'last_scrape' => $lastScrape,
  168. ];
  169. }
  170. // Sorting in PHP (keeps it simple and avoids dynamic SQL)
  171. $cmp = function(array $a, array $b) use ($sort, $dir): int {
  172. $mul = $dir === 'desc' ? -1 : 1;
  173. if ($sort === 'count') {
  174. return $mul * (($a['total'] <=> $b['total']) ?: strcasecmp($a['council'], $b['council']));
  175. }
  176. if ($sort === 'open') {
  177. $ao = $a['open'] ?? -1;
  178. $bo = $b['open'] ?? -1;
  179. return $mul * (($ao <=> $bo) ?: strcasecmp($a['council'], $b['council']));
  180. }
  181. if ($sort === 'last') {
  182. $at = $a['last_scrape'] ? strtotime($a['last_scrape']) : 0;
  183. $bt = $b['last_scrape'] ? strtotime($b['last_scrape']) : 0;
  184. return $mul * (($at <=> $bt) ?: strcasecmp($a['council'], $b['council']));
  185. }
  186. return $mul * strcasecmp($a['council'], $b['council']);
  187. };
  188. usort($summary, $cmp);
  189. ?>
  190. <!doctype html>
  191. <html lang="en">
  192. <head>
  193. <meta charset="utf-8">
  194. <meta name="viewport" content="width=device-width, initial-scale=1">
  195. <title>Council scrape status</title>
  196. <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
  197. <style>
  198. body { padding: 24px; }
  199. .muted { color:#6c757d; }
  200. .nowrap { white-space: nowrap; }
  201. </style>
  202. </head>
  203. <body>
  204. <div class="container">
  205. <div class="d-flex align-items-center justify-content-between mb-3">
  206. <h1 class="h3 m-0">Council scrape status</h1>
  207. <div class="d-flex gap-2">
  208. <a class="btn btn-outline-secondary btn-sm" href="index.php">Open applications</a>
  209. </div>
  210. </div>
  211. <form method="get" class="row g-2 align-items-center mb-3">
  212. <div class="col-auto">
  213. <select name="sort" class="form-select form-select-sm">
  214. <option value="name" <?= $sort==='name'?'selected':'' ?>>sort: council</option>
  215. <option value="last" <?= $sort==='last'?'selected':'' ?>>sort: last scrape</option>
  216. <option value="count" <?= $sort==='count'?'selected':'' ?>>sort: total rows</option>
  217. <option value="open" <?= $sort==='open'?'selected':'' ?>>sort: open rows</option>
  218. </select>
  219. </div>
  220. <div class="col-auto">
  221. <select name="dir" class="form-select form-select-sm">
  222. <option value="asc" <?= $dir==='asc'?'selected':'' ?>>asc</option>
  223. <option value="desc" <?= $dir==='desc'?'selected':'' ?>>desc</option>
  224. </select>
  225. </div>
  226. <div class="col-auto">
  227. <button class="btn btn-primary btn-sm" type="submit">Apply</button>
  228. </div>
  229. <div class="col-auto muted">
  230. <?= count($summary) ?> council(s)
  231. <?php if ($runsTable): ?>
  232. , using run log: <?= h($runsTable) ?>
  233. <?php endif; ?>
  234. </div>
  235. </form>
  236. <div class="table-responsive">
  237. <table class="table table-sm table-bordered align-middle">
  238. <thead class="table-light">
  239. <tr>
  240. <th style="width: 24%">Council</th>
  241. <th style="width: 18%">Table</th>
  242. <th class="text-end" style="width: 12%">Total</th>
  243. <th class="text-end" style="width: 12%">Open</th>
  244. <th style="width: 22%">Last successful scrape</th>
  245. <th style="width: 12%"></th>
  246. </tr>
  247. </thead>
  248. <tbody>
  249. <?php foreach ($summary as $r): ?>
  250. <tr>
  251. <td><?= h($r['council']) ?></td>
  252. <td class="nowrap"><code><?= h($r['council_key']) ?></code></td>
  253. <td class="text-end"><?= number_format((int)$r['total']) ?></td>
  254. <td class="text-end"><?= is_null($r['open']) ? '<span class="muted">n/a</span>' : number_format((int)$r['open']) ?></td>
  255. <td class="nowrap">
  256. <?php if ($r['last_scrape']): ?>
  257. <?= h(fmt_dt($r['last_scrape'])) ?>
  258. <span class="muted">(<?= h(rel_age($r['last_scrape'])) ?> ago)</span>
  259. <?php else: ?>
  260. <span class="muted">unknown</span>
  261. <?php endif; ?>
  262. </td>
  263. <td class="text-center">
  264. <a class="btn btn-outline-secondary btn-sm"
  265. href="index.php?council_key=<?= urlencode($r['council_key']) ?>">
  266. View
  267. </a>
  268. </td>
  269. </tr>
  270. <?php endforeach; ?>
  271. </tbody>
  272. </table>
  273. </div>
  274. <div class="muted mt-3">
  275. Notes: “Last successful scrape” is taken from a scrape log table if present (scrape_runs, scrape_log, runs). Otherwise it uses the best available per-row timestamp column, otherwise date_received.
  276. </div>
  277. </div>
  278. </body>
  279. </html>