New FastAPI python page for easily human reading of the json objects we are capturing. Addition of a template parser that can be used to interpret and send/push jobMaterials to the server. Adding the docs dir just so that it is all together.

This commit is contained in:
2026-04-28 16:14:07 +10:00
parent 9ab1b3ea8f
commit debc442081
11 changed files with 2794 additions and 0 deletions
+421
View File
@@ -0,0 +1,421 @@
import json
import os
import sqlite3
from contextlib import closing
from datetime import datetime
from html import escape
from urllib.parse import urlencode
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse
DB_PATH = os.getenv("WEBHOOK_DB_PATH", "./servicem8_webhooks.db")
APP_HOST = os.getenv("WEBHOOK_INSPECTOR_HOST", "127.0.0.1")
APP_PORT = int(os.getenv("WEBHOOK_INSPECTOR_PORT", "18355"))
PAGE_SIZE = 50
app = FastAPI(title="ServiceM8 Inspector", version="0.1.0")
def get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def html_page(title: str, body: str) -> HTMLResponse:
nav = """
<nav>
<a href='/'>Dashboard</a>
<a href='/events'>Events</a>
<a href='/objects'>Objects</a>
<a href='/form-responses'>Form responses</a>
</nav>
"""
css = """
<style>
body { font-family: Inter, system-ui, sans-serif; margin: 24px; color: #1f2937; background: #f8fafc; }
nav { margin-bottom: 20px; display: flex; gap: 14px; flex-wrap: wrap; }
nav a { text-decoration: none; color: #0f766e; font-weight: 600; }
h1, h2, h3 { margin-bottom: 0.4rem; }
.muted { color: #6b7280; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 16px 0 24px; }
.card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
.big { font-size: 1.6rem; font-weight: 700; }
table { width: 100%; border-collapse: collapse; background: white; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
th { background: #f1f5f9; position: sticky; top: 0; }
tr:hover td { background: #f8fafc; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
pre { white-space: pre-wrap; word-break: break-word; background: #0f172a; color: #e2e8f0; padding: 14px; border-radius: 10px; overflow-x: auto; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e0f2fe; color: #075985; font-size: 0.85rem; margin: 2px 4px 2px 0; }
.section { margin: 24px 0; }
.toolbar { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-bottom: 16px; }
input[type='text'] { padding: 8px; width: 280px; max-width: 100%; }
button { padding: 8px 12px; }
.pagination { margin-top: 16px; display: flex; gap: 12px; }
.summary-grid { display: grid; grid-template-columns: 220px 1fr; gap: 8px 16px; }
.summary-grid div { padding: 4px 0; }
details { margin: 12px 0; }
a { color: #0f766e; }
</style>
"""
return HTMLResponse(f"<!doctype html><html><head><meta charset='utf-8'><title>{escape(title)}</title>{css}</head><body>{nav}<h1>{escape(title)}</h1>{body}</body></html>")
def pretty_json(value):
try:
if isinstance(value, str):
parsed = json.loads(value)
else:
parsed = value
return json.dumps(parsed, indent=2, ensure_ascii=False)
except Exception:
return str(value)
def parse_json_field(value, default=None):
if value is None:
return default
try:
return json.loads(value)
except Exception:
return default
def event_summary(payload):
data = payload.get("data", {}) if isinstance(payload, dict) else {}
related = payload.get("related", {}) if isinstance(payload, dict) else {}
company = related.get("company", {}) if isinstance(related, dict) else {}
job_number = data.get("generated_job_id", "")
purchase_order_number = data.get("purchase_order_number", "")
return {
"event_type": payload.get("type", "") if isinstance(payload, dict) else "",
"uuid": data.get("uuid", ""),
"generated_job_id": job_number,
"job_number": job_number,
"purchase_order_number": purchase_order_number,
"status": data.get("status", ""),
"edit_date": data.get("edit_date", ""),
"company_name": company.get("name", ""),
}
def format_pills(values):
if not values:
return ""
return " ".join(f"<span class='pill'>{escape(str(v))}</span>" for v in values)
def link_with_params(path, **params):
filtered = {k: v for k, v in params.items() if v not in (None, "")}
return f"{path}?{urlencode(filtered)}" if filtered else path
@app.get("/health")
def health():
return {"ok": True, "db_path": DB_PATH}
@app.get("/", response_class=HTMLResponse)
def dashboard():
with closing(get_conn()) as conn:
counts = {
"webhook_events": conn.execute("select count(*) from webhook_events").fetchone()[0],
"webhook_objects": conn.execute("select count(*) from webhook_objects").fetchone()[0],
"webhook_form_responses": conn.execute("select count(*) from webhook_form_responses").fetchone()[0],
}
latest_event = conn.execute("select received_at from webhook_events order by id desc limit 1").fetchone()
latest_object = conn.execute("select received_at from webhook_objects order by id desc limit 1").fetchone()
latest_form = conn.execute("select received_at from webhook_form_responses order by id desc limit 1").fetchone()
cards = "".join(
f"<div class='card'><div class='muted'>{escape(name)}</div><div class='big'>{count}</div></div>"
for name, count in counts.items()
)
body = f"""
<div class='cards'>{cards}</div>
<div class='card'>
<div class='summary-grid'>
<div><strong>DB path</strong></div><div><code>{escape(DB_PATH)}</code></div>
<div><strong>Latest event</strong></div><div>{escape(latest_event[0] if latest_event else '')}</div>
<div><strong>Latest object</strong></div><div>{escape(latest_object[0] if latest_object else '')}</div>
<div><strong>Latest form response</strong></div><div>{escape(latest_form[0] if latest_form else '')}</div>
</div>
</div>
<div class='section'>
<h2>Quick links</h2>
<ul>
<li><a href='/events'>Browse webhook events</a></li>
<li><a href='/objects'>Browse object webhooks</a></li>
<li><a href='/form-responses'>Browse form responses</a></li>
</ul>
</div>
"""
return html_page("ServiceM8 Inspector", body)
@app.get("/events", response_class=HTMLResponse)
def list_events(q: str = Query(""), page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
with closing(get_conn()) as conn:
rows = conn.execute(
"select id, received_at, payload_json, path from webhook_events order by id desc limit ? offset ?",
(PAGE_SIZE, offset),
).fetchall()
filtered = []
q_lower = q.lower().strip()
q_digits = "".join(ch for ch in q if ch.isdigit())
for row in rows:
payload = parse_json_field(row["payload_json"], {}) or {}
summary = event_summary(payload)
job_number = str(summary.get("job_number", "") or "")
purchase_order_number = str(summary.get("purchase_order_number", "") or "")
haystack = " ".join(str(v) for v in summary.values()).lower() + " " + str(payload).lower()
job_match = bool(q_digits) and (q_digits in job_number or q_digits in purchase_order_number)
if q_lower and not job_match and q_lower not in haystack:
continue
filtered.append((row, payload, summary))
table_rows = []
for row, payload, summary in filtered:
table_rows.append(
f"<tr>"
f"<td><a href='/events/{row['id']}'>{row['id']}</a></td>"
f"<td>{escape(row['received_at'])}</td>"
f"<td>{escape(summary['event_type'])}</td>"
f"<td>{escape(summary['generated_job_id'])}</td>"
f"<td>{escape(summary['uuid'])}</td>"
f"<td>{escape(summary['status'])}</td>"
f"<td>{escape(summary['company_name'])}</td>"
f"<td>{escape(row['path'])}</td>"
f"</tr>"
)
body = f"""
<div class='toolbar'>
<form method='get'>
<label>Search <input type='text' name='q' value='{escape(q)}' placeholder='job number, uuid, status, company, type'></label>
<input type='hidden' name='page' value='1'>
<button type='submit'>Filter</button>
</form>
<div class='muted'>Search supports job number / generated job ID, UUID, status, company, or event type. Showing up to {PAGE_SIZE} latest rows, then filtering in-app.</div>
</div>
<table>
<thead><tr><th>ID</th><th>Received</th><th>Type</th><th>Job ID</th><th>UUID</th><th>Status</th><th>Company</th><th>Path</th></tr></thead>
<tbody>{''.join(table_rows) or "<tr><td colspan='8'>No rows matched.</td></tr>"}</tbody>
</table>
<div class='pagination'>
{f"<a href='{link_with_params('/events', q=q, page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/events', q=q, page=page+1)}'>Next →</a>
</div>
"""
return html_page("Webhook events", body)
@app.get("/events/{event_id}", response_class=HTMLResponse)
def event_detail(event_id: int):
with closing(get_conn()) as conn:
row = conn.execute("select * from webhook_events where id = ?", (event_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Event not found")
payload = parse_json_field(row["payload_json"], {}) or {}
headers = parse_json_field(row["headers_json"], {}) or {}
summary = event_summary(payload)
related = payload.get("related", {}) if isinstance(payload, dict) else {}
company = related.get("company", {}) if isinstance(related, dict) else {}
contacts = related.get("jobContacts", []) if isinstance(related, dict) else []
materials = related.get("jobMaterials", []) if isinstance(related, dict) else []
body = f"""
<div class='card summary-grid'>
<div><strong>ID</strong></div><div>{row['id']}</div>
<div><strong>Received</strong></div><div>{escape(row['received_at'])}</div>
<div><strong>Event type</strong></div><div>{escape(summary['event_type'])}</div>
<div><strong>UUID</strong></div><div>{escape(summary['uuid'])}</div>
<div><strong>Generated job ID</strong></div><div>{escape(summary['generated_job_id'])}</div>
<div><strong>Status</strong></div><div>{escape(summary['status'])}</div>
<div><strong>Company</strong></div><div>{escape(summary['company_name'])}</div>
<div><strong>Path</strong></div><div>{escape(row['path'])}</div>
</div>
<div class='section'>
<h2>Related summary</h2>
<div class='card summary-grid'>
<div><strong>Company name</strong></div><div>{escape(company.get('name', ''))}</div>
<div><strong>Contacts</strong></div><div>{len(contacts)}</div>
<div><strong>Materials</strong></div><div>{len(materials)}</div>
</div>
</div>
<div class='section'>
<h2>Headers</h2>
<pre>{escape(pretty_json(headers))}</pre>
</div>
<div class='section'>
<h2>Payload</h2>
<pre>{escape(pretty_json(payload))}</pre>
</div>
"""
return html_page(f"Event {event_id}", body)
@app.get("/objects", response_class=HTMLResponse)
def list_objects(page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
with closing(get_conn()) as conn:
rows = conn.execute(
"select id, received_at, object_type, object_uuid, changed_fields_json, object_time_utc, resource_url from webhook_objects order by id desc limit ? offset ?",
(PAGE_SIZE, offset),
).fetchall()
table_rows = []
for row in rows:
changed = parse_json_field(row["changed_fields_json"], []) or []
table_rows.append(
f"<tr>"
f"<td><a href='/objects/{row['id']}'>{row['id']}</a></td>"
f"<td>{escape(row['received_at'])}</td>"
f"<td>{escape(row['object_type'] or '')}</td>"
f"<td>{escape(row['object_uuid'] or '')}</td>"
f"<td>{format_pills(changed[:8])}</td>"
f"<td>{escape(row['object_time_utc'] or '')}</td>"
f"<td><a href='{escape(row['resource_url'] or '')}'>{escape(row['resource_url'] or '')}</a></td>"
f"</tr>"
)
body = f"""
<table>
<thead><tr><th>ID</th><th>Received</th><th>Object</th><th>UUID</th><th>Changed fields</th><th>Object time</th><th>Resource URL</th></tr></thead>
<tbody>{''.join(table_rows) or "<tr><td colspan='7'>No rows found.</td></tr>"}</tbody>
</table>
<div class='pagination'>
{f"<a href='{link_with_params('/objects', page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/objects', page=page+1)}'>Next →</a>
</div>
"""
return html_page("Object webhooks", body)
@app.get("/objects/{object_id}", response_class=HTMLResponse)
def object_detail(object_id: int):
with closing(get_conn()) as conn:
row = conn.execute("select * from webhook_objects where id = ?", (object_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Object row not found")
payload = parse_json_field(row["payload_json"], {}) or {}
headers = parse_json_field(row["headers_json"], {}) or {}
changed = parse_json_field(row["changed_fields_json"], []) or []
body = f"""
<div class='card summary-grid'>
<div><strong>ID</strong></div><div>{row['id']}</div>
<div><strong>Received</strong></div><div>{escape(row['received_at'])}</div>
<div><strong>Object type</strong></div><div>{escape(row['object_type'] or '')}</div>
<div><strong>Object UUID</strong></div><div>{escape(row['object_uuid'] or '')}</div>
<div><strong>Object time UTC</strong></div><div>{escape(row['object_time_utc'] or '')}</div>
<div><strong>Resource URL</strong></div><div><a href='{escape(row['resource_url'] or '')}'>{escape(row['resource_url'] or '')}</a></div>
<div><strong>Changed fields</strong></div><div>{format_pills(changed)}</div>
</div>
<div class='section'><h2>Headers</h2><pre>{escape(pretty_json(headers))}</pre></div>
<div class='section'><h2>Payload</h2><pre>{escape(pretty_json(payload))}</pre></div>
"""
return html_page(f"Object webhook {object_id}", body)
@app.get("/form-responses", response_class=HTMLResponse)
def list_form_responses(page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
with closing(get_conn()) as conn:
rows = conn.execute(
"select id, received_at, payload_json from webhook_form_responses order by id desc limit ? offset ?",
(PAGE_SIZE, offset),
).fetchall()
table_rows = []
for row in rows:
payload = parse_json_field(row["payload_json"], {}) or {}
data = payload.get("data", {}) if isinstance(payload, dict) else {}
related = payload.get("related", {}) if isinstance(payload, dict) else {}
job = related.get("job", {}) if isinstance(related, dict) else {}
table_rows.append(
f"<tr>"
f"<td><a href='/form-responses/{row['id']}'>{row['id']}</a></td>"
f"<td>{escape(row['received_at'])}</td>"
f"<td>{escape(data.get('form_uuid', ''))}</td>"
f"<td>{escape(data.get('regarding_object', ''))}</td>"
f"<td>{escape(data.get('regarding_object_uuid', ''))}</td>"
f"<td>{escape(job.get('generated_job_id', ''))}</td>"
f"<td>{escape(job.get('status', ''))}</td>"
f"</tr>"
)
body = f"""
<table>
<thead><tr><th>ID</th><th>Received</th><th>Form UUID</th><th>Regarding</th><th>Object UUID</th><th>Job ID</th><th>Job status</th></tr></thead>
<tbody>{''.join(table_rows) or "<tr><td colspan='7'>No rows found.</td></tr>"}</tbody>
</table>
<div class='pagination'>
{f"<a href='{link_with_params('/form-responses', page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/form-responses', page=page+1)}'>Next →</a>
</div>
"""
return html_page("Form responses", body)
@app.get("/form-responses/{row_id}", response_class=HTMLResponse)
def form_response_detail(row_id: int):
with closing(get_conn()) as conn:
row = conn.execute("select * from webhook_form_responses where id = ?", (row_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Form response not found")
payload = parse_json_field(row["payload_json"], {}) or {}
headers = parse_json_field(row["headers_json"], {}) or {}
data = payload.get("data", {}) if isinstance(payload, dict) else {}
related = payload.get("related", {}) if isinstance(payload, dict) else {}
job = related.get("job", {}) if isinstance(related, dict) else {}
field_data_raw = data.get("field_data", "[]")
field_data = parse_json_field(field_data_raw, []) or []
field_rows = []
for item in sorted(field_data, key=lambda x: x.get("SortOrder", 0)):
field_rows.append(
f"<tr>"
f"<td>{escape(str(item.get('SortOrder', '')))}</td>"
f"<td>{escape(item.get('FieldType', ''))}</td>"
f"<td>{escape(item.get('Question', ''))}</td>"
f"<td>{escape(item.get('Response', ''))}</td>"
f"</tr>"
)
body = f"""
<div class='card summary-grid'>
<div><strong>ID</strong></div><div>{row['id']}</div>
<div><strong>Received</strong></div><div>{escape(row['received_at'])}</div>
<div><strong>Form UUID</strong></div><div>{escape(data.get('form_uuid', ''))}</div>
<div><strong>Regarding object</strong></div><div>{escape(data.get('regarding_object', ''))}</div>
<div><strong>Regarding UUID</strong></div><div>{escape(data.get('regarding_object_uuid', ''))}</div>
<div><strong>Timestamp</strong></div><div>{escape(data.get('timestamp', ''))}</div>
<div><strong>Job ID</strong></div><div>{escape(job.get('generated_job_id', ''))}</div>
<div><strong>Job status</strong></div><div>{escape(job.get('status', ''))}</div>
</div>
<div class='section'>
<h2>Decoded field data</h2>
<table>
<thead><tr><th>Order</th><th>Type</th><th>Question</th><th>Response</th></tr></thead>
<tbody>{''.join(field_rows) or "<tr><td colspan='4'>No decoded field data.</td></tr>"}</tbody>
</table>
</div>
<div class='section'><h2>Related job</h2><pre>{escape(pretty_json(job))}</pre></div>
<div class='section'><h2>Headers</h2><pre>{escape(pretty_json(headers))}</pre></div>
<div class='section'><h2>Payload</h2><pre>{escape(pretty_json(payload))}</pre></div>
"""
return html_page(f"Form response {row_id}", body)
if __name__ == "__main__":
import uvicorn
uvicorn.run("servicem8_inspector:app", host=APP_HOST, port=APP_PORT, reload=False)