admin-dashboard-return.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. #!/usr/bin/env python3
  2. """Paired local dashboard first-open and return benchmark with a slow fixed API.
  3. The mock session and gateway are synthetic. The marker is the real dashboard
  4. welcome and chart, not the title or loading placeholder.
  5. """
  6. import argparse
  7. import asyncio
  8. import json
  9. import statistics
  10. import threading
  11. from functools import partial
  12. from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
  13. from pathlib import Path
  14. from urllib.parse import urlsplit
  15. from playwright.async_api import async_playwright
  16. SESSION = {"objectId": "perf-test", "username": "perf-test", "sessionToken": "local-perf-test",
  17. "displayName": "测速账号", "roles": [], "operationsRole": "ops-manager", "isSuperAdmin": False}
  18. TREND = {"period": "week", "periodLabel": "近 7 天", "dateFrom": "2026-09-20", "dateTo": "2026-09-26",
  19. "points": [{"key": "2026-09-26", "label": "9月26日", "shortLabel": "周六", "courseCount": 2,
  20. "studentCount": 2, "completedCount": 1, "isToday": True}],
  21. "todayCourseCount": 2, "todayStudentCount": 2, "totalCourseCount": 2, "totalStudentCount": 2,
  22. "maxValue": 2, "refreshedAt": "2026-09-26T06:00:00.000Z", "liveSourceAvailable": True,
  23. "liveSourceMessage": "本地模拟数据"}
  24. DASHBOARD = {"identity": {"objectId": "perf-test", "username": "perf-test", "displayName": "运营经理",
  25. "roles": [], "operationsRole": "ops-manager", "isSuperAdmin": False},
  26. "date": "2026-09-26", "metrics": [], "alerts": [], "scheduleTrend": TREND,
  27. "refreshedAt": "2026-09-26T06:00:00.000Z", "liveSourceAvailable": True}
  28. class AppHandler(SimpleHTTPRequestHandler):
  29. def do_GET(self):
  30. if not (Path(self.directory) / urlsplit(self.path).path.lstrip("/")).is_file():
  31. self.path = "/index.html"
  32. return super().do_GET()
  33. def log_message(self, *_args):
  34. pass
  35. def serve(directory):
  36. server = ThreadingHTTPServer(("127.0.0.1", 0), partial(AppHandler, directory=str(directory)))
  37. threading.Thread(target=server.serve_forever, daemon=True).start()
  38. return server
  39. def percentile(values, n):
  40. return round(statistics.quantiles(sorted(values), n=100, method="inclusive")[n - 1], 1)
  41. async def measure(browser, url, from_login=False):
  42. context = await browser.new_context(viewport={"width": 1440, "height": 900})
  43. if not from_login:
  44. await context.add_init_script(
  45. "sessionStorage.setItem('xiaoshu_admin_session_v1', " + json.dumps(json.dumps(SESSION)) + ");"
  46. )
  47. page = await context.new_page()
  48. cdp = await context.new_cdp_session(page)
  49. await cdp.send("Network.enable")
  50. await cdp.send("Network.setCacheDisabled", {"cacheDisabled": True})
  51. await cdp.send("Network.emulateNetworkConditions", {
  52. "offline": False, "latency": 20, "downloadThroughput": 4 * 1024 * 1024 / 8,
  53. "uploadThroughput": 3 * 1024 * 1024 / 8,
  54. })
  55. dashboard_requests = 0
  56. async def mock_gateway(route):
  57. nonlocal dashboard_requests
  58. operation = (route.request.post_data_json or {}).get("params", {}).get("operation", "")
  59. if route.request.url.endswith('/xiaoshu/app/gateway'):
  60. await asyncio.sleep(0.3)
  61. await route.fulfill(status=200, content_type="application/json",
  62. body=json.dumps({"retcode": 0, "result": {"objectId": SESSION["objectId"],
  63. "userName": SESSION["username"], "sessionToken": SESSION["sessionToken"]}}))
  64. return
  65. if operation == "meta":
  66. await asyncio.sleep(0.5)
  67. payload = {"identity": DASHBOARD["identity"]}
  68. elif operation == "ops/dashboard/summary":
  69. dashboard_requests += 1
  70. await asyncio.sleep(1.2)
  71. payload = DASHBOARD
  72. elif operation == "ops/live/versions":
  73. payload = {"version": "fixed-v1", "scopeVersions": {}, "refreshedAt": "2026-09-26T06:00:00.000Z",
  74. "syncStatus": {"state": "current"}}
  75. else:
  76. payload = {}
  77. await route.fulfill(status=200, content_type="application/json",
  78. body=json.dumps({"success": True, "data": payload}))
  79. await page.route("**/cloud-functions/**", mock_gateway)
  80. try:
  81. if from_login:
  82. await page.goto(url + "/admin/login", wait_until="commit")
  83. await page.locator('input[name="username"]').fill("perf-test")
  84. await page.locator('input[name="password"]').fill("test-password")
  85. before = await page.evaluate("performance.now()")
  86. await page.get_by_role("button", name="登录管理后台").click()
  87. await page.get_by_role("heading", name="运营经理,欢迎回来").wait_for(timeout=30000)
  88. await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
  89. return {"loginMs": round(await page.evaluate("performance.now()") - before, 1),
  90. "dashboardRequests": dashboard_requests}
  91. await page.goto(url + "/admin/dashboard", wait_until="commit")
  92. await page.get_by_role("heading", name="运营经理,欢迎回来").wait_for(timeout=30000)
  93. await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
  94. first_ms = round(await page.evaluate("performance.now()"), 1)
  95. await page.locator('a[href="/admin/schedule"]').first.click()
  96. await page.get_by_role("heading", name="排课中心", exact=True).wait_for(timeout=30000)
  97. before = await page.evaluate("performance.now()")
  98. await page.locator('#admin-sidebar a[href="/admin/dashboard"]').last.click()
  99. await page.get_by_role("heading", name="运营经理,欢迎回来").wait_for(timeout=30000)
  100. await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
  101. returned_ms = round(await page.evaluate("performance.now()") - before, 1)
  102. return {"firstMs": first_ms, "returnMs": returned_ms, "dashboardRequests": dashboard_requests}
  103. finally:
  104. await context.close()
  105. async def main():
  106. parser = argparse.ArgumentParser()
  107. parser.add_argument("--baseline", type=Path, required=True)
  108. parser.add_argument("--candidate", type=Path)
  109. parser.add_argument("--runs", type=int, default=10)
  110. parser.add_argument("--out", type=Path)
  111. parser.add_argument("--from-login", action="store_true")
  112. args = parser.parse_args()
  113. roots = {"A": args.baseline}
  114. if args.candidate:
  115. roots["B"] = args.candidate
  116. servers = {name: serve(root) for name, root in roots.items()}
  117. urls = {name: f"http://127.0.0.1:{server.server_port}" for name, server in servers.items()}
  118. runs = {name: [] for name in roots}
  119. try:
  120. async with async_playwright() as playwright:
  121. browser = await playwright.chromium.launch()
  122. try:
  123. for index in range(args.runs):
  124. for name in (list(roots) if index % 2 == 0 else list(reversed(roots))):
  125. runs[name].append(await measure(browser, urls[name], args.from_login))
  126. finally:
  127. await browser.close()
  128. finally:
  129. for server in servers.values():
  130. server.shutdown()
  131. keys = ["loginMs"] if args.from_login else ["firstMs", "returnMs"]
  132. summary = {name: {"n": len(items), **{key + "P75": percentile([item[key] for item in items], 75)
  133. for key in keys},
  134. "dashboardRequests": max(item["dashboardRequests"] for item in items), "runs": items}
  135. for name, items in runs.items()}
  136. print(json.dumps({name: {k: v for k, v in item.items() if k != "runs"}
  137. for name, item in summary.items()}, ensure_ascii=False, indent=2))
  138. if args.out:
  139. args.out.parent.mkdir(parents=True, exist_ok=True)
  140. args.out.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
  141. if __name__ == "__main__":
  142. asyncio.run(main())