Files
plumbing/servicem8_inspector.py
T
2026-05-04 13:14:44 +10:00

890 lines
44 KiB
Python

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")
STATE_DB_PATH = os.getenv("WEBHOOK_STATE_DB_PATH", "./servicem8_quote_materials_state.db")
POLL_DB_PATH = os.getenv("WEBHOOK_POLL_DB_PATH", "./servicem8_formresponse_poll.db")
APP_HOST = os.getenv("WEBHOOK_INSPECTOR_HOST", "0.0.0.0")
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 get_state_conn():
conn = sqlite3.connect(STATE_DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def get_poll_conn():
conn = sqlite3.connect(POLL_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'>Webhook form responses</a>
<a href='/poll/form-responses'>Polled form responses</a>
<a href='/poll/quote-template'>Polled quote templates</a>
<a href='/poll/apply-runs'>Apply runs</a>
<a href='/generated-materials'>Generated materials</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, "state_db_path": STATE_DB_PATH, "poll_db_path": POLL_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()
state_count = 0
latest_generated = None
try:
with closing(get_state_conn()) as conn:
state_count = conn.execute("select count(*) from generated_job_materials").fetchone()[0]
latest_generated = conn.execute("select updated_at from generated_job_materials order by id desc limit 1").fetchone()
except sqlite3.Error:
pass
poll_counts = {
"poll_runs": 0,
"polled_form_responses": 0,
"polled_quote_templates": 0,
}
latest_poll_run = None
latest_polled_form = None
latest_polled_quote = None
try:
with closing(get_poll_conn()) as conn:
poll_counts["poll_runs"] = conn.execute("select count(*) from poll_runs").fetchone()[0]
poll_counts["polled_form_responses"] = conn.execute("select count(*) from form_responses_raw").fetchone()[0]
poll_counts["polled_quote_templates"] = conn.execute("select count(*) from quote_template_form_responses").fetchone()[0]
latest_poll_run = conn.execute("select finished_at from poll_runs order by id desc limit 1").fetchone()
latest_polled_form = conn.execute("select timestamp from form_responses_raw order by timestamp desc limit 1").fetchone()
latest_polled_quote = conn.execute("select discovered_at from quote_template_form_responses order by discovered_at desc limit 1").fetchone()
except sqlite3.Error:
pass
counts["generated_job_materials"] = state_count
counts.update(poll_counts)
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>State DB path</strong></div><div><code>{escape(STATE_DB_PATH)}</code></div>
<div><strong>Poll DB path</strong></div><div><code>{escape(POLL_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><strong>Latest generated material row</strong></div><div>{escape(latest_generated[0] if latest_generated else '')}</div>
<div><strong>Latest poll run</strong></div><div>{escape(latest_poll_run[0] if latest_poll_run else '')}</div>
<div><strong>Latest polled form timestamp</strong></div><div>{escape(latest_polled_form[0] if latest_polled_form else '')}</div>
<div><strong>Latest polled quote discovered</strong></div><div>{escape(latest_polled_quote[0] if latest_polled_quote 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 webhook form responses</a></li>
<li><a href='/poll/form-responses'>Browse polled form responses</a></li>
<li><a href='/poll/quote-template'>Browse parsed polled Quote Template responses</a></li>
<li><a href='/poll/runs'>Browse poll runs</a></li>
<li><a href='/poll/apply-runs'>Browse dry-run/apply runs</a></li>
<li><a href='/generated-materials'>Browse generated job-material state</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("/generated-materials", response_class=HTMLResponse)
def list_generated_materials(page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
try:
with closing(get_state_conn()) as conn:
rows = conn.execute(
"select id, job_uuid, form_response_uuid, job_material_uuid, kind, source_question, source_text, updated_at from generated_job_materials order by id desc limit ? offset ?",
(PAGE_SIZE, offset),
).fetchall()
except sqlite3.Error as e:
return html_page("Generated materials", f"<div class='card'>State DB unavailable: {escape(str(e))}</div>")
table_rows = []
for row in rows:
table_rows.append(
f"<tr>"
f"<td><a href='/generated-materials/{row['id']}'>{row['id']}</a></td>"
f"<td>{escape(row['job_uuid'] or '')}</td>"
f"<td>{escape(row['form_response_uuid'] or '')}</td>"
f"<td>{escape(row['job_material_uuid'] or '')}</td>"
f"<td>{escape(row['kind'] or '')}</td>"
f"<td>{escape(row['source_question'] or '')}</td>"
f"<td>{escape(row['updated_at'] or '')}</td>"
f"</tr>"
)
body = f"""
<table>
<thead><tr><th>ID</th><th>Job UUID</th><th>Form response UUID</th><th>Job material UUID</th><th>Kind</th><th>Source question</th><th>Updated</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('/generated-materials', page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/generated-materials', page=page+1)}'>Next →</a>
</div>
"""
return html_page("Generated materials", body)
@app.get("/generated-materials/{row_id}", response_class=HTMLResponse)
def generated_material_detail(row_id: int):
try:
with closing(get_state_conn()) as conn:
row = conn.execute("select * from generated_job_materials where id = ?", (row_id,)).fetchone()
except sqlite3.Error as e:
return html_page("Generated material detail", f"<div class='card'>State DB unavailable: {escape(str(e))}</div>")
if not row:
raise HTTPException(status_code=404, detail="Generated material row not found")
body = f"""
<div class='card summary-grid'>
<div><strong>ID</strong></div><div>{row['id']}</div>
<div><strong>Job UUID</strong></div><div>{escape(row['job_uuid'] or '')}</div>
<div><strong>Form response UUID</strong></div><div>{escape(row['form_response_uuid'] or '')}</div>
<div><strong>Job material UUID</strong></div><div>{escape(row['job_material_uuid'] or '')}</div>
<div><strong>Kind</strong></div><div>{escape(row['kind'] or '')}</div>
<div><strong>Source field UUID</strong></div><div>{escape(row['source_field_uuid'] or '')}</div>
<div><strong>Source question</strong></div><div>{escape(row['source_question'] or '')}</div>
<div><strong>Source text</strong></div><div>{escape(row['source_text'] or '')}</div>
<div><strong>Created</strong></div><div>{escape(row['created_at'] or '')}</div>
<div><strong>Updated</strong></div><div>{escape(row['updated_at'] or '')}</div>
</div>
"""
return html_page(f"Generated material {row_id}", 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)
@app.get("/poll/apply-runs", response_class=HTMLResponse)
def list_apply_runs(page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
try:
with closing(get_poll_conn()) as conn:
rows = conn.execute(
"""
select id, form_response_uuid, job_uuid, mode, started_at, finished_at,
desired_count, created_count, status, error
from quote_template_apply_runs
order by id desc
limit ? offset ?
""",
(PAGE_SIZE, offset),
).fetchall()
except sqlite3.Error as e:
return html_page("Apply runs", f"<div class='card'>Apply-run table unavailable: {escape(str(e))}</div>")
table_rows = []
for row in rows:
table_rows.append(
f"<tr><td><a href='/poll/apply-runs/{row['id']}'>{row['id']}</a></td>"
f"<td>{escape(row['mode'] or '')}</td><td>{escape(row['status'] or '')}</td>"
f"<td><a href='/poll/quote-template/{escape(row['form_response_uuid'])}'>{escape(row['form_response_uuid'])}</a></td>"
f"<td>{escape(row['job_uuid'] or '')}</td><td>{escape(row['started_at'] or '')}</td><td>{escape(row['finished_at'] or '')}</td>"
f"<td>{row['desired_count']}</td><td>{row['created_count']}</td><td>{escape((row['error'] or '')[:160])}</td></tr>"
)
body = f"""
<table>
<thead><tr><th>ID</th><th>Mode</th><th>Status</th><th>Form response UUID</th><th>Job UUID</th><th>Started</th><th>Finished</th><th>Desired</th><th>Created</th><th>Error</th></tr></thead>
<tbody>{''.join(table_rows) or "<tr><td colspan='10'>No apply runs found yet.</td></tr>"}</tbody>
</table>
<div class='pagination'>
{f"<a href='{link_with_params('/poll/apply-runs', page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/poll/apply-runs', page=page+1)}'>Next →</a>
</div>
"""
return html_page("Apply runs", body)
@app.get("/poll/apply-runs/{run_id}", response_class=HTMLResponse)
def apply_run_detail(run_id: int):
try:
with closing(get_poll_conn()) as conn:
run = conn.execute("select * from quote_template_apply_runs where id = ?", (run_id,)).fetchone()
rows = conn.execute(
"select * from quote_template_apply_run_rows where run_id = ? order by row_index asc",
(run_id,),
).fetchall()
except sqlite3.Error as e:
return html_page("Apply run", f"<div class='card'>Apply-run table unavailable: {escape(str(e))}</div>")
if not run:
raise HTTPException(status_code=404, detail="Apply run not found")
table_rows = []
for row in rows:
payload = parse_json_field(row['api_payload_json'], {}) or {}
table_rows.append(
f"<tr><td>{row['row_index']}</td><td>{escape(row['action'] or '')}</td><td>{escape(row['kind'] or '')}</td>"
f"<td>{escape(row['name'] or '')}</td><td>{escape(row['job_material_uuid'] or '')}</td>"
f"<td>{escape(row['source_question'] or '')}</td><td>{escape(row['error'] or '')}</td></tr>"
f"<tr><td></td><td colspan='6'><details><summary>API payload</summary><pre>{escape(pretty_json(payload))}</pre></details></td></tr>"
)
body = f"""
<div class='card summary-grid'>
<div><strong>Run ID</strong></div><div>{run['id']}</div>
<div><strong>Mode</strong></div><div>{escape(run['mode'] or '')}</div>
<div><strong>Status</strong></div><div>{escape(run['status'] or '')}</div>
<div><strong>Form response UUID</strong></div><div><a href='/poll/quote-template/{escape(run['form_response_uuid'])}'>{escape(run['form_response_uuid'])}</a></div>
<div><strong>Job UUID</strong></div><div>{escape(run['job_uuid'] or '')}</div>
<div><strong>Started</strong></div><div>{escape(run['started_at'] or '')}</div>
<div><strong>Finished</strong></div><div>{escape(run['finished_at'] or '')}</div>
<div><strong>Desired</strong></div><div>{run['desired_count']}</div>
<div><strong>Created</strong></div><div>{run['created_count']}</div>
<div><strong>Error</strong></div><div>{escape(run['error'] or '')}</div>
</div>
<div class='section'><h2>Rows</h2><table><thead><tr><th>#</th><th>Action</th><th>Kind</th><th>Name</th><th>Created UUID</th><th>Source question</th><th>Error</th></tr></thead><tbody>{''.join(table_rows) or "<tr><td colspan='7'>No rows recorded.</td></tr>"}</tbody></table></div>
"""
return html_page(f"Apply run {run_id}", body)
@app.get("/poll/runs", response_class=HTMLResponse)
def list_poll_runs(page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
try:
with closing(get_poll_conn()) as conn:
rows = conn.execute(
"""
select id, started_at, finished_at, since_value, filter_field,
fetched_count, inserted_count, updated_count,
quote_match_count, newly_queued_count, error
from poll_runs order by id desc limit ? offset ?
""",
(PAGE_SIZE, offset),
).fetchall()
except sqlite3.Error as e:
return html_page("Poll runs", f"<div class='card'>Poll DB unavailable: {escape(str(e))}</div>")
table_rows = []
for row in rows:
table_rows.append(
f"<tr><td>{row['id']}</td><td>{escape(row['started_at'] or '')}</td>"
f"<td>{escape(row['finished_at'] or '')}</td>"
f"<td>{escape(row['filter_field'] or '')} gt {escape(row['since_value'] or '')}</td>"
f"<td>{row['fetched_count']}</td><td>{row['inserted_count']}</td><td>{row['updated_count']}</td>"
f"<td>{row['quote_match_count']}</td><td>{row['newly_queued_count']}</td>"
f"<td>{escape((row['error'] or '')[:180])}</td></tr>"
)
body = f"""
<table>
<thead><tr><th>ID</th><th>Started</th><th>Finished</th><th>Filter</th><th>Fetched</th><th>Inserted</th><th>Updated</th><th>Quote matches</th><th>Queued</th><th>Error</th></tr></thead>
<tbody>{''.join(table_rows) or "<tr><td colspan='10'>No poll runs found.</td></tr>"}</tbody>
</table>
<div class='pagination'>
{f"<a href='{link_with_params('/poll/runs', page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/poll/runs', page=page+1)}'>Next →</a>
</div>
"""
return html_page("Poll runs", body)
@app.get("/poll/form-responses", response_class=HTMLResponse)
def list_polled_form_responses(q: str = Query(""), quote_only: int = Query(0), page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
clauses = []
params = []
if quote_only:
clauses.append("is_quote_template = 1")
if q.strip():
like = f"%{q.strip()}%"
clauses.append("(uuid like ? or form_uuid like ? or regarding_object_uuid like ? or timestamp like ? or edit_date like ? or raw_json like ?)")
params.extend([like, like, like, like, like, like])
where = " where " + " and ".join(clauses) if clauses else ""
try:
with closing(get_poll_conn()) as conn:
rows = conn.execute(
f"""
select uuid, first_seen_at, last_seen_at, seen_count, timestamp, edit_date,
form_uuid, regarding_object, regarding_object_uuid,
is_quote_template, parse_status, parse_error
from form_responses_raw {where}
order by timestamp desc, edit_date desc limit ? offset ?
""",
(*params, PAGE_SIZE, offset),
).fetchall()
except sqlite3.Error as e:
return html_page("Polled form responses", f"<div class='card'>Poll DB unavailable: {escape(str(e))}</div>")
table_rows = []
for row in rows:
quote_pill = "<span class='pill'>Quote Template</span>" if row["is_quote_template"] else ""
table_rows.append(
f"<tr><td><a href='/poll/form-responses/{escape(row['uuid'])}'>{escape(row['uuid'])}</a></td>"
f"<td>{escape(row['timestamp'] or '')}</td><td>{escape(row['edit_date'] or '')}</td>"
f"<td>{escape(row['form_uuid'] or '')}<br>{quote_pill}</td>"
f"<td>{escape(row['regarding_object'] or '')}</td><td>{escape(row['regarding_object_uuid'] or '')}</td>"
f"<td>{escape(row['parse_status'] or '')}</td><td>{row['seen_count']}</td><td>{escape(row['last_seen_at'] or '')}</td></tr>"
)
body = f"""
<div class='toolbar'>
<form method='get'>
<label>Search <input type='text' name='q' value='{escape(q)}' placeholder='uuid, job uuid, form uuid, timestamp'></label>
<label><input type='checkbox' name='quote_only' value='1' {'checked' if quote_only else ''}> Quote Template only</label>
<input type='hidden' name='page' value='1'>
<button type='submit'>Filter</button>
</form>
</div>
<table>
<thead><tr><th>UUID</th><th>Timestamp</th><th>Edit date</th><th>Form UUID</th><th>Regarding</th><th>Object UUID</th><th>Parse</th><th>Seen</th><th>Last seen</th></tr></thead>
<tbody>{''.join(table_rows) or "<tr><td colspan='9'>No rows found.</td></tr>"}</tbody>
</table>
<div class='pagination'>
{f"<a href='{link_with_params('/poll/form-responses', q=q, quote_only=quote_only, page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/poll/form-responses', q=q, quote_only=quote_only, page=page+1)}'>Next →</a>
</div>
"""
return html_page("Polled form responses", body)
@app.get("/poll/form-responses/{form_response_uuid}", response_class=HTMLResponse)
def polled_form_response_detail(form_response_uuid: str):
try:
with closing(get_poll_conn()) as conn:
row = conn.execute("select * from form_responses_raw where uuid = ?", (form_response_uuid,)).fetchone()
quote = conn.execute("select * from quote_template_form_responses where form_response_uuid = ?", (form_response_uuid,)).fetchone()
except sqlite3.Error as e:
return html_page("Polled form response", f"<div class='card'>Poll DB unavailable: {escape(str(e))}</div>")
if not row:
raise HTTPException(status_code=404, detail="Polled form response not found")
raw = parse_json_field(row["raw_json"], {}) or {}
field_data = parse_json_field(raw.get("field_data"), []) or []
field_rows = []
if isinstance(field_data, list):
for item in sorted(field_data, key=lambda x: x.get("SortOrder", 0) if isinstance(x, dict) else 0):
if isinstance(item, dict):
field_rows.append(
f"<tr><td>{escape(str(item.get('SortOrder', '')))}</td><td>{escape(item.get('FieldType', ''))}</td>"
f"<td>{escape(item.get('Question', ''))}</td><td>{escape(item.get('Response', ''))}</td></tr>"
)
quote_link = f"<div><strong>Parsed quote</strong></div><div><a href='/poll/quote-template/{escape(form_response_uuid)}'>Open parsed Quote Template view</a></div>" if quote else ""
body = f"""
<div class='card summary-grid'>
<div><strong>UUID</strong></div><div>{escape(row['uuid'])}</div>
<div><strong>Timestamp</strong></div><div>{escape(row['timestamp'] or '')}</div>
<div><strong>Edit date</strong></div><div>{escape(row['edit_date'] or '')}</div>
<div><strong>Form UUID</strong></div><div>{escape(row['form_uuid'] or '')}</div>
<div><strong>Regarding object</strong></div><div>{escape(row['regarding_object'] or '')}</div>
<div><strong>Regarding UUID</strong></div><div>{escape(row['regarding_object_uuid'] or '')}</div>
<div><strong>First seen</strong></div><div>{escape(row['first_seen_at'] or '')}</div>
<div><strong>Last seen</strong></div><div>{escape(row['last_seen_at'] or '')}</div>
<div><strong>Seen count</strong></div><div>{row['seen_count']}</div>
<div><strong>Parse status</strong></div><div>{escape(row['parse_status'] or '')}</div>
<div><strong>Parse error</strong></div><div>{escape(row['parse_error'] or '')}</div>
{quote_link}
</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>Raw API row</h2><pre>{escape(pretty_json(raw))}</pre></div>
"""
return html_page(f"Polled form response {form_response_uuid}", body)
@app.get("/poll/quote-template", response_class=HTMLResponse)
def list_polled_quote_templates(page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
try:
with closing(get_poll_conn()) as conn:
rows = conn.execute(
"""
select form_response_uuid, discovered_at, job_uuid, form_uuid, author_name,
description, desired_job_materials_json, queued_at, processed_at,
process_status, process_error
from quote_template_form_responses
order by discovered_at desc, form_response_uuid desc limit ? offset ?
""",
(PAGE_SIZE, offset),
).fetchall()
except sqlite3.Error as e:
return html_page("Polled Quote Template responses", f"<div class='card'>Poll DB unavailable: {escape(str(e))}</div>")
table_rows = []
for row in rows:
try:
material_count = len(json.loads(row["desired_job_materials_json"] or "[]"))
except Exception:
material_count = "?"
table_rows.append(
f"<tr><td><a href='/poll/quote-template/{escape(row['form_response_uuid'])}'>{escape(row['form_response_uuid'])}</a></td>"
f"<td>{escape(row['discovered_at'] or '')}</td><td>{escape(row['job_uuid'] or '')}</td>"
f"<td>{escape(row['description'] or '')}</td><td>{material_count}</td>"
f"<td>{escape(row['queued_at'] or '')}</td><td>{escape(row['process_status'] or '')}</td></tr>"
)
body = f"""
<table>
<thead><tr><th>Form response UUID</th><th>Discovered</th><th>Job UUID</th><th>Description</th><th>Rows</th><th>Queued</th><th>Status</th></tr></thead>
<tbody>{''.join(table_rows) or "<tr><td colspan='7'>No quote template rows found.</td></tr>"}</tbody>
</table>
<div class='pagination'>
{f"<a href='{link_with_params('/poll/quote-template', page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/poll/quote-template', page=page+1)}'>Next →</a>
</div>
"""
return html_page("Polled Quote Template responses", body)
@app.get("/poll/quote-template/{form_response_uuid}", response_class=HTMLResponse)
def polled_quote_template_detail(form_response_uuid: str):
try:
with closing(get_poll_conn()) as conn:
row = conn.execute("select * from quote_template_form_responses where form_response_uuid = ?", (form_response_uuid,)).fetchone()
except sqlite3.Error as e:
return html_page("Polled Quote Template response", f"<div class='card'>Poll DB unavailable: {escape(str(e))}</div>")
if not row:
raise HTTPException(status_code=404, detail="Polled quote template response not found")
parsed = parse_json_field(row["parsed_json"], {}) or {}
materials = parse_json_field(row["desired_job_materials_json"], []) or []
material_rows = []
for item in materials:
material_rows.append(
f"<tr><td>{escape(str(item.get('sort_order', '')))}</td><td>{escape(item.get('kind', ''))}</td>"
f"<td>{escape(item.get('name', ''))}</td><td>{escape(item.get('material_uuid', ''))}</td>"
f"<td>{escape(item.get('source_question', ''))}</td></tr>"
)
dry_run_cmd = f"/opt/webhooks/apply_polled_quote_template_jobmaterials.py --uuid {row['form_response_uuid']} --pretty"
apply_cmd = f"/opt/webhooks/apply_polled_quote_template_jobmaterials.py --uuid {row['form_response_uuid']} --apply --pretty"
try:
with closing(get_poll_conn()) as conn:
recent_runs = conn.execute(
"select id, mode, status, started_at, finished_at, desired_count, created_count, error from quote_template_apply_runs where form_response_uuid = ? order by id desc limit 8",
(row['form_response_uuid'],),
).fetchall()
except sqlite3.Error:
recent_runs = []
recent_run_rows = []
for run in recent_runs:
recent_run_rows.append(
f"<tr><td><a href='/poll/apply-runs/{run['id']}'>{run['id']}</a></td><td>{escape(run['mode'] or '')}</td><td>{escape(run['status'] or '')}</td><td>{escape(run['started_at'] or '')}</td><td>{escape(run['finished_at'] or '')}</td><td>{run['desired_count']}</td><td>{run['created_count']}</td><td>{escape((run['error'] or '')[:120])}</td></tr>"
)
body = f"""
<div class='card summary-grid'>
<div><strong>Form response UUID</strong></div><div>{escape(row['form_response_uuid'])}</div>
<div><strong>Discovered</strong></div><div>{escape(row['discovered_at'] or '')}</div>
<div><strong>Job UUID</strong></div><div>{escape(row['job_uuid'] or '')}</div>
<div><strong>Form UUID</strong></div><div>{escape(row['form_uuid'] or '')}</div>
<div><strong>Author</strong></div><div>{escape(row['author_name'] or '')}</div>
<div><strong>Description</strong></div><div>{escape(row['description'] or '')}</div>
<div><strong>Queued</strong></div><div>{escape(row['queued_at'] or '')}</div>
<div><strong>Processed</strong></div><div>{escape(row['processed_at'] or '')}</div>
<div><strong>Status</strong></div><div>{escape(row['process_status'] or '')}</div>
<div><strong>Error</strong></div><div>{escape(row['process_error'] or '')}</div>
<div><strong>Raw polled response</strong></div><div><a href='/poll/form-responses/{escape(row['form_response_uuid'])}'>Open raw polled form response</a></div>
</div>
<div class='section'>
<h2>Selective apply commands</h2>
<div class='card'>
<p><strong>Dry-run first:</strong></p>
<pre>{escape(dry_run_cmd)}</pre>
<p><strong>Apply to ServiceM8 only after checking the dry-run:</strong></p>
<pre>{escape(apply_cmd)}</pre>
</div>
</div>
<div class='section'>
<h2>Recent dry-run/apply runs for this response</h2>
<table><thead><tr><th>ID</th><th>Mode</th><th>Status</th><th>Started</th><th>Finished</th><th>Desired</th><th>Created</th><th>Error</th></tr></thead><tbody>{''.join(recent_run_rows) or "<tr><td colspan='8'>No dry-run/apply runs yet.</td></tr>"}</tbody></table>
</div>
<div class='section'><h2>Desired jobMaterial rows</h2><table><thead><tr><th>Sort</th><th>Kind</th><th>Name</th><th>Material UUID</th><th>Source question</th></tr></thead><tbody>{''.join(material_rows) or "<tr><td colspan='5'>No desired jobMaterial rows.</td></tr>"}</tbody></table></div>
<div class='section'><h2>Parsed JSON</h2><pre>{escape(pretty_json(parsed))}</pre></div>
"""
return html_page(f"Polled Quote Template {form_response_uuid}", body)
if __name__ == "__main__":
import uvicorn
uvicorn.run("servicem8_inspector:app", host=APP_HOST, port=APP_PORT, reload=False)