@xenon-device-management/xenon 2.9.2 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-B87n4pUu.js → AnnotationOverlay-LzfXiiZJ.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-CNZJH6Cs.js → ApiKeyGate-BzFDp4mK.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-DQ-R87ZV.js → BugReportButton-BpgWvmZo.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-dX3e7O-w.js → DeviceMosaicView-CCB-egSH.js} +2 -2
  6. package/lib/public/assets/{EmptyState-D3fG2GHY.js → EmptyState-DkUVmANo.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CUmbJZxI.js → FieldGroup-mbfnfTql.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-Dm2c-UCk.js → FilterMenu-BH5usz4Y.js} +1 -1
  9. package/lib/public/assets/{Menu-Dl4uXMkH.js → Menu-Cbo9mdTk.js} +1 -1
  10. package/lib/public/assets/{Modal-DNmn2RDs.js → Modal-D6XfQHPr.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-Dn3By34o.js → RecordingPage-CmFPkWqH.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-lCUIM4pm.js → RecordingsPage-CSlKk2qC.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-BdA_mHiZ.js → SegmentedControl-BFW9Dlqd.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Dx3WhK7w.js → SettingCard-C4DioTTi.js} +1 -1
  15. package/lib/public/assets/{Table-D_2lhvRM.js → Table-DrKjlL00.js} +1 -1
  16. package/lib/public/assets/{activity-cNHHOKx5.js → activity-C9bE7Jm8.js} +1 -1
  17. package/lib/public/assets/{ai-settings-t6RMrDkg.js → ai-settings-DRYic4oO.js} +1 -1
  18. package/lib/public/assets/{api-keys-CK_qyQvW.js → api-keys-CaQU0g7c.js} +1 -1
  19. package/lib/public/assets/{apps-DCiI3UOb.js → apps-c1gUFmmK.js} +1 -1
  20. package/lib/public/assets/{arrow-left-CnYs8HVw.js → arrow-left-DMvAWA8r.js} +1 -1
  21. package/lib/public/assets/{arrow-right-Cc9qm6UG.js → arrow-right-BEQgxmKT.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-e7yBOb-c.js → arrow-up-right-Ba0e2GeU.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DKrtcIjG.js → auth-shell-COODQnc7.js} +2 -2
  24. package/lib/public/assets/{builds-page-CRd5oEz6.js → builds-page-DZQgWBUs.js} +4 -4
  25. package/lib/public/assets/{button-C0caCjJB.js → button-WW9j43wR.js} +1 -1
  26. package/lib/public/assets/{calendar-BqOOkln_.js → calendar-COUs9VxF.js} +1 -1
  27. package/lib/public/assets/{check-DYSXRG79.js → check-D8ifHIJR.js} +1 -1
  28. package/lib/public/assets/{chevron-right-hCNu0CA-.js → chevron-right-BjKVUlB_.js} +1 -1
  29. package/lib/public/assets/{circle-check-DnQ263fa.js → circle-check-CfP79QE2.js} +1 -1
  30. package/lib/public/assets/{circle-x-BaRKZyz9.js → circle-x-BZbFCixZ.js} +1 -1
  31. package/lib/public/assets/{clock-XP3iBAhg.js → clock-BuTm35Qg.js} +1 -1
  32. package/lib/public/assets/{copy-D8d9UzWE.js → copy-W00zUBwN.js} +1 -1
  33. package/lib/public/assets/{device-explorer-pdM3L9TC.js → device-explorer-BEpS-Y-_.js} +5 -5
  34. package/lib/public/assets/{download-SaN9-umX.js → download-CGz-MS3y.js} +1 -1
  35. package/lib/public/assets/{forgot-password-DfUhEZW5.js → forgot-password-DG7SqVgU.js} +1 -1
  36. package/lib/public/assets/{index-C8pvxG1f.css → index-DQa193-A.css} +1 -1
  37. package/lib/public/assets/{index-IefGCFQQ.js → index-DWHTp1YP.js} +1 -1
  38. package/lib/public/assets/{index-CzaIyL9t.js → index-EkaQM1DK.js} +2 -2
  39. package/lib/public/assets/{input-BqFzAQT3.js → input-ChaTXeCq.js} +1 -1
  40. package/lib/public/assets/{keycodes-DDV2OlpQ.js → keycodes-BhbgeW_r.js} +10 -5
  41. package/lib/public/assets/line-chart-9P3RxScy.js +1 -0
  42. package/lib/public/assets/list-checks-_23IIEEL.js +11 -0
  43. package/lib/public/assets/{lock-Eye1CG8p.js → lock-B2JaqqRI.js} +1 -1
  44. package/lib/public/assets/{login-Co1IXuen.js → login-BEWIfLfC.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-D1fw6iqm.js → maintenance-settings-BGJIJQ-k.js} +1 -1
  46. package/lib/public/assets/{monitor-U5Y-PxW5.js → monitor-BrwMY4G0.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CDAwphUm.js → mouse-pointer-2-DsWDMyrY.js} +1 -1
  48. package/lib/public/assets/{network-BP_U4iDZ.js → network-DfIhNVJv.js} +1 -1
  49. package/lib/public/assets/overview-B1mePY2_.js +6 -0
  50. package/lib/public/assets/{page-header-ZdzNEJp9.js → page-header-DoD3TOzZ.js} +1 -1
  51. package/lib/public/assets/{plus-1k0sxXyU.js → plus-BGvWvpBB.js} +1 -1
  52. package/lib/public/assets/{profile-page-B_GWKRy3.js → profile-page-CvV5aWvl.js} +1 -1
  53. package/lib/public/assets/{reset-password-DizCTkdw.js → reset-password-CGVd31LR.js} +1 -1
  54. package/lib/public/assets/{runbook-page-DWzRiOLZ.js → runbook-page-Bq2cvwb5.js} +1 -1
  55. package/lib/public/assets/{select-CiRW6v17.js → select-CTcD4jWR.js} +1 -1
  56. package/lib/public/assets/selector-detail-redirect-19odlD4B.js +1 -0
  57. package/lib/public/assets/selector-health-page-LqIsLrjW.js +11 -0
  58. package/lib/public/assets/session-detail-page-DQzb8_fq.js +4 -0
  59. package/lib/public/assets/{settings-DhwZoWv7.js → settings-C-EnGIqs.js} +1 -1
  60. package/lib/public/assets/stat-tile-BY0RDTRm.js +1 -0
  61. package/lib/public/assets/{tablet-CPJV3dBq.js → tablet-B4cFM-5B.js} +1 -1
  62. package/lib/public/assets/{teams-D0IBa2Ov.js → teams-BFe-RpYd.js} +1 -1
  63. package/lib/public/assets/time-D-gjmE1o.js +1 -0
  64. package/lib/public/assets/{trash-2-u8MlXUBD.js → trash-2-CJCKBOP9.js} +1 -1
  65. package/lib/public/assets/{upload-Cq8L3kpw.js → upload-DidWkbld.js} +1 -1
  66. package/lib/public/assets/use-builds-data-fnfxatOd.js +8 -0
  67. package/lib/public/assets/{use-password-reset-mode-Dbr2HEfw.js → use-password-reset-mode-iPen3tmC.js} +1 -1
  68. package/lib/public/assets/{users-CZqCE511.js → users-CrcjmRdf.js} +1 -1
  69. package/lib/public/assets/{users-DxLHYmFm.js → users-DBUv0CJx.js} +1 -1
  70. package/lib/public/assets/{video-off-mh1EEzP9.js → video-off-eh69-FKB.js} +1 -1
  71. package/lib/public/assets/view-state-CP9dE1UI.js +1 -0
  72. package/lib/public/assets/{webhook-settings-BYuOib-Q.js → webhook-settings-CQoSshBn.js} +1 -1
  73. package/lib/public/assets/{zap-BOpUur0M.js → zap-CICtWiLn.js} +1 -1
  74. package/lib/public/index.html +2 -2
  75. package/lib/schema.json +5 -0
  76. package/lib/src/app/routers/dashboard.js +86 -40
  77. package/lib/src/app/routers/selector-health.js +75 -0
  78. package/lib/src/app/swagger-docs.js +77 -8
  79. package/lib/src/dashboard/event-manager.js +15 -99
  80. package/lib/src/generated/client/edge.js +27 -3
  81. package/lib/src/generated/client/index-browser.js +24 -0
  82. package/lib/src/generated/client/index.d.ts +8207 -5283
  83. package/lib/src/generated/client/index.js +27 -3
  84. package/lib/src/generated/client/package.json +1 -1
  85. package/lib/src/generated/client/schema.prisma +43 -9
  86. package/lib/src/generated/client/wasm.js +24 -0
  87. package/lib/src/prisma.js +2 -0
  88. package/lib/src/services/CleanupService.js +1 -0
  89. package/lib/src/services/NotificationService.js +3 -4
  90. package/lib/src/services/SelectorStateService.js +151 -113
  91. package/lib/src/services/SelectorVerificationJob.js +14 -9
  92. package/lib/src/services/metrics/AndroidMetricsSampler.js +94 -0
  93. package/lib/src/services/metrics/IOSMetricsSampler.js +120 -0
  94. package/lib/src/services/metrics/SessionMetricsService.js +238 -0
  95. package/lib/src/services/metrics/androidMetrics.js +94 -0
  96. package/lib/src/services/metrics/iosMetrics.js +26 -0
  97. package/lib/src/services/metrics/metricsBody.js +37 -0
  98. package/lib/src/services/metrics/types.js +19 -0
  99. package/lib/src/services/selector-health/access.js +66 -0
  100. package/lib/src/services/selector-health/healingTrend.js +49 -0
  101. package/lib/src/services/selector-health/people.js +27 -0
  102. package/lib/src/services/selector-health/selectorDetail.js +198 -0
  103. package/lib/src/services/selector-health/selectorKeys.js +36 -0
  104. package/lib/src/services/selector-health/selectorList.js +333 -0
  105. package/lib/src/services/selector-health/stateView.js +67 -0
  106. package/lib/test/helpers/scratch-database.js +2 -0
  107. package/lib/test/helpers/selector-health-fixture.js +216 -0
  108. package/lib/test/integration/team-visibility-sessions.spec.js +1 -0
  109. package/lib/test/unit/CleanupService.spec.js +28 -2
  110. package/lib/test/unit/dashboard-events-metrics.spec.js +146 -0
  111. package/lib/test/unit/healing-no-cost.spec.js +134 -0
  112. package/lib/test/unit/healing-state-endpoints.spec.js +70 -0
  113. package/lib/test/unit/healing-trend.spec.js +118 -0
  114. package/lib/test/unit/selector-event-model.spec.js +47 -0
  115. package/lib/test/unit/selector-health-actions.spec.js +106 -0
  116. package/lib/test/unit/selector-health-detail.spec.js +131 -0
  117. package/lib/test/unit/selector-health-list.spec.js +185 -0
  118. package/lib/test/unit/selector-state-service.spec.js +74 -1
  119. package/lib/test/unit/selector-verification-job.spec.js +38 -0
  120. package/lib/test/unit/session-metrics-parse.spec.js +138 -0
  121. package/lib/test/unit/session-metrics-route.spec.js +140 -0
  122. package/lib/test/unit/session-metrics-samplers.spec.js +248 -0
  123. package/lib/test/unit/session-metrics-service.spec.js +215 -0
  124. package/lib/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +1 -1
  126. package/prisma/migrations/20261002120000_session_metric/migration.sql +17 -0
  127. package/prisma/migrations/20261002130000_selector_event/migration.sql +13 -0
  128. package/prisma/schema.prisma +43 -9
  129. package/schema.json +5 -0
  130. package/lib/public/assets/chevron-left-BSG6mcpo.js +0 -6
  131. package/lib/public/assets/funnel-OefHE-54.js +0 -6
  132. package/lib/public/assets/layers-a3GNz2DA.js +0 -6
  133. package/lib/public/assets/list-checks-DMrMAFDt.js +0 -6
  134. package/lib/public/assets/overview-CsPyPv10.js +0 -1
  135. package/lib/public/assets/selector-detail-page-pA913STP.js +0 -1
  136. package/lib/public/assets/selector-health-BlICzZlk.css +0 -1
  137. package/lib/public/assets/selector-health-CHLX8KqY.js +0 -16
  138. package/lib/public/assets/selector-health-page-vVkg8Mii.js +0 -16
  139. package/lib/public/assets/session-detail-page-BCD49zDi.js +0 -4
  140. package/lib/public/assets/stat-tile-BaE76cnK.js +0 -8
  141. package/lib/public/assets/time-B8FIgwPE.js +0 -1
  142. package/lib/src/profiling/AndroidAppProfiler.js +0 -213
  143. /package/lib/public/assets/{session-detail-page-Bzzc3bMl.css → line-chart-Bzzc3bMl.css} +0 -0
