Add work order details display and asset table WO column

- Backend: new /api/admin/workorders/by-asset endpoint for
  work orders linked to a specific asset name
- Backend: enhanced WO search to also match by
  msdyn_customerasset!name and msdyn_serviceaccount!name
- Frontend: added 🔧 column to asset table linking to
  work orders tab pre-filtered by asset name
- Frontend: added showAssetWorkOrders() function
- Frontend: made asset name in WO detail modal clickable
  (navigates to asset search)

Closes #21
This commit is contained in:
Leo
2026-05-31 23:54:17 -04:00
parent 7024b5f820
commit ed5e3ff9ec
2 changed files with 1837 additions and 2 deletions
+621 -1
View File
@@ -11,6 +11,7 @@ Designed to run on a separate port from the main field-worker server.
"""
import csv
import hashlib
import httpx
import io
import json as _json
import os
@@ -406,7 +407,7 @@ async def auth_middleware(request: Request, call_next):
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
return await call_next(request)
if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business"):
if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business") or path.startswith("/api/admin/reports/"):
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
@@ -2240,6 +2241,625 @@ def get_replacement_history():
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# WORK ORDER REPORTS — reads extraction DB directly
# ═══════════════════════════════════════════════════════════════════════════
EXTRACTION_PROJECT = Path.home() / "projects" / "ms-field-service-extraction"
EXTRACTION_DB = (
EXTRACTION_PROJECT
/ "data-samples"
/ "msfs_backup"
/ "fieldservicecanteen.crm.dynamics.com_9fc6c50c-b097-f011-b4cb-7ced8d1b15a2_data.db"
)
STATUS_LABELS_REPORT = {
"690970000": "Unscheduled",
"690970001": "Scheduled",
"690970002": "In Progress",
"690970003": "Completed",
"690970004": "Posted",
"690970005": "Cancelled",
"690959000": "Postponed",
}
def _get_extraction_db_report() -> sqlite3.Connection | None:
"""Return read-only connection to the MS Field Service extraction DB."""
if not EXTRACTION_DB.exists():
return None
conn = sqlite3.connect(str(EXTRACTION_DB))
conn.row_factory = sqlite3.Row
return conn
def _status_label_report(code) -> str:
return STATUS_LABELS_REPORT.get(str(code), f"Unknown ({code})")
@app.get("/api/admin/reports/workorder-kpis")
async def reports_workorder_kpis(
date_from: str = Query("", description="Start date ISO (YYYY-MM-DD)"),
date_to: str = Query("", description="End date ISO (YYYY-MM-DD)"),
technician: str = Query("", description="Filter by technician name"),
status: str = Query("", description="Filter by status code (comma-separated)"),
):
"""
Return aggregated KPI data for field tech work orders.
Filters: date_from, date_to (on createdon), technician, status.
"""
conn = _get_extraction_db_report()
if not conn:
raise HTTPException(503, "Extraction DB not available")
try:
cur = conn.cursor()
# ── Build WHERE clause ──
where_parts = []
params = []
if date_from:
where_parts.append("w.createdon >= ?")
params.append(date_from)
if date_to:
where_parts.append("w.createdon < date(?, '+1 day')")
params.append(date_to)
if technician:
tech_names = [t.strip() for t in technician.split(",") if t.strip()]
if tech_names:
tech_conditions = []
for name in tech_names:
like = f"%{name}%"
tech_conditions.append(
"(COALESCE(w.\"hsl_bookedresource1!name\",'') LIKE ? "
"OR COALESCE(w.\"hsl_bookedresource2!name\",'') LIKE ? "
"OR COALESCE(w.\"hsl_bookedresource3!name\",'') LIKE ? "
"OR EXISTS (SELECT 1 FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" LIKE ?))"
)
params.extend([like, like, like, like])
where_parts.append("(" + " OR ".join(tech_conditions) + ")")
if status:
codes = [s.strip() for s in status.split(",") if s.strip()]
if codes:
placeholders = ", ".join(["?"] * len(codes))
where_parts.append(f"w.msdyn_systemstatus IN ({placeholders})")
params.extend(codes)
where_sql = " AND ".join(where_parts) if where_parts else "1=1"
# ── KPI 1: Total WOs ──
cur.execute(
f"SELECT COUNT(*) FROM msdyn_workorder w WHERE {where_sql}", params
)
total_workorders = cur.fetchone()[0]
# ── KPI 2: By Status ──
cur.execute(
f"""
SELECT w.msdyn_systemstatus, COUNT(*) as cnt
FROM msdyn_workorder w
WHERE {where_sql}
GROUP BY w.msdyn_systemstatus
ORDER BY cnt DESC
""",
params,
)
by_status_raw = cur.fetchall()
by_status = []
for r in by_status_raw:
code = str(r["msdyn_systemstatus"])
by_status.append({
"code": code,
"label": _status_label_report(code),
"count": r["cnt"],
})
# ── KPI 3: WOs Over Time (daily) ──
cur.execute(
f"""
SELECT date(w.createdon) as day, COUNT(*) as cnt
FROM msdyn_workorder w
WHERE {where_sql}
GROUP BY day
ORDER BY day
""",
params,
)
over_time = [{"day": r["day"], "count": r["cnt"]} for r in cur.fetchall()]
# ── KPI 4: By Technician ──
cur.execute(
f"""
SELECT t.technician, COUNT(*) as cnt
FROM (
SELECT COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
) AS technician
FROM msdyn_workorder w
WHERE {where_sql}
) t
WHERE t.technician IS NOT NULL AND t.technician != ''
GROUP BY t.technician
ORDER BY cnt DESC
LIMIT 30
""",
params,
)
by_technician = [{"name": r["technician"], "count": r["cnt"]} for r in cur.fetchall()]
# ── KPI 5: By Work Type ──
cur.execute(
f"""
SELECT w.\"msdyn_workordertype!name\" AS work_type, COUNT(*) as cnt
FROM msdyn_workorder w
WHERE {where_sql}
GROUP BY work_type
ORDER BY cnt DESC
""",
params,
)
by_work_type = [{"name": r["work_type"] or "Unspecified", "count": r["cnt"]} for r in cur.fetchall()]
# ── KPI 6: By Priority ──
cur.execute(
f"""
SELECT w.\"msdyn_priority!name\" AS priority, COUNT(*) as cnt
FROM msdyn_workorder w
WHERE {where_sql}
GROUP BY priority
ORDER BY cnt DESC
""",
params,
)
by_priority = [{"name": r["priority"] or "Unspecified", "count": r["cnt"]} for r in cur.fetchall()]
# ── KPI 7: Technician Performance (detailed) — use simpler subquery ──
cur.execute(
f"""
SELECT
t.technician,
COUNT(*) as total,
SUM(CASE WHEN t.msdyn_systemstatus IN (690970003, 690970004) THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN t.msdyn_systemstatus = 690970002 THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN t.msdyn_systemstatus = 690970001 THEN 1 ELSE 0 END) as scheduled
FROM (
SELECT
COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
) AS technician,
w.msdyn_systemstatus
FROM msdyn_workorder w
WHERE {where_sql}
) t
WHERE t.technician IS NOT NULL AND t.technician != ''
GROUP BY t.technician
ORDER BY total DESC
LIMIT 30
""",
params,
)
tech_perf = []
for r in cur.fetchall():
tech_perf.append({
"name": r["technician"],
"total": r["total"],
"completed": r["completed"],
"in_progress": r["in_progress"],
"scheduled": r["scheduled"],
})
# ── KPI 8: By Status per Tech (stacked) ──
cur.execute(
f"""
SELECT
t.technician,
t.status_code,
COUNT(*) as cnt
FROM (
SELECT
COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
) AS technician,
w.msdyn_systemstatus AS status_code
FROM msdyn_workorder w
WHERE {where_sql}
) t
WHERE t.technician IS NOT NULL AND t.technician != ''
GROUP BY t.technician, t.status_code
ORDER BY t.technician, t.status_code
""",
params,
)
status_by_tech = {}
for r in cur.fetchall():
tech = r["technician"]
if tech not in status_by_tech:
status_by_tech[tech] = {}
code = str(r["status_code"])
status_by_tech[tech][code] = r["cnt"]
# ── KPI 9: Summary stats ──
cur.execute(
f"""
SELECT
COUNT(*) as total,
SUM(CASE WHEN w.msdyn_systemstatus IN (690970003, 690970004) THEN 1 ELSE 0 END) as completed_posted,
SUM(CASE WHEN w.msdyn_systemstatus = 690970002 THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN w.msdyn_systemstatus = 690970001 THEN 1 ELSE 0 END) as scheduled,
COUNT(DISTINCT COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
)) as unique_techs
FROM msdyn_workorder w
WHERE {where_sql}
""",
params,
)
summary = dict(cur.fetchone())
# ── KPI 10: Avg days to complete (for completed/posted) ──
cur.execute(
f"""
SELECT AVG(julianday(w.msdyn_completedon) - julianday(w.createdon)) as avg_days
FROM msdyn_workorder w
WHERE {where_sql}
AND w.msdyn_systemstatus IN (690970003, 690970004)
AND w.msdyn_completedon IS NOT NULL
""",
params,
)
avg_days = cur.fetchone()[0]
summary["avg_days_to_complete"] = round(avg_days, 1) if avg_days else None
return {
"total_workorders": total_workorders,
"summary": summary,
"by_status": by_status,
"by_technician": by_technician,
"by_work_type": by_work_type,
"by_priority": by_priority,
"over_time": over_time,
"technician_performance": tech_perf,
"status_by_tech": status_by_tech,
}
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# WORK ORDERS LIST — reads extraction DB directly (same DB as reports)
# ═══════════════════════════════════════════════════════════════════════════
@app.get("/api/admin/workorders/list")
async def admin_workorders_list(
q: str = Query("", description="Search term (WO#, account, city)"),
status: str = Query("", description="Comma-separated status codes"),
tech: str = Query("", description="Technician name filter"),
date_from: str = Query("", description="Date filter start (YYYY-MM-DD)"),
date_to: str = Query("", description="Date filter end (YYYY-MM-DD)"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""Fetch filtered, paginated work orders from the extraction DB."""
conn = _get_extraction_db_report()
if not conn:
raise HTTPException(503, "Extraction DB not available")
try:
cur = conn.cursor()
params: list = []
where_clauses: list[str] = []
if q:
like = f"%{q}%"
where_clauses.append(
"(w.msdyn_name LIKE ? OR a.name LIKE ? OR a.address1_city LIKE ? OR w.\"msdyn_customerasset!name\" LIKE ? OR w.\"msdyn_serviceaccount!name\" LIKE ?)"
)
params.extend([like, like, like, like, like])
if status:
codes = [s.strip() for s in status.split(",") if s.strip()]
if codes:
placeholders = ",".join(["?" for _ in codes])
where_clauses.append(f"w.msdyn_systemstatus IN ({placeholders})")
params.extend(codes)
if tech:
like = f"%{tech}%"
where_clauses.append(
"(COALESCE(w.\"hsl_bookedresource1!name\",'') LIKE ? "
"OR COALESCE(w.\"hsl_bookedresource2!name\",'') LIKE ? "
"OR COALESCE(w.\"hsl_bookedresource3!name\",'') LIKE ? "
"OR EXISTS (SELECT 1 FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" LIKE ?))"
)
params.extend([like, like, like, like])
if date_from:
where_clauses.append("w.createdon >= ?")
params.append(date_from)
if date_to:
where_clauses.append("w.createdon < date(?, '+1 day')")
params.append(date_to)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
# Count
cur.execute(
f"SELECT COUNT(*) FROM msdyn_workorder w LEFT JOIN account a ON w.\"msdyn_serviceaccount!id\" = a.accountid WHERE {where_sql}",
params,
)
total = cur.fetchone()[0]
# Data
data_sql = f"""
SELECT
w.msdyn_workorderid,
w.msdyn_name,
w."msdyn_serviceaccount!name" AS account_name,
w.msdyn_workordersummary,
w.createdon,
w.msdyn_completedon,
w.msdyn_systemstatus,
w."msdyn_priority!name" AS priority,
w."msdyn_workordertype!name" AS work_type,
w."msdyn_primaryincidenttype!name" AS incident_type,
w."msdyn_serviceterritory!name" AS territory,
w."msdyn_customerasset!name" AS customer_asset_name,
w.msdyn_city,
w.msdyn_stateorprovince,
(SELECT GROUP_CONCAT(DISTINCT t)
FROM (
SELECT b2."resource!name" AS t FROM bookableresourcebooking b2 WHERE b2."msdyn_workorder!name" = w.msdyn_name
UNION
SELECT w2."hsl_bookedresource1!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource1!name" IS NOT NULL
UNION
SELECT w2."hsl_bookedresource2!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource2!name" IS NOT NULL
UNION
SELECT w2."hsl_bookedresource3!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource3!name" IS NOT NULL
)) AS technicians,
a.name AS account_display_name,
a.address1_city,
a.address1_stateorprovince
FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
WHERE {where_sql}
ORDER BY w.createdon DESC, w.msdyn_name
LIMIT ? OFFSET ?
"""
data_params = params + [limit, offset]
cur.execute(data_sql, data_params)
rows = cur.fetchall()
workorders = []
for r in rows:
workorders.append({
"id": r["msdyn_workorderid"],
"name": r["msdyn_name"],
"account_name": r["account_name"],
"account_display_name": r["account_display_name"],
"summary": r["msdyn_workordersummary"],
"status_code": str(r["msdyn_systemstatus"]),
"status": _status_label_report(r["msdyn_systemstatus"]),
"priority": r["priority"],
"work_type": r["work_type"],
"incident_type": r["incident_type"],
"territory": r["territory"],
"customer_asset_name": r["customer_asset_name"],
"technicians": r["technicians"],
"created_on": r["createdon"],
"completed_on": r["msdyn_completedon"],
"city": r["msdyn_city"] or r["address1_city"],
"state": r["msdyn_stateorprovince"] or r["address1_stateorprovince"],
})
return {
"results": workorders,
"total": total,
"offset": offset,
"limit": limit,
}
finally:
conn.close()
@app.get("/api/admin/workorders/lookup")
async def admin_workorders_lookup(name: str = Query("", description="Work order name/number")):
"""Return full details for a single work order by name."""
if not name:
raise HTTPException(400, "Work order name is required")
conn = _get_extraction_db_report()
if not conn:
raise HTTPException(503, "Extraction DB not available")
try:
cur = conn.cursor()
cur.execute("""
SELECT
w.*,
a.name AS account_display_name,
a.address1_line1,
a.address1_city,
a.address1_stateorprovince,
a.address1_postalcode,
a.telephone1,
a.websiteurl,
i.title AS incident_title,
i.msdyn_servicerequesttype AS incident_type_code,
(SELECT GROUP_CONCAT(DISTINCT t)
FROM (
SELECT b2."resource!name" AS t FROM bookableresourcebooking b2 WHERE b2."msdyn_workorder!name" = w.msdyn_name
UNION
SELECT w2."hsl_bookedresource1!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource1!name" IS NOT NULL
UNION
SELECT w2."hsl_bookedresource2!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource2!name" IS NOT NULL
UNION
SELECT w2."hsl_bookedresource3!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource3!name" IS NOT NULL
)) AS technicians
FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
LEFT JOIN msdyn_workorderincident woi ON woi."msdyn_workorder!id" = w.msdyn_workorderid
LEFT JOIN incident i ON i.incidentid = woi."msdyn_incident!id"
WHERE w.msdyn_name = ?
LIMIT 1
""", [name])
row = cur.fetchone()
if not row:
raise HTTPException(404, f"Work order '{name}' not found")
# Map row to dict (all columns)
keys = [d[0] for d in cur.description]
data = dict(zip(keys, row))
# Add readable status
data["status_label"] = _status_label_report(data.get("msdyn_systemstatus"))
return {"found": True, "data": data}
finally:
conn.close()
@app.get("/api/admin/workorders/technicians")
async def admin_workorders_technicians():
"""Return list of all technicians found in work orders."""
conn = _get_extraction_db_report()
if not conn:
raise HTTPException(503, "Extraction DB not available")
try:
cur = conn.cursor()
cur.execute("""
SELECT DISTINCT t FROM (
SELECT b."resource!name" AS t FROM bookableresourcebooking b WHERE b."resource!name" IS NOT NULL AND b."resource!name" != ''
UNION
SELECT w."hsl_bookedresource1!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource1!name" IS NOT NULL AND w."hsl_bookedresource1!name" != ''
UNION
SELECT w."hsl_bookedresource2!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource2!name" IS NOT NULL AND w."hsl_bookedresource2!name" != ''
UNION
SELECT w."hsl_bookedresource3!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource3!name" IS NOT NULL AND w."hsl_bookedresource3!name" != ''
) ORDER BY t
""")
techs = [r[0] for r in cur.fetchall()]
return {"technicians": techs}
finally:
conn.close()
@app.get("/api/admin/workorders/by-asset")
async def admin_workorders_by_asset(
name: str = Query("", description="Asset name (msdyn_customerasset!name) to search work orders for"),
):
"""Return work orders linked to a specific customer asset by name."""
if not name:
raise HTTPException(400, "Asset name is required")
conn = _get_extraction_db_report()
if not conn:
raise HTTPException(503, "Extraction DB not available")
try:
cur = conn.cursor()
cur.execute("""
SELECT
w.msdyn_name, w."msdyn_customerasset!name" AS asset_name,
w."msdyn_serviceaccount!name" AS account_name,
w.msdyn_workordersummary,
w."msdyn_workordertype!name" AS work_type,
w."msdyn_priority!name" AS priority,
w.msdyn_systemstatus,
w.createdon, w.msdyn_completedon,
a.name AS account_display_name,
a.address1_city, a.address1_stateorprovince
FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
WHERE w."msdyn_customerasset!name" = ?
ORDER BY w.createdon DESC
""", [name])
rows = cur.fetchall()
workorders = []
for r in rows:
workorders.append({
"name": r["msdyn_name"],
"asset_name": r["asset_name"],
"account_name": r["account_name"] or r["account_display_name"],
"summary": r["msdyn_workordersummary"],
"work_type": r["work_type"],
"priority": r["priority"],
"status_code": str(r["msdyn_systemstatus"]),
"status": _status_label_report(r["msdyn_systemstatus"]),
"created_on": r["createdon"],
"completed_on": r["msdyn_completedon"],
"city": r["address1_city"],
"state": r["address1_stateorprovince"],
})
return {"results": workorders, "total": len(workorders)}
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# ADB MSFS SYNC — proxies to the extraction viewer's sync API on port 8910
# ═══════════════════════════════════════════════════════════════════════════
EXTRACTION_SYNC_BASE = "http://localhost:8910"
@app.get("/api/admin/msfs-sync/info")
async def admin_msfs_sync_info():
"""Return ADB connection status, last sync metadata, and DB info."""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{EXTRACTION_SYNC_BASE}/api/sync/info")
return resp.json()
except httpx.RequestError as e:
return {
"adb_connected": False,
"extraction_server": False,
"error": f"Cannot reach extraction server on port 8910: {e}",
}
@app.post("/api/admin/msfs-sync/start")
async def admin_msfs_sync_start():
"""Start a background ADB sync task."""
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/start")
return resp.json()
except httpx.RequestError as e:
raise HTTPException(502, f"Extraction server unreachable: {e}")
@app.get("/api/admin/msfs-sync/task/{task_id}")
async def admin_msfs_sync_task(task_id: str):
"""Poll status of a running sync task."""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{EXTRACTION_SYNC_BASE}/api/sync/task/{task_id}")
if resp.status_code == 404:
raise HTTPException(404, "Task not found")
return resp.json()
except httpx.RequestError as e:
raise HTTPException(502, f"Extraction server unreachable: {e}")
@app.post("/api/admin/msfs-sync/launch-app")
async def admin_msfs_sync_launch():
"""Launch the MS Field Service app on the connected phone."""
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/start")
# The start endpoint already launches the app
return resp.json()
except httpx.RequestError as e:
raise HTTPException(502, f"Extraction server unreachable: {e}")
@app.post("/api/admin/msfs-sync/cancel/{task_id}")
async def admin_msfs_sync_cancel(task_id: str):
"""Cancel a running sync task."""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/cancel/{task_id}")
return resp.json()
except httpx.RequestError as e:
raise HTTPException(502, f"Extraction server unreachable: {e}")
# ─── SPA Frontend (catch-all — MUST be last route) ────────────────────────
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
+1216 -1
View File
File diff suppressed because it is too large Load Diff