890 lines
44 KiB
Python
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)
|