[object Object]

← back to Socrata Cre Viewer

socrata-cre-viewer: searchable/sortable grid over realestate.socrata_cre_prospects (pg-parameterized, sort+density, basic-auth)

bfc886fdb07b3b5fa4856fc1c4a25b8e4e23a5b5 · 2026-08-10 22:00:50 -0700 · steve

Files touched

Diff

commit bfc886fdb07b3b5fa4856fc1c4a25b8e4e23a5b5
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Aug 10 22:00:50 2026 -0700

    socrata-cre-viewer: searchable/sortable grid over realestate.socrata_cre_prospects (pg-parameterized, sort+density, basic-auth)
---
 .gitignore        |   4 ++
 package-lock.json | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json      |  13 +++++
 public/index.html |  94 +++++++++++++++++++++++++++++++
 server.js         | 110 +++++++++++++++++++++++++++++++++++++
 5 files changed, 382 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1bcdbdc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.env*
+*.log
+.DS_Store
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..7519d4d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,161 @@
+{
+  "name": "socrata-cre-viewer",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "socrata-cre-viewer",
+      "version": "0.1.0",
+      "dependencies": {
+        "pg": "^8.23.0"
+      }
+    },
+    "node_modules/pg": {
+      "version": "8.23.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+      "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.16.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+      "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d8b19ad
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+  "name": "socrata-cre-viewer",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Searchable/sortable viewer over realestate.socrata_cre_prospects (LA Socrata CRE prospect firms)",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js"
+  },
+  "dependencies": {
+    "pg": "^8.23.0"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..f350401
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,94 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>LA Socrata CRE Prospects</title>
+<style>
+  :root{--bg:#0e1116;--panel:#161b22;--line:#232b36;--ink:#e6edf3;--dim:#8b98a8;--accent:#c9793a;--chip:#1d2530;--rowh:34px;--fs:13px}
+  *{box-sizing:border-box}
+  body{margin:0;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink)}
+  header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
+  header h1{font-size:16px;margin:0;font-weight:650}
+  header .sub{color:var(--dim);font-size:12px}
+  .bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:12px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
+  input,select{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:13px}
+  input#q{min-width:300px}
+  .bar label{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.4px;margin-right:-4px}
+  .density{display:flex;align-items:center;gap:7px;margin-left:auto}
+  .density input[type=range]{accent-color:var(--accent)}
+  .count{color:var(--dim);font-size:12px;white-space:nowrap}.count b{color:var(--ink)}
+  .wrap{overflow:auto;height:calc(100vh - 118px)}
+  table{border-collapse:collapse;width:100%;font-size:var(--fs)}
+  thead th{position:sticky;top:0;background:var(--panel);border-bottom:1px solid var(--line);text-align:left;padding:8px 10px;white-space:nowrap;cursor:pointer;user-select:none;font-weight:600;color:#cdd6e0}
+  thead th .ar{color:var(--accent);font-size:10px;margin-left:3px}
+  tbody td{border-bottom:1px solid var(--line);padding:0 10px;height:var(--rowh);white-space:nowrap;max-width:320px;overflow:hidden;text-overflow:ellipsis}
+  tbody tr:hover{background:#131922}
+  .role{display:inline-block;padding:1px 7px;border-radius:10px;background:var(--chip);color:#e0b48a;font-size:11px}
+  .src{font-size:11px;color:var(--dim)}
+  .num{text-align:right;font-variant-numeric:tabular-nums}
+  .when{color:var(--dim);font-size:11px}.muted{color:var(--dim)}
+  .pager{display:flex;gap:8px;align-items:center;padding:9px 18px;border-top:1px solid var(--line);background:var(--bg)}
+  .pager button{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:6px 12px;cursor:pointer}
+  .pager button:disabled{opacity:.4;cursor:default}
+  .empty{padding:40px;text-align:center;color:var(--dim)}
+</style>
+</head>
+<body>
+<header>
+  <h1>LA Socrata CRE Prospects</h1>
+  <span class="sub">Net-new firms from LA Active Businesses + LADBS permits · deduped vs the curated LABJ set</span>
+</header>
+<div class="bar">
+  <input id="q" placeholder="Search firm, contact, address, NAICS, work…" autocomplete="off">
+  <label>Role</label><select id="role"></select>
+  <label>Source</label><select id="source"></select>
+  <label>City</label><select id="city"></select>
+  <label>NAICS</label><select id="naics_desc"></select>
+  <span class="count" id="count">…</span>
+  <div class="density"><span class="muted" style="font-size:11px">Density</span><input type="range" id="density" min="0" max="4" step="1" value="1"></div>
+</div>
+<div class="wrap"><table><thead><tr id="head"></tr></thead><tbody id="rows"></tbody></table><div class="empty" id="empty" hidden>No matches.</div></div>
+<div class="pager">
+  <button id="prev">‹ Prev</button>
+  <span class="count">Page <b id="page">1</b> <span class="muted">of</span> <b id="pages">1</b></span>
+  <button id="next">Next ›</button><span class="muted" id="range"></span>
+</div>
+<script>
+const LS='socrata-cre-view';
+const COLS=[['firm_name','Firm'],['role','Role'],['contact_name','Contact'],['city','City'],['naics_desc','NAICS'],['permit_sub_type','Permit'],['valuation','Valuation'],['issue_date','Date'],['source','Source'],['first_seen','First seen']];
+const DENS=[['26px','12px'],['34px','13px'],['40px','14px'],['48px','15px'],['58px','16px']];
+const state=Object.assign({q:'',role:'',source:'',city:'',naics_desc:'',sort:'firm_name',dir:'asc',page:1,limit:50,density:1},JSON.parse(localStorage.getItem(LS)||'{}'));
+const save=()=>localStorage.setItem(LS,JSON.stringify(state));const $=id=>document.getElementById(id);
+function dens(){const[h,f]=DENS[state.density]||DENS[1];document.documentElement.style.setProperty('--rowh',h);document.documentElement.style.setProperty('--fs',f);$('density').value=state.density;}
+function esc(s){return(s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+function fmt(s){if(!s)return'';try{return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'});}catch{return s;}}
+function money(v){if(!v)return'<span class=muted>—</span>';const n=+String(v).replace(/[^0-9.]/g,'');return isNaN(n)||!n?'<span class=muted>—</span>':'$'+n.toLocaleString();}
+function head(){$('head').innerHTML=COLS.map(([k,l])=>`<th data-k="${k}">${l}${state.sort===k?`<span class="ar">${state.dir==='asc'?'▲':'▼'}</span>`:''}</th>`).join('');
+  $('head').querySelectorAll('th').forEach(th=>th.onclick=()=>{const k=th.dataset.k;if(state.sort===k)state.dir=state.dir==='asc'?'desc':'asc';else{state.sort=k;state.dir='asc';}state.page=1;save();load();});}
+function rows(d){$('empty').hidden=d.rows.length>0;$('rows').innerHTML=d.rows.map(r=>`<tr>
+  <td title="${esc(r.firm_name)}"><b>${esc(r.firm_name)}</b></td>
+  <td><span class="role">${esc(r.role)}</span></td>
+  <td>${esc(r.contact_name)||'<span class=muted>—</span>'}</td>
+  <td>${esc(r.city)}</td>
+  <td class="muted" title="${esc(r.naics_desc)}">${esc(r.naics_desc)||'—'}</td>
+  <td class="muted">${esc(r.permit_sub_type)||'—'}</td>
+  <td class="num">${money(r.valuation)}</td>
+  <td class="when">${fmt(r.issue_date)}</td>
+  <td class="src">${esc(r.source)}</td>
+  <td class="when" title="${esc(r.first_seen)}">${fmt(r.first_seen)}</td></tr>`).join('');}
+async function load(){const p=new URLSearchParams();for(const k of['q','role','source','city','naics_desc','sort','dir','page','limit'])if(state[k]!==''&&state[k]!=null)p.set(k,state[k]);
+  const d=await(await fetch('/api/rows?'+p)).json();head();rows(d);
+  const pages=Math.max(1,Math.ceil(d.total/d.limit)),start=d.total?(d.page-1)*d.limit+1:0,end=Math.min(d.page*d.limit,d.total);
+  $('count').innerHTML=`<b>${d.total.toLocaleString()}</b> firms`;$('page').textContent=d.page;$('pages').textContent=pages.toLocaleString();
+  $('range').textContent=d.total?`showing ${start.toLocaleString()}–${end.toLocaleString()}`:'';$('prev').disabled=d.page<=1;$('next').disabled=d.page>=pages;}
+function fill(id,list,label){const cur=state[id];$(id).innerHTML=`<option value="">All ${label}</option>`+list.map(r=>`<option value="${esc(r.v)}">${esc(r.v)} (${r.n.toLocaleString()})</option>`).join('');$(id).value=cur;$(id).onchange=()=>{state[id]=$(id).value;state.page=1;save();load();};}
+async function boot(){dens();$('q').value=state.q;const f=await(await fetch('/api/facets')).json();
+  fill('role',f.role,'roles');fill('source',f.source,'sources');fill('city',f.city,'cities');fill('naics_desc',f.naics_desc,'NAICS');
+  let t;$('q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>{state.q=$('q').value.trim();state.page=1;save();load();},250);};
+  $('density').oninput=()=>{state.density=+$('density').value;save();dens();};
+  $('prev').onclick=()=>{if(state.page>1){state.page--;save();load();}};$('next').onclick=()=>{state.page++;save();load();};load();}
+boot();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..9bbc7d0
--- /dev/null
+++ b/server.js
@@ -0,0 +1,110 @@
+#!/usr/bin/env node
+'use strict';
+/*
+ * socrata-cre-viewer
+ * Searchable / sortable grid over realestate.socrata_cre_prospects — LA Socrata
+ * CRE advertiser-prospect firms (Active Businesses + LADBS permits), net-new vs
+ * the curated LABJ set. Node built-in http + pg (parameterized queries only).
+ * Basic Auth admin / DW2024!  (override via VIEWER_USER / VIEWER_PASS).
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { Pool } = require('pg');
+
+const PORT = Number(process.env.PORT || 0);
+const HOST = process.env.HOST || '127.0.0.1';
+const USER = process.env.VIEWER_USER || 'admin';
+const PASS = process.env.VIEWER_PASS || 'DW2024!';
+
+const pool = new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'realestate', max: 6 });
+pool.on('error', (err) => console.error('pg pool error', err));
+const TABLE = 'socrata_cre_prospects';
+
+const SORTABLE = new Set(['firm_name', 'role', 'contact_name', 'city', 'naics_desc',
+  'permit_sub_type', 'valuation', 'issue_date', 'source', 'first_seen', 'zip']);
+const FILTERS = ['role', 'source', 'city', 'naics_desc', 'council_district'];
+const SEARCH_COLS = ['firm_name', 'contact_name', 'address', 'naics_desc', 'work_description'];
+
+function safeEq(a, b) {
+  const ab = Buffer.from(a), bb = Buffer.from(b);
+  return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
+}
+function authed(req) {
+  const h = req.headers.authorization || '';
+  if (!h.startsWith('Basic ')) return false;
+  const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
+  return safeEq(u || '', USER) && safeEq(p || '', PASS);
+}
+
+function buildWhere(qs) {
+  const clauses = [], params = [];
+  for (const col of FILTERS) {
+    const v = qs.get(col);
+    if (v) { params.push(v); clauses.push(`${col} = $${params.length}`); }
+  }
+  const q = (qs.get('q') || '').trim();
+  if (q) {
+    params.push(`%${q}%`);
+    const i = params.length;
+    clauses.push('(' + SEARCH_COLS.map((c) => `${c} ILIKE $${i}`).join(' OR ') + ')');
+  }
+  return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params };
+}
+
+async function apiRows(qs) {
+  const { where, params } = buildWhere(qs);
+  let sort = qs.get('sort') || 'firm_name';
+  if (!SORTABLE.has(sort)) sort = 'firm_name';
+  const dir = (qs.get('dir') || 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC';
+  // valuation is stored as text; sort it numerically when chosen.
+  const sortExpr = sort === 'valuation' ? "NULLIF(valuation,'')::bigint" : sort;
+  const limit = Math.min(Math.max(parseInt(qs.get('limit'), 10) || 50, 1), 200);
+  const page = Math.max(parseInt(qs.get('page'), 10) || 1, 1);
+  const offset = (page - 1) * limit;
+
+  const total = Number((await pool.query(`SELECT count(*)::bigint n FROM ${TABLE} ${where}`, params)).rows[0].n);
+  const rowsSql =
+    `SELECT firm_name, role, contact_name, address, city, state, zip, naics, naics_desc,
+            permit_type, permit_sub_type, valuation, issue_date, source, work_description,
+            council_district, first_seen
+       FROM ${TABLE} ${where}
+      ORDER BY ${sortExpr} ${dir} NULLS LAST, firm_name ASC
+      LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
+  const rows = (await pool.query(rowsSql, [...params, limit, offset])).rows;
+  return { total, page, limit, sort, dir, rows };
+}
+
+let fc = null, fa = 0;
+async function apiFacets() {
+  if (fc && Date.now() - fa < 3 * 60 * 1000) return fc;
+  const facet = async (col, lim) => (await pool.query(
+    `SELECT ${col} AS v, count(*)::int AS n FROM ${TABLE}
+      WHERE ${col} IS NOT NULL AND ${col} <> '' GROUP BY ${col} ORDER BY n DESC LIMIT ${lim}`)).rows;
+  const [role, source, city, naics_desc] = await Promise.all([
+    facet('role', 20), facet('source', 10), facet('city', 40), facet('naics_desc', 40)]);
+  const total = Number((await pool.query(`SELECT count(*)::bigint n FROM ${TABLE}`)).rows[0].n);
+  fc = { total, role, source, city, naics_desc }; fa = Date.now();
+  return fc;
+}
+
+function send(res, code, body, type = 'application/json') {
+  res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
+  res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
+}
+
+const server = http.createServer(async (req, res) => {
+  if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="socrata-cre"' }); return res.end('Auth required'); }
+  const url = new URL(req.url, 'http://x');
+  try {
+    if (url.pathname === '/' || url.pathname === '/index.html')
+      return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html')), 'text/html; charset=utf-8');
+    if (url.pathname === '/api/facets') return send(res, 200, await apiFacets());
+    if (url.pathname === '/api/rows') return send(res, 200, await apiRows(url.searchParams));
+    if (url.pathname === '/healthz') return send(res, 200, { ok: true });
+    return send(res, 404, { error: 'not found' });
+  } catch (e) { console.error(e); return send(res, 500, { error: String(e.message || e) }); }
+});
+
+server.listen(PORT, HOST, () => console.log(`socrata-cre-viewer live: http://${HOST}:${server.address().port}  (login ${USER})`));

(oldest)  ·  back to Socrata Cre Viewer  ·  nav-agent: universal grid-controls drop-in on internal dashb e9ca4ee →