@@ -1 +1 @@
1
- import{t as P,r,X as c,j as e,J as u,A as b,C as U,K as D}from"./index-CzaIyL9t.js";/* empty css */import{F as S}from"./FieldGroup-CUmbJZxI.js";import{P as O}from"./page-header-ZdzNEJp9.js";import{T as V}from"./trash-2-u8MlXUBD.js";import{P as x}from"./plus-1k0sxXyU.js";import{C as B}from"./circle-x-BaRKZyz9.js";import{C as H}from"./circle-check-DnQ263fa.js";import{Z as I}from"./zap-BOpUur0M.js";import{A as J}from"./activity-cNHHOKx5.js";const C=[{id:"device_offline",label:"Device offline",icon:U,tone:"red"},{id:"device_new",label:"New device",icon:x,tone:"green"},{id:"session_failed",label:"Session failed",icon:B,tone:"amber"},{id:"selector_health_digest",label:"Selector health digest",icon:D,tone:"green"}],X=["udid","host","name","sessionId","failureReason","eventType","platform"],ae=()=>{const{toast:o}=P(),[g,T]=r.useState([]),[A,w]=r.useState(!0),[l,j]=r.useState(""),[d,k]=r.useState(["device_offline","session_failed"]),[h,f]=r.useState(!1),[v,N]=r.useState(!1),[n,_]=r.useState(!1),[y,m]=r.useState("");r.useEffect(()=>{p()},[]);const p=async()=>{w(!0);try{const s=await c.getWebhookConfigs();T(s||[])}catch(s){console.error("Failed to load webhook configs",s),o("Failed to load webhooks.","error")}finally{w(!1)}},z=async()=>{if(!l.trim()){o("Webhook URL is required.","error");return}if(d.length===0){o("Select at least one trigger event.","error");return}f(!0);try{await c.addWebhookConfig(l.trim(),d,"slack",y||void 0),j(""),m(""),_(!1),k(["device_offline","session_failed"]),await p(),o("Webhook saved.","success")}catch(s){console.error("Failed to add webhook",s),o(`Failed to add webhook: ${s.message||"unknown error"}`,"error")}finally{f(!1)}},E=async(s,t)=>{if(window.confirm(`Remove webhook for "${t}"?`))try{await c.deleteWebhookConfig(s),await p(),o("Webhook removed.","success")}catch(a){console.error("Failed to delete webhook",a),o(`Failed to delete: ${a.message||"unknown error"}`,"error")}},L=async()=>{if(l.trim()){N(!0);try{await c.testWebhook(l.trim(),"slack"),o("Test payload delivered.","success")}catch(s){o(`Test failed: ${s.message||"check the URL"}`,"error")}finally{N(!1)}}},$=s=>{k(t=>t.includes(s)?t.filter(a=>a!==s):[...t,s])},R=s=>{m(t=>t+`{{${s}}} `)};return e.jsxs("div",{className:"settings-container",children:[e.jsx(O,{icon:u,title:"Notifications",subtitle:"Configure Slack or generic webhooks to receive alerts for critical infrastructure events."}),e.jsxs("div",{className:"settings-content",children:[A?e.jsxs("div",{className:"settings-loading",style:{height:200},children:[e.jsx(b,{className:"animate-spin",size:28}),e.jsx("span",{children:"Loading webhooks…"})]}):g.length===0?e.jsxs("div",{className:"empty-state",children:[e.jsx("div",{className:"empty-state__icon",children:e.jsx(u,{size:28})}),e.jsx("h3",{className:"empty-state__title",children:"No webhooks configured"}),e.jsx("p",{className:"empty-state__copy",children:"Wire up a Slack incoming webhook or any HTTPS endpoint to receive alerts when devices go offline, new devices register, or sessions fail. Configure your first webhook below."})]}):e.jsx("div",{className:"webhook-list-grid",children:g.map(s=>{const t=(()=>{try{return JSON.parse(s.events)}catch{return[]}})();return e.jsxs("div",{className:"webhook-row-card",children:[e.jsxs("div",{className:"webhook-row-card__header",children:[e.jsx("span",{className:`pill-chip ${s.payloadTemplate?"pill-chip--admin":"pill-chip--scope"}`,children:s.payloadTemplate?"CUSTOM":"SLACK"}),e.jsx("span",{className:"webhook-row-card__url",title:s.url,children:s.url}),e.jsxs("button",{type:"button",className:"row-action row-action--danger",onClick:()=>E(s.id,s.url),"aria-label":`Remove webhook for ${s.url}`,children:[e.jsx(V,{size:13}),e.jsx("span",{children:"Remove"})]})]}),e.jsx("div",{className:"webhook-row-card__events",children:t.length===0?e.jsx("span",{className:"webhook-row-card__no-events",children:"No triggers"}):t.map(a=>{const i=C.find(F=>F.id===a),W=(i==null?void 0:i.icon)??u;return e.jsxs("span",{className:`event-pill event-pill--${(i==null?void 0:i.tone)||"gray"}`,children:[e.jsx(W,{size:11}),(i==null?void 0:i.label)||a]},a)})})]},s.id)})}),e.jsxs("div",{className:"add-webhook-card",children:[e.jsxs("div",{className:"add-webhook-card__header",children:[e.jsx(x,{size:14}),e.jsx("span",{children:"Add a new webhook"})]}),e.jsxs("div",{className:"add-webhook-card__body",children:[e.jsx(S,{label:"Webhook URL",description:"The endpoint we POST webhook events to.",htmlFor:"webhook-url",children:e.jsx("div",{className:"setting-input-wrapper",children:e.jsx("input",{id:"webhook-url",type:"url",placeholder:"https://hooks.slack.com/services/...",value:l,onChange:s=>j(s.target.value)})})}),e.jsx(S,{label:"Trigger events",children:e.jsx("div",{className:"event-toggle-grid",children:C.map(s=>{const t=d.includes(s.id),a=s.icon;return e.jsxs("button",{type:"button",role:"checkbox","aria-checked":t,className:`event-toggle event-toggle--${s.tone} ${t?"is-selected":""}`,onClick:()=>$(s.id),children:[e.jsx(a,{size:14,className:"event-toggle__lead"}),e.jsx("span",{children:s.label}),t&&e.jsx(H,{size:14,className:"event-toggle__check"})]},s.id)})})}),e.jsxs("div",{className:"template-section",children:[e.jsxs("button",{type:"button",className:"template-section__toggle",onClick:()=>_(!n),"aria-expanded":n,children:[e.jsx(I,{size:14,className:n?"tone-amber":"tone-dim"}),e.jsx("span",{children:"Use custom payload (optional)"}),e.jsx("span",{className:"template-section__indicator",children:n?"−":"+"})]}),n&&e.jsxs("div",{className:"template-editor",children:[e.jsx("p",{className:"template-editor__hint",children:"Define a JSON or text template. Click a variable below to insert it."}),e.jsx("div",{className:"template-editor__chips",children:X.map(s=>e.jsx("button",{type:"button",className:"template-editor__chip",onClick:()=>R(s),children:`{{${s}}}`},s))}),e.jsx("textarea",{className:"template-editor__textarea",placeholder:'Example: { "text": "Alert: Device {{udid}} is offline!" }',value:y,onChange:s=>m(s.target.value),rows:4})]})]})]}),e.jsxs("div",{className:"add-webhook-card__footer",children:[e.jsxs("button",{type:"button",className:"page-header-action page-header-action--ghost",onClick:L,disabled:!l.trim()||v,children:[v?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(J,{size:14}),e.jsx("span",{children:"Test payload"})]}),e.jsxs("button",{type:"button",className:"page-header-action",onClick:z,disabled:h||!l.trim(),children:[h?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(x,{size:14}),e.jsx("span",{children:h?"Saving…":"Save webhook"})]})]})]})]})]})};export{ae as WebhookSettings};
1
+ import{t as P,r,X as c,j as e,J as u,A as b,C as U,K as D}from"./index-EkaQM1DK.js";/* empty css */import{F as S}from"./FieldGroup-mbfnfTql.js";import{P as O}from"./page-header-DoD3TOzZ.js";import{T as V}from"./trash-2-CJCKBOP9.js";import{P as x}from"./plus-BGvWvpBB.js";import{C as B}from"./circle-x-BZbFCixZ.js";import{C as H}from"./circle-check-CfP79QE2.js";import{Z as I}from"./zap-CICtWiLn.js";import{A as J}from"./activity-C9bE7Jm8.js";const C=[{id:"device_offline",label:"Device offline",icon:U,tone:"red"},{id:"device_new",label:"New device",icon:x,tone:"green"},{id:"session_failed",label:"Session failed",icon:B,tone:"amber"},{id:"selector_health_digest",label:"Selector health digest",icon:D,tone:"green"}],X=["udid","host","name","sessionId","failureReason","eventType","platform"],ae=()=>{const{toast:o}=P(),[g,T]=r.useState([]),[A,w]=r.useState(!0),[l,j]=r.useState(""),[d,k]=r.useState(["device_offline","session_failed"]),[h,f]=r.useState(!1),[v,N]=r.useState(!1),[n,_]=r.useState(!1),[y,m]=r.useState("");r.useEffect(()=>{p()},[]);const p=async()=>{w(!0);try{const s=await c.getWebhookConfigs();T(s||[])}catch(s){console.error("Failed to load webhook configs",s),o("Failed to load webhooks.","error")}finally{w(!1)}},z=async()=>{if(!l.trim()){o("Webhook URL is required.","error");return}if(d.length===0){o("Select at least one trigger event.","error");return}f(!0);try{await c.addWebhookConfig(l.trim(),d,"slack",y||void 0),j(""),m(""),_(!1),k(["device_offline","session_failed"]),await p(),o("Webhook saved.","success")}catch(s){console.error("Failed to add webhook",s),o(`Failed to add webhook: ${s.message||"unknown error"}`,"error")}finally{f(!1)}},E=async(s,t)=>{if(window.confirm(`Remove webhook for "${t}"?`))try{await c.deleteWebhookConfig(s),await p(),o("Webhook removed.","success")}catch(a){console.error("Failed to delete webhook",a),o(`Failed to delete: ${a.message||"unknown error"}`,"error")}},L=async()=>{if(l.trim()){N(!0);try{await c.testWebhook(l.trim(),"slack"),o("Test payload delivered.","success")}catch(s){o(`Test failed: ${s.message||"check the URL"}`,"error")}finally{N(!1)}}},$=s=>{k(t=>t.includes(s)?t.filter(a=>a!==s):[...t,s])},R=s=>{m(t=>t+`{{${s}}} `)};return e.jsxs("div",{className:"settings-container",children:[e.jsx(O,{icon:u,title:"Notifications",subtitle:"Configure Slack or generic webhooks to receive alerts for critical infrastructure events."}),e.jsxs("div",{className:"settings-content",children:[A?e.jsxs("div",{className:"settings-loading",style:{height:200},children:[e.jsx(b,{className:"animate-spin",size:28}),e.jsx("span",{children:"Loading webhooks…"})]}):g.length===0?e.jsxs("div",{className:"empty-state",children:[e.jsx("div",{className:"empty-state__icon",children:e.jsx(u,{size:28})}),e.jsx("h3",{className:"empty-state__title",children:"No webhooks configured"}),e.jsx("p",{className:"empty-state__copy",children:"Wire up a Slack incoming webhook or any HTTPS endpoint to receive alerts when devices go offline, new devices register, or sessions fail. Configure your first webhook below."})]}):e.jsx("div",{className:"webhook-list-grid",children:g.map(s=>{const t=(()=>{try{return JSON.parse(s.events)}catch{return[]}})();return e.jsxs("div",{className:"webhook-row-card",children:[e.jsxs("div",{className:"webhook-row-card__header",children:[e.jsx("span",{className:`pill-chip ${s.payloadTemplate?"pill-chip--admin":"pill-chip--scope"}`,children:s.payloadTemplate?"CUSTOM":"SLACK"}),e.jsx("span",{className:"webhook-row-card__url",title:s.url,children:s.url}),e.jsxs("button",{type:"button",className:"row-action row-action--danger",onClick:()=>E(s.id,s.url),"aria-label":`Remove webhook for ${s.url}`,children:[e.jsx(V,{size:13}),e.jsx("span",{children:"Remove"})]})]}),e.jsx("div",{className:"webhook-row-card__events",children:t.length===0?e.jsx("span",{className:"webhook-row-card__no-events",children:"No triggers"}):t.map(a=>{const i=C.find(F=>F.id===a),W=(i==null?void 0:i.icon)??u;return e.jsxs("span",{className:`event-pill event-pill--${(i==null?void 0:i.tone)||"gray"}`,children:[e.jsx(W,{size:11}),(i==null?void 0:i.label)||a]},a)})})]},s.id)})}),e.jsxs("div",{className:"add-webhook-card",children:[e.jsxs("div",{className:"add-webhook-card__header",children:[e.jsx(x,{size:14}),e.jsx("span",{children:"Add a new webhook"})]}),e.jsxs("div",{className:"add-webhook-card__body",children:[e.jsx(S,{label:"Webhook URL",description:"The endpoint we POST webhook events to.",htmlFor:"webhook-url",children:e.jsx("div",{className:"setting-input-wrapper",children:e.jsx("input",{id:"webhook-url",type:"url",placeholder:"https://hooks.slack.com/services/...",value:l,onChange:s=>j(s.target.value)})})}),e.jsx(S,{label:"Trigger events",children:e.jsx("div",{className:"event-toggle-grid",children:C.map(s=>{const t=d.includes(s.id),a=s.icon;return e.jsxs("button",{type:"button",role:"checkbox","aria-checked":t,className:`event-toggle event-toggle--${s.tone} ${t?"is-selected":""}`,onClick:()=>$(s.id),children:[e.jsx(a,{size:14,className:"event-toggle__lead"}),e.jsx("span",{children:s.label}),t&&e.jsx(H,{size:14,className:"event-toggle__check"})]},s.id)})})}),e.jsxs("div",{className:"template-section",children:[e.jsxs("button",{type:"button",className:"template-section__toggle",onClick:()=>_(!n),"aria-expanded":n,children:[e.jsx(I,{size:14,className:n?"tone-amber":"tone-dim"}),e.jsx("span",{children:"Use custom payload (optional)"}),e.jsx("span",{className:"template-section__indicator",children:n?"−":"+"})]}),n&&e.jsxs("div",{className:"template-editor",children:[e.jsx("p",{className:"template-editor__hint",children:"Define a JSON or text template. Click a variable below to insert it."}),e.jsx("div",{className:"template-editor__chips",children:X.map(s=>e.jsx("button",{type:"button",className:"template-editor__chip",onClick:()=>R(s),children:`{{${s}}}`},s))}),e.jsx("textarea",{className:"template-editor__textarea",placeholder:'Example: { "text": "Alert: Device {{udid}} is offline!" }',value:y,onChange:s=>m(s.target.value),rows:4})]})]})]}),e.jsxs("div",{className:"add-webhook-card__footer",children:[e.jsxs("button",{type:"button",className:"page-header-action page-header-action--ghost",onClick:L,disabled:!l.trim()||v,children:[v?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(J,{size:14}),e.jsx("span",{children:"Test payload"})]}),e.jsxs("button",{type:"button",className:"page-header-action",onClick:z,disabled:h||!l.trim(),children:[h?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(x,{size:14}),e.jsx("span",{children:h?"Saving…":"Save webhook"})]})]})]})]})]})};export{ae as WebhookSettings};
@@ -1,4 +1,4 @@
1
- import{c as a}from"./index-CzaIyL9t.js";/**
1
+ import{c as a}from"./index-EkaQM1DK.js";/**
2
2
  * @license lucide-react v0.555.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
@@ -36,8 +36,8 @@
36
36
  Learn how to configure a non-root public URL by running `npm run build`.
37
37
  -->
38
38
  <title>Xenon</title>
39
- <script type="module" crossorigin src="/xenon/assets/index-CzaIyL9t.js"></script>
40
- <link rel="stylesheet" crossorigin href="/xenon/assets/index-C8pvxG1f.css">
39
+ <script type="module" crossorigin src="/xenon/assets/index-EkaQM1DK.js"></script>
40
+ <link rel="stylesheet" crossorigin href="/xenon/assets/index-DQa193-A.css">
41
41
  </head>
42
42
 
43
43
  <body>
package/lib/schema.json CHANGED
@@ -281,6 +281,11 @@
281
281
  "streaming": {
282
282
  "type": "object",
283
283
  "description": "Live-streaming options. See StreamingConfig interface for details."
284
+ },
285
+ "sessionMetrics": {
286
+ "type": "boolean",
287
+ "default": true,
288
+ "description": "Record CPU and memory every 2 s for each session on this server's own phones (Android: the app under test and the device; iPhone: device CPU), shown in the session page's Performance panel. Needs the dashboard."
284
289
  }
285
290
  },
286
291
  "required": [
@@ -45,6 +45,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
45
45
  return (mod && mod.__esModule) ? mod : { "default": mod };
46
46
  };
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.MUTE_REASON_MAX = void 0;
48
49
  exports.getRecentHealingEvents = getRecentHealingEvents;
49
50
  exports.aggregateHotspots = aggregateHotspots;
50
51
  exports.getSelectorHealth = getSelectorHealth;
@@ -59,12 +60,18 @@ const typedi_1 = require("typedi");
59
60
  const scopeGuard_1 = require("../../middleware/scopeGuard");
60
61
  const roleGuard_1 = require("../../middleware/roleGuard");
61
62
  const build_export_1 = __importDefault(require("./build-export"));
63
+ const selector_health_1 = __importDefault(require("./selector-health"));
64
+ const actor_1 = require("../../services/device-access/actor");
65
+ const access_1 = require("../../services/selector-health/access");
62
66
  const NotificationService_1 = require("../../services/NotificationService");
63
67
  const SelectorStateService_1 = require("../../services/SelectorStateService");
64
68
  const path_1 = __importDefault(require("path"));
65
69
  const config_1 = require("../../config");
66
70
  const sessionVisibility_1 = require("../../services/device-access/sessionVisibility");
67
71
  const HealEtalonService_1 = require("../../services/healing/HealEtalonService");
72
+ const metricsBody_1 = require("../../services/metrics/metricsBody");
73
+ const healingTrend_1 = require("../../services/selector-health/healingTrend");
74
+ const SessionMetricsService_1 = require("../../services/metrics/SessionMetricsService");
68
75
  const sessionInsights_1 = require("./sessionInsights");
69
76
  const logger_1 = __importDefault(require("../../logger"));
70
77
  const MJPEG_PROXY_CACHE = new Map();
@@ -324,26 +331,6 @@ function getRecentHealingEvents(request, response) {
324
331
  return response.status(200).json({ events, todayCount });
325
332
  });
326
333
  }
327
- // Rough per-heal cost estimates in USD. Local tiers are effectively free
328
- // (CPU/inference cost only); LLM is the headline number testers should
329
- // optimize against. Surfaced so engineering managers can quantify suite
330
- // hygiene work without reading raw billing data.
331
- const TIER_COST_USD = {
332
- Native: 0,
333
- Resilio: 0,
334
- 'Fuzzy XML': 0,
335
- OCR: 0.0005,
336
- 'Visual AI': 0.002,
337
- LLM: 0.04,
338
- };
339
- function estimateCost(byTier) {
340
- var _a;
341
- let sum = 0;
342
- for (const [tier, count] of Object.entries(byTier)) {
343
- sum += ((_a = TIER_COST_USD[tier]) !== null && _a !== void 0 ? _a : 0) * count;
344
- }
345
- return sum;
346
- }
347
334
  function parseWindowDays(raw, fallback = 30) {
348
335
  const n = parseInt(typeof raw === 'string' ? raw : '', 10);
349
336
  if (!Number.isFinite(n))
@@ -355,6 +342,7 @@ function parseWindowDays(raw, fallback = 30) {
355
342
  function getHealingSummary(request, response) {
356
343
  return __awaiter(this, void 0, void 0, function* () {
357
344
  const windowDays = parseWindowDays(request.query.windowDays);
345
+ const tz = (0, healingTrend_1.parseTzOffset)(request.query.tz);
358
346
  const now = new Date();
359
347
  const since = new Date(now);
360
348
  since.setDate(since.getDate() - windowDays);
@@ -370,6 +358,8 @@ function getHealingSummary(request, response) {
370
358
  session_id: true,
371
359
  original_selector: true,
372
360
  healing_tier: true,
361
+ createdAt: true,
362
+ duration: true,
373
363
  },
374
364
  }),
375
365
  prisma_1.prisma.sessionLog.findMany({
@@ -378,15 +368,20 @@ function getHealingSummary(request, response) {
378
368
  session_id: true,
379
369
  original_selector: true,
380
370
  healing_tier: true,
371
+ createdAt: true,
372
+ duration: true,
381
373
  },
382
374
  }),
383
375
  ]);
384
376
  const aggregate = (rows) => {
377
+ var _a;
385
378
  const sessions = new Set();
386
379
  const selectors = new Set();
387
380
  const byTier = {};
381
+ let timeSpentMs = 0;
388
382
  for (const r of rows) {
389
383
  sessions.add(r.session_id);
384
+ timeSpentMs += (_a = r.duration) !== null && _a !== void 0 ? _a : 0;
390
385
  if (r.original_selector)
391
386
  selectors.add(r.original_selector);
392
387
  const tier = r.healing_tier || 'Unknown';
@@ -397,7 +392,8 @@ function getHealingSummary(request, response) {
397
392
  distinctSelectors: selectors.size,
398
393
  sessionsTouched: sessions.size,
399
394
  byTier,
400
- estCostUsd: estimateCost(byTier),
395
+ // The commands that needed healing, start to end: what healing cost the run.
396
+ timeSpentMs,
401
397
  };
402
398
  };
403
399
  const current = aggregate(currentRows);
@@ -417,6 +413,7 @@ function getHealingSummary(request, response) {
417
413
  prior,
418
414
  resolvedCount,
419
415
  pendingCount,
416
+ trend: (0, healingTrend_1.dailyHeals)(currentRows.map((r) => ({ at: r.createdAt, method: r.healing_tier })), since, now, tz),
420
417
  });
421
418
  });
422
419
  }
@@ -624,7 +621,6 @@ function aggregateHotspots(opts) {
624
621
  distinctSelectors: buckets.size,
625
622
  sessionsTouched: sessionsTouched.size,
626
623
  byTier,
627
- estCostUsd: estimateCost(byTier),
628
624
  hotspots,
629
625
  };
630
626
  });
@@ -660,7 +656,6 @@ function getHealingViolations(request, response) {
660
656
  violationCount: agg.hotspots.length,
661
657
  totalHeals: agg.totalHeals,
662
658
  distinctSelectors: agg.distinctSelectors,
663
- estCostUsd: agg.estCostUsd,
664
659
  violations: agg.hotspots,
665
660
  });
666
661
  });
@@ -685,7 +680,6 @@ function sendHealingDigest(request, response) {
685
680
  windowDays,
686
681
  totalHeals: agg.totalHeals,
687
682
  distinctSelectors: agg.distinctSelectors,
688
- estCostUsd: agg.estCostUsd,
689
683
  hotspots: agg.hotspots,
690
684
  };
691
685
  const notifier = typedi_1.Container.get(NotificationService_1.NotificationService);
@@ -801,7 +795,6 @@ function getHealingSelectorDetail(request, response) {
801
795
  windowDays,
802
796
  healCount: rows.length,
803
797
  sessionCount: sessions.size,
804
- estCostUsd: estimateCost(byTier),
805
798
  byTier,
806
799
  byPlatform,
807
800
  byBuild: Object.entries(byBuild)
@@ -878,14 +871,24 @@ function getSelectorHealth(request, response) {
878
871
  }
879
872
  // SelectorState lifecycle action endpoint — mark fixed / mute / unmute /
880
873
  // cancel verification. The action vocabulary is closed (any value not in
881
- // VALID_ACTIONS rejects with 400). A SelectorStateConflictError surfaces as
882
- // 409 with `currentStatus`; anything else logs and surfaces as 500.
874
+ // VALID_ACTIONS rejects with 400). A member may act on a selector they can
875
+ // see (healed in a session they can see); any other answers 404, as an
876
+ // unknown one. The person acting is recorded (SelectorEvent), with the
877
+ // reason for a mute. A SelectorStateConflictError surfaces as 409 with
878
+ // `currentStatus`; anything else logs and surfaces as 500.
883
879
  const VALID_SELECTOR_ACTIONS = ['mark_fixed', 'mute', 'unmute', 'cancel_verification'];
880
+ /** The longest reason a mute may give. */
881
+ exports.MUTE_REASON_MAX = 500;
884
882
  function postSelectorStateAction(request, response) {
885
883
  return __awaiter(this, void 0, void 0, function* () {
886
- var _a, _b, _c, _d;
887
- const { original_strategy, original_selector, action } = ((_a = request.body) !== null && _a !== void 0 ? _a : {});
888
- if (!original_strategy || !original_selector || !action) {
884
+ var _a, _b, _c, _d, _e;
885
+ const { original_strategy, original_selector, action, reason } = ((_a = request.body) !== null && _a !== void 0 ? _a : {});
886
+ // The strategy may be '': heals recorded before strategies were.
887
+ if (typeof original_strategy !== 'string' ||
888
+ typeof original_selector !== 'string' ||
889
+ !original_selector ||
890
+ typeof action !== 'string' ||
891
+ !action) {
889
892
  return response.status(400).json({
890
893
  error: 'original_strategy, original_selector, and action are required',
891
894
  });
@@ -895,10 +898,29 @@ function postSelectorStateAction(request, response) {
895
898
  error: `action must be one of ${VALID_SELECTOR_ACTIONS.join(', ')}`,
896
899
  });
897
900
  }
898
- const apiKeyId = (_c = (_b = request.apiKey) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : '';
899
- const ctx = { strategy: original_strategy, selector: original_selector, apiKeyId };
901
+ if (reason !== undefined &&
902
+ reason !== null &&
903
+ (typeof reason !== 'string' || reason.length > exports.MUTE_REASON_MAX)) {
904
+ return response.status(400).json({
905
+ error: `reason must be text of at most ${exports.MUTE_REASON_MAX} characters`,
906
+ });
907
+ }
908
+ const muteReason = action === 'mute' && typeof reason === 'string' ? reason.trim() : '';
909
+ const ctx = {
910
+ strategy: original_strategy,
911
+ selector: original_selector,
912
+ apiKeyId: (_c = (_b = request.apiKey) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : '',
913
+ userId: (_d = (0, actor_1.resolveActor)(request).userId) !== null && _d !== void 0 ? _d : null,
914
+ reason: muteReason || null,
915
+ };
900
916
  const service = typedi_1.Container.get(SelectorStateService_1.SelectorStateService);
901
917
  try {
918
+ // Inside the try: a failed lookup answers 500 rather than leaving the
919
+ // request open (Express 4 doesn't catch a rejected handler).
920
+ const scope = (yield (0, sessionVisibility_1.visibleSessionWhere)(authOf(request)));
921
+ if (!(yield (0, access_1.canSeeSelector)({ strategy: original_strategy, selector: original_selector }, scope))) {
922
+ return response.status(404).json(access_1.SELECTOR_NOT_FOUND);
923
+ }
902
924
  let row;
903
925
  switch (action) {
904
926
  case 'mark_fixed':
@@ -920,7 +942,7 @@ function postSelectorStateAction(request, response) {
920
942
  if (err instanceof SelectorStateService_1.SelectorStateConflictError) {
921
943
  return response.status(409).json({ error: err.message, currentStatus: err.currentStatus });
922
944
  }
923
- logger_1.default.error(`[Dashboard] selector state action failed: ${(_d = err === null || err === void 0 ? void 0 : err.message) !== null && _d !== void 0 ? _d : err}`);
945
+ logger_1.default.error(`[Dashboard] selector state action failed: ${(_e = err === null || err === void 0 ? void 0 : err.message) !== null && _e !== void 0 ? _e : err}`);
924
946
  return response.status(500).json({ error: 'internal' });
925
947
  }
926
948
  });
@@ -1028,6 +1050,27 @@ function getProfilingData(request, response) {
1028
1050
  return response.status(200).json(profilingData);
1029
1051
  });
1030
1052
  }
1053
+ /** A session's CPU and memory samples, for the Performance panel. */
1054
+ function getSessionMetrics(request, response) {
1055
+ return __awaiter(this, void 0, void 0, function* () {
1056
+ var _a;
1057
+ const sessionId = request.params.sessionId;
1058
+ const [session, rows] = yield Promise.all([
1059
+ prisma_1.prisma.session.findFirst({
1060
+ where: { id: sessionId },
1061
+ select: { device_platform: true, status: true },
1062
+ }),
1063
+ prisma_1.prisma.sessionMetric.findMany({ where: { session_id: sessionId }, orderBy: { at: 'asc' } }),
1064
+ ]);
1065
+ // A running session's page says whether it is sampled, not "Collecting…" for one nothing samples.
1066
+ const recording = (session === null || session === void 0 ? void 0 : session.status) === 'running'
1067
+ ? typedi_1.Container.get(SessionMetricsService_1.SessionMetricsService).recordingState(sessionId)
1068
+ : null;
1069
+ return response
1070
+ .status(200)
1071
+ .json((0, metricsBody_1.sessionMetricsBody)((_a = session === null || session === void 0 ? void 0 : session.device_platform) !== null && _a !== void 0 ? _a : '', rows, recording));
1072
+ });
1073
+ }
1031
1074
  function streamLiveSessionVideo(request, response) {
1032
1075
  return __awaiter(this, void 0, void 0, function* () {
1033
1076
  var _a;
@@ -1140,6 +1183,7 @@ function register(router) {
1140
1183
  router.get('/session/:sessionId/logs/device', getDeviceLogs);
1141
1184
  router.get('/session/:sessionId/logs/debug', getDebugLogs);
1142
1185
  router.get('/session/:sessionId/profiling', getProfilingData);
1186
+ router.get('/session/:sessionId/metrics', getSessionMetrics);
1143
1187
  router.get('/session/:sessionId/asset/:kind/:file', getSessionAsset);
1144
1188
  // The healing reads count only heals from sessions the caller may see
1145
1189
  // (visibleSessionWhere); the digest below counts every team's.
@@ -1149,16 +1193,18 @@ function register(router) {
1149
1193
  router.get('/healing/hotspots/violations', getHealingViolations);
1150
1194
  router.get('/healing/selector', getHealingSelectorDetail);
1151
1195
  router.get('/healing/selector-health', getSelectorHealth);
1196
+ // The Selector Health page's list and panel (selector-health.ts).
1197
+ selector_health_1.default.register(router);
1152
1198
  // Outbound notification — admin only since it can fan out to every
1153
1199
  // configured webhook (Slack channels, etc.).
1154
1200
  router.post('/healing/digest/send', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), sendHealingDigest);
1155
- // SelectorState lifecycle: state mutations require admin (they affect what
1156
- // shows up in the live hotspot list, the CI gate, and the digest); the two
1157
- // reads inherit the existing dashboard auth. Both reads stay global, not
1158
- // team-scoped: a selector's mute or fix is one lab-wide row with no team
1159
- // column, shared by every team whose tests use that selector. The one
1160
- // heal-derived field, the muted list's `last_healed_at`, is the caller's.
1161
- router.post('/healing/selector/state', (0, roleGuard_1.roleGuard)('ADMIN'), (0, scopeGuard_1.scopeGuard)(['admin']), postSelectorStateAction);
1201
+ // SelectorState lifecycle: members act on selectors they can see (the
1202
+ // handler checks, and records who acted); the `sessions` scope is needed,
1203
+ // which `admin` implies. The two reads stay global, not team-scoped: a
1204
+ // selector's mute or fix is one lab-wide row with no team column, shared
1205
+ // by every team whose tests use that selector. The one heal-derived field,
1206
+ // the muted list's `last_healed_at`, is the caller's.
1207
+ router.post('/healing/selector/state', (0, roleGuard_1.roleGuard)('MEMBER'), (0, scopeGuard_1.scopeGuard)(['sessions']), postSelectorStateAction);
1162
1208
  router.get('/healing/state/muted', getMutedSelectors);
1163
1209
  router.get('/healing/state/:strategy/:value', getSelectorStateByTuple);
1164
1210
  router.get('/config', getGlobalConfig);
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.callerOf = void 0;
16
+ exports.scopeOf = scopeOf;
17
+ exports.getSelectorList = getSelectorList;
18
+ exports.getSelectorDetail = getSelectorDetail;
19
+ const logger_1 = __importDefault(require("../../logger"));
20
+ const sessionVisibility_1 = require("../../services/device-access/sessionVisibility");
21
+ const access_1 = require("../../services/selector-health/access");
22
+ const selectorDetail_1 = require("../../services/selector-health/selectorDetail");
23
+ const selectorList_1 = require("../../services/selector-health/selectorList");
24
+ const callerOf = (request) => request.auth;
25
+ exports.callerOf = callerOf;
26
+ /** The caller's sessions as a Session `where`; undefined for an admin, or with auth disabled. */
27
+ function scopeOf(request) {
28
+ return __awaiter(this, void 0, void 0, function* () {
29
+ return (yield (0, sessionVisibility_1.visibleSessionWhere)((0, exports.callerOf)(request)));
30
+ });
31
+ }
32
+ /** One tab of Selector Health's list, the four tab counts, and whether the caller may act. */
33
+ function getSelectorList(request, response) {
34
+ return __awaiter(this, void 0, void 0, function* () {
35
+ var _a, _b;
36
+ try {
37
+ const query = (0, selectorList_1.parseSelectorListQuery)(request.query);
38
+ const answer = yield (0, selectorList_1.listSelectors)(query, yield scopeOf(request));
39
+ return response
40
+ .status(200)
41
+ .json(Object.assign(Object.assign({}, answer), { canAct: (0, access_1.canActOnSelectors)((_a = (0, exports.callerOf)(request)) === null || _a === void 0 ? void 0 : _a.scopes) }));
42
+ }
43
+ catch (err) {
44
+ logger_1.default.error(`[SelectorHealth] list failed: ${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
45
+ return response.status(500).json({ error: 'internal' });
46
+ }
47
+ });
48
+ }
49
+ /** Everything the side panel shows for one selector, and whether the caller may act on it. */
50
+ function getSelectorDetail(request, response) {
51
+ return __awaiter(this, void 0, void 0, function* () {
52
+ var _a, _b;
53
+ const query = (0, selectorDetail_1.parseSelectorDetailQuery)(request.query);
54
+ if (!query) {
55
+ return response.status(400).json({ error: 'bad_request', message: 'selector is required' });
56
+ }
57
+ try {
58
+ const detail = yield (0, selectorDetail_1.selectorDetail)(query, yield scopeOf(request));
59
+ if (!detail)
60
+ return response.status(404).json(access_1.SELECTOR_NOT_FOUND);
61
+ return response
62
+ .status(200)
63
+ .json(Object.assign(Object.assign({}, detail), { canAct: (0, access_1.canActOnSelectors)((_a = (0, exports.callerOf)(request)) === null || _a === void 0 ? void 0 : _a.scopes) }));
64
+ }
65
+ catch (err) {
66
+ logger_1.default.error(`[SelectorHealth] detail failed: ${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
67
+ return response.status(500).json({ error: 'internal' });
68
+ }
69
+ });
70
+ }
71
+ function register(router) {
72
+ router.get('/healing/selectors', getSelectorList);
73
+ router.get('/healing/selectors/detail', getSelectorDetail);
74
+ }
75
+ exports.default = { register };
@@ -218,11 +218,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
218
218
  * post:
219
219
  * summary: Apply a lifecycle action to a selector
220
220
  * description: |
221
- * Mutates the SelectorState row for a (strategy, selector) tuple.
222
- * Actions are bounded — `mark_fixed`, `mute`, `unmute`,
223
- * `cancel_verification`. Returns 409 with `currentStatus` if the
224
- * action is incompatible with the row's current state (e.g.
225
- * mark_fixed on a muted selector).
221
+ * Mutates the SelectorState row for a (strategy, selector) tuple and
222
+ * records the change, with the person, in SelectorEvent. Actions are
223
+ * bounded — `mark_fixed`, `mute`, `unmute`, `cancel_verification`.
224
+ * Members and admins may act on a selector healed in a session they
225
+ * can see; any other answers 404, as an unknown one. Needs the
226
+ * `sessions` scope (`admin` implies it). `original_strategy` may be
227
+ * empty, for heals recorded with no strategy. Returns 409 with
228
+ * `currentStatus` if the action is incompatible with the row's
229
+ * current state (e.g. mark_fixed on a muted selector).
226
230
  * tags: [Selector Health]
227
231
  * security:
228
232
  * - apiKey: []
@@ -243,13 +247,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
243
247
  * action:
244
248
  * type: string
245
249
  * enum: [mark_fixed, mute, unmute, cancel_verification]
250
+ * reason: { type: string, maxLength: 500, description: 'Why the selector is muted; mute only' }
246
251
  * responses:
247
252
  * 200:
248
253
  * description: Updated SelectorState row
249
254
  * 400:
250
- * description: Missing fields or unknown action
255
+ * description: Missing fields, unknown action, or a reason over 500 characters
251
256
  * 403:
252
- * description: API key lacks `admin` scope
257
+ * description: The caller lacks the `sessions` scope
258
+ * 404:
259
+ * description: '`{ error: "not_found", message: "Selector not found" }`'
253
260
  * 409:
254
261
  * description: Action conflicts with current state (returns currentStatus)
255
262
  * 401: { $ref: '#/components/responses/Unauthorized' }
@@ -1221,6 +1228,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
1221
1228
  * - in: query
1222
1229
  * name: windowDays
1223
1230
  * schema: { type: integer, minimum: 1, maximum: 365, default: 30 }
1231
+ * - in: query
1232
+ * name: tz
1233
+ * schema: { type: integer, minimum: -840, maximum: 840, default: 0 }
1234
+ * description: "The caller's offset from UTC in minutes (east positive), so `trend` days are theirs"
1224
1235
  * responses:
1225
1236
  * 200:
1226
1237
  * description: KPI summary
@@ -1237,15 +1248,73 @@ Object.defineProperty(exports, "__esModule", { value: true });
1237
1248
  * distinctSelectors: { type: integer }
1238
1249
  * sessionsTouched: { type: integer }
1239
1250
  * byTier: { type: object, additionalProperties: { type: integer } }
1240
- * estCostUsd: { type: number }
1251
+ * timeSpentMs: { type: integer, description: 'Total duration of the commands that needed healing' }
1241
1252
  * prior:
1242
1253
  * type: object
1243
1254
  * description: 'Same shape as `current` for the immediately preceding window'
1244
1255
  * resolvedCount: { type: integer }
1245
1256
  * pendingCount: { type: integer }
1257
+ * trend:
1258
+ * type: array
1259
+ * description: 'Heals per day of the period, in the caller''s time zone, days with none included'
1260
+ * items:
1261
+ * type: object
1262
+ * properties:
1263
+ * t: { type: integer, description: 'When the day began, epoch ms' }
1264
+ * heals: { type: integer }
1265
+ * aiHeals: { type: integer, description: 'Heals by Visual AI or an LLM' }
1246
1266
  * 401: { $ref: '#/components/responses/Unauthorized' }
1247
1267
  * 429: { $ref: '#/components/responses/RateLimited' }
1248
1268
  */
1269
+ /**
1270
+ * @swagger
1271
+ * /api/healing/selectors:
1272
+ * get:
1273
+ * summary: One tab of the Selector Health list
1274
+ * description: |
1275
+ * `fix` groups the period's heals by selector and leaves out selectors
1276
+ * being verified, fixed or muted; the other tabs list the selectors with
1277
+ * that status (`fixed`: verified within the period). Only selectors
1278
+ * healed, at any time, in sessions the caller can see. `counts` follow
1279
+ * the period, not the search or filters. A page past the end answers the
1280
+ * last page.
1281
+ * tags: [Selector Health]
1282
+ * parameters:
1283
+ * - { in: query, name: tab, schema: { type: string, enum: [fix, verifying, fixed, muted], default: fix } }
1284
+ * - { in: query, name: days, schema: { type: integer, minimum: 1, maximum: 365, default: 30 } }
1285
+ * - { in: query, name: q, schema: { type: string }, description: 'Text in the selector, or (fix) in its suggested fix' }
1286
+ * - { in: query, name: platform, schema: { type: string }, description: 'fix only' }
1287
+ * - { in: query, name: method, schema: { type: string }, description: 'Healing method, e.g. LLM; fix only' }
1288
+ * - { in: query, name: sort, schema: { type: string, enum: [heals, recent, time], default: heals }, description: 'fix only' }
1289
+ * - { in: query, name: page, schema: { type: integer, minimum: 1, default: 1 } }
1290
+ * - { in: query, name: pageSize, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
1291
+ * responses:
1292
+ * 200: { description: '`{ tab, days, page, pageSize, total, counts, canAct, items }`' }
1293
+ * 401: { $ref: '#/components/responses/Unauthorized' }
1294
+ */
1295
+ /**
1296
+ * @swagger
1297
+ * /api/healing/selectors/detail:
1298
+ * get:
1299
+ * summary: Everything the Selector Health side panel shows for one selector
1300
+ * description: |
1301
+ * Its heals in the period (suggested fixes with their share, methods and
1302
+ * confidence; heals per day; platforms, builds and devices; the latest 20
1303
+ * heals), its status, the latest 50 status changes with who made them,
1304
+ * and whether the caller may act. A selector no session the caller can
1305
+ * see has healed answers 404, exactly as an unknown one.
1306
+ * tags: [Selector Health]
1307
+ * parameters:
1308
+ * - { in: query, name: selector, required: true, schema: { type: string } }
1309
+ * - { in: query, name: strategy, schema: { type: string }, description: "Empty for heals recorded with no strategy" }
1310
+ * - { in: query, name: days, schema: { type: integer, minimum: 1, maximum: 365, default: 30 } }
1311
+ * - { in: query, name: tz, schema: { type: integer, minimum: -840, maximum: 840, default: 0 } }
1312
+ * responses:
1313
+ * 200: { description: 'The panel for one selector' }
1314
+ * 400: { description: 'No selector given' }
1315
+ * 404: { description: '`{ error: "not_found", message: "Selector not found" }`' }
1316
+ * 401: { $ref: '#/components/responses/Unauthorized' }
1317
+ */
1249
1318
  /**
1250
1319
  * @swagger
1251
1320
  * /api/healing/digest/send: