@oneuptime/common 11.7.1 → 11.7.3

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 (285) hide show
  1. package/Models/AnalyticsModels/KubernetesCostAllocation.ts +103 -0
  2. package/Models/DatabaseModels/DashboardDomain.ts +1 -0
  3. package/Models/DatabaseModels/Incident.ts +2 -0
  4. package/Models/DatabaseModels/IncidentEpisode.ts +1 -0
  5. package/Models/DatabaseModels/IncidentEpisodePublicNote.ts +1 -0
  6. package/Models/DatabaseModels/IncidentEpisodeStateTimeline.ts +1 -0
  7. package/Models/DatabaseModels/IncidentPublicNote.ts +1 -0
  8. package/Models/DatabaseModels/IncidentStateTimeline.ts +1 -0
  9. package/Models/DatabaseModels/Monitor.ts +1 -0
  10. package/Models/DatabaseModels/MonitorStatusTimeline.ts +1 -0
  11. package/Models/DatabaseModels/NetworkSite.ts +1 -0
  12. package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +1 -0
  13. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +1 -0
  14. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +2 -0
  15. package/Models/DatabaseModels/Probe.ts +13 -13
  16. package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -0
  17. package/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +1 -0
  18. package/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.ts +1 -0
  19. package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -0
  20. package/Models/DatabaseModels/StatusPage.ts +1 -0
  21. package/Models/DatabaseModels/StatusPageAnnouncement.ts +1 -0
  22. package/Models/DatabaseModels/StatusPageDomain.ts +1 -0
  23. package/Models/DatabaseModels/TableView.ts +40 -0
  24. package/Models/DatabaseModels/WorkspaceNotificationSummary.ts +1 -0
  25. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +11 -0
  26. package/Server/API/BaseAPI.ts +17 -1
  27. package/Server/API/CommonAPI.ts +32 -0
  28. package/Server/API/MicrosoftTeamsAPI.ts +99 -2
  29. package/Server/API/ProbeAPI.ts +2 -0
  30. package/Server/API/SlackAPI.ts +62 -0
  31. package/Server/API/StatusPageAPI.ts +157 -192
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.ts +89 -0
  33. package/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.ts +61 -0
  34. package/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.ts +280 -0
  35. package/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.ts +13 -0
  36. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  37. package/Server/Services/DatabaseService.ts +11 -3
  38. package/Server/Services/MonitorGroupService.ts +145 -0
  39. package/Server/Services/MonitorService.ts +231 -30
  40. package/Server/Services/ProbeService.ts +319 -16
  41. package/Server/Services/StatusPageService.ts +72 -42
  42. package/Server/Services/WorkspaceNotificationRuleService.ts +232 -2
  43. package/Server/Utils/Database/PostgresErrorTranslator.ts +131 -0
  44. package/Server/Utils/Monitor/MonitorResource.ts +88 -35
  45. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +21 -1
  46. package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +158 -0
  47. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +706 -6
  48. package/Tests/Models/AnalyticsModels/KubernetesCostAllocation.test.ts +16 -0
  49. package/Tests/Models/DatabaseModels/ProbeColumnAccessControl.test.ts +103 -0
  50. package/Tests/Server/API/BaseAPIUpdateNoOp.test.ts +135 -0
  51. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +317 -0
  52. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +228 -0
  53. package/Tests/Server/API/ProbeAPI.test.ts +6 -0
  54. package/Tests/Server/API/ZZVerifyChatsAuth.test.ts +167 -0
  55. package/Tests/Server/Services/AddHotQueryIndexesMigration.test.ts +255 -0
  56. package/Tests/Server/Services/AddHotQueryIndexesSecondPassMigration.test.ts +258 -0
  57. package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +1 -1
  58. package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +121 -0
  59. package/Tests/Server/Services/HeartbeatWriteFastPathSafety.test.ts +200 -0
  60. package/Tests/Server/Services/HookFreeWriteSafetySecondPass.test.ts +181 -0
  61. package/Tests/Server/Services/MonitorGroupServiceBatchedStatuses.test.ts +513 -0
  62. package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +492 -0
  63. package/Tests/Server/Services/MonitorServiceProbeSelection.test.ts +401 -0
  64. package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +453 -0
  65. package/Tests/Server/Services/ProbeAuthKeyCacheAndHeartbeat.test.ts +679 -0
  66. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +3 -3
  67. package/Tests/Server/Services/StatusPageDomainResolutionCache.test.ts +245 -0
  68. package/Tests/Server/Services/StatusPageOverviewTimelineWindow.test.ts +283 -0
  69. package/Tests/Server/Services/StatusPageServiceMcp.test.ts +17 -1
  70. package/Tests/Server/Services/StatusPageServiceMonitorGroupBatching.test.ts +331 -0
  71. package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +12 -12
  72. package/Tests/Server/Services/WorkspaceNotificationRuleChats.test.ts +1390 -0
  73. package/Tests/Server/Utils/AI/Insights/InsightFixRouting.test.ts +4 -4
  74. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +2 -2
  75. package/Tests/Server/Utils/AI/Insights/InsightStore.test.ts +1 -3
  76. package/Tests/Server/Utils/AI/Insights/InsightStoreHardening.test.ts +1 -3
  77. package/Tests/Server/Utils/AI/Insights/InsightTriage.test.ts +1 -1
  78. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +5 -5
  79. package/Tests/Server/Utils/Alert/AlertPrivacyFilter.test.ts +187 -0
  80. package/Tests/Server/Utils/AlertEpisode/AlertEpisodePrivacyFilter.test.ts +170 -0
  81. package/Tests/Server/Utils/Database/PostgresErrorTranslator.test.ts +125 -0
  82. package/Tests/Server/Utils/Incident/IncidentPrivacyFilter.test.ts +264 -0
  83. package/Tests/Server/Utils/IncidentEpisode/IncidentEpisodePrivacyFilter.test.ts +175 -0
  84. package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +181 -0
  85. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +1 -1
  86. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +2 -2
  87. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelsList.test.ts +341 -0
  88. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +2175 -0
  89. package/Tests/Server/Utils/Workspace/SlackChannelsList.test.ts +585 -0
  90. package/Tests/Types/Billing/MeteredPlan.test.ts +46 -0
  91. package/Tests/Types/JSONFunctions.test.ts +103 -1
  92. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +219 -1
  93. package/Tests/UI/Components/BulkUpdateForm.test.tsx +174 -0
  94. package/Tests/UI/Components/CardModelDetailEdit.test.tsx +315 -0
  95. package/Tests/UI/Components/ModelDetailSelect.test.tsx +229 -0
  96. package/Tests/UI/Components/ModelTable/BaseModelTableBulkSelectAll.test.tsx +1258 -0
  97. package/Tests/UI/Components/ModelTable/BaseModelTableColumnCustomization.test.tsx +679 -0
  98. package/Tests/UI/Components/ModelTable/ColumnCustomizationModal.test.tsx +572 -0
  99. package/Tests/UI/Components/ModelTable/ColumnPreference.test.ts +1678 -0
  100. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +1094 -0
  101. package/Tests/UI/Components/ModelTableExportFromColumns.test.ts +153 -0
  102. package/Tests/UI/Components/MonacoLoader.test.ts +67 -0
  103. package/Tests/UI/Components/MonacoRuntime.test.ts +121 -0
  104. package/Tests/UI/Components/TableBulkCsvExport.test.tsx +212 -3
  105. package/Tests/UI/Utils/Project.test.ts +809 -0
  106. package/Tests/UI/Utils/TableColumnsToCsv.test.ts +360 -0
  107. package/Tests/Utils/Alerts/AlertMetricType.test.ts +143 -0
  108. package/Tests/Utils/Dashboard/VariableUrlState.test.ts +219 -0
  109. package/Tests/Utils/Incident/IncidentMetricType.test.ts +174 -0
  110. package/Tests/Utils/Monitor/MonitorProbeSelectionUtil.test.ts +145 -0
  111. package/Tests/Utils/StatusPage/ResourceUptime.test.ts +263 -0
  112. package/Tests/Utils/UserPreferences.test.ts +563 -0
  113. package/Types/JSONFunctions.ts +7 -2
  114. package/Types/Kubernetes/KubernetesCostIngest.ts +37 -1
  115. package/Types/Workspace/NotificationRules/BaseNotificationRule.ts +4 -0
  116. package/Types/Workspace/NotificationRules/NotificationRuleCondition.ts +25 -10
  117. package/Types/Workspace/WorkspaceMessagePayload.ts +1 -0
  118. package/UI/Components/BulkUpdate/BulkUpdateForm.tsx +63 -8
  119. package/UI/Components/CodeEditor/CodeEditor.tsx +3 -0
  120. package/UI/Components/CodeEditor/MonacoLoader.ts +36 -0
  121. package/UI/Components/Forms/ModelForm.tsx +41 -18
  122. package/UI/Components/MasterPage/MasterPage.tsx +65 -0
  123. package/UI/Components/ModelDetail/CardModelDetail.tsx +23 -1
  124. package/UI/Components/ModelDetail/ModelDetail.tsx +46 -1
  125. package/UI/Components/ModelTable/BaseModelTable.tsx +687 -34
  126. package/UI/Components/ModelTable/Column.ts +29 -0
  127. package/UI/Components/ModelTable/ColumnCustomizationModal.tsx +420 -0
  128. package/UI/Components/ModelTable/ColumnPreference.ts +482 -0
  129. package/UI/Components/ModelTable/CustomFieldColumns.tsx +326 -0
  130. package/UI/Components/ModelTable/ExportFromColumns.ts +57 -0
  131. package/UI/Components/ModelTable/TableView.tsx +24 -2
  132. package/UI/Components/ModelTable/useCustomFieldColumns.ts +150 -0
  133. package/UI/Components/Navbar/NavBar.tsx +66 -1
  134. package/UI/Components/SideMenu/SideMenu.tsx +24 -4
  135. package/UI/Components/Table/Table.tsx +66 -14
  136. package/UI/Components/Table/TableRow.tsx +180 -175
  137. package/UI/Components/Table/Types/Column.ts +20 -0
  138. package/UI/Utils/Project.ts +214 -18
  139. package/UI/Utils/TableColumnsToCsv.ts +86 -6
  140. package/UI/esbuild-config.js +34 -0
  141. package/Utils/Monitor/MonitorProbeSelectionUtil.ts +72 -0
  142. package/Utils/UserPreferences.ts +53 -0
  143. package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js +86 -0
  144. package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js.map +1 -1
  145. package/build/dist/Models/DatabaseModels/DashboardDomain.js +1 -0
  146. package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
  147. package/build/dist/Models/DatabaseModels/Incident.js +2 -0
  148. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  149. package/build/dist/Models/DatabaseModels/IncidentEpisode.js +1 -0
  150. package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
  151. package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js +1 -0
  152. package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js.map +1 -1
  153. package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js +1 -0
  154. package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js.map +1 -1
  155. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js +1 -0
  156. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js.map +1 -1
  157. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js +1 -0
  158. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js.map +1 -1
  159. package/build/dist/Models/DatabaseModels/Monitor.js +1 -0
  160. package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
  161. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +1 -0
  162. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
  163. package/build/dist/Models/DatabaseModels/NetworkSite.js +1 -0
  164. package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -1
  165. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +1 -0
  166. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -1
  167. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +1 -0
  168. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  169. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +2 -0
  170. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  171. package/build/dist/Models/DatabaseModels/Probe.js +13 -13
  172. package/build/dist/Models/DatabaseModels/Probe.js.map +1 -1
  173. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -0
  174. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  175. package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js +1 -0
  176. package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js.map +1 -1
  177. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js +1 -0
  178. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js.map +1 -1
  179. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -0
  180. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
  181. package/build/dist/Models/DatabaseModels/StatusPage.js +1 -0
  182. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  183. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +1 -0
  184. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
  185. package/build/dist/Models/DatabaseModels/StatusPageDomain.js +1 -0
  186. package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
  187. package/build/dist/Models/DatabaseModels/TableView.js +40 -0
  188. package/build/dist/Models/DatabaseModels/TableView.js.map +1 -1
  189. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js +1 -0
  190. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js.map +1 -1
  191. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  192. package/build/dist/Server/API/BaseAPI.js +12 -1
  193. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  194. package/build/dist/Server/API/CommonAPI.js +21 -0
  195. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  196. package/build/dist/Server/API/MicrosoftTeamsAPI.js +72 -2
  197. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  198. package/build/dist/Server/API/ProbeAPI.js +2 -0
  199. package/build/dist/Server/API/ProbeAPI.js.map +1 -1
  200. package/build/dist/Server/API/SlackAPI.js +44 -0
  201. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  202. package/build/dist/Server/API/StatusPageAPI.js +98 -152
  203. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  204. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js +36 -0
  205. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js.map +1 -0
  206. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js +26 -0
  207. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js.map +1 -0
  208. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js +254 -0
  209. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js.map +1 -0
  210. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.js +12 -0
  211. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.js.map +1 -0
  212. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  213. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  214. package/build/dist/Server/Services/DatabaseService.js +10 -2
  215. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  216. package/build/dist/Server/Services/MonitorGroupService.js +118 -0
  217. package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
  218. package/build/dist/Server/Services/MonitorService.js +198 -24
  219. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  220. package/build/dist/Server/Services/ProbeService.js +238 -9
  221. package/build/dist/Server/Services/ProbeService.js.map +1 -1
  222. package/build/dist/Server/Services/StatusPageService.js +46 -39
  223. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  224. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +178 -3
  225. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  226. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js +80 -0
  227. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js.map +1 -0
  228. package/build/dist/Server/Utils/Monitor/MonitorResource.js +67 -29
  229. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  230. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +31 -14
  231. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  232. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +117 -0
  233. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -0
  234. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +549 -7
  235. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  236. package/build/dist/Types/JSONFunctions.js +8 -3
  237. package/build/dist/Types/JSONFunctions.js.map +1 -1
  238. package/build/dist/Types/Kubernetes/KubernetesCostIngest.js.map +1 -1
  239. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js +17 -7
  240. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js.map +1 -1
  241. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js +37 -11
  242. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js.map +1 -1
  243. package/build/dist/UI/Components/CodeEditor/CodeEditor.js +2 -0
  244. package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
  245. package/build/dist/UI/Components/CodeEditor/MonacoLoader.js +34 -0
  246. package/build/dist/UI/Components/CodeEditor/MonacoLoader.js.map +1 -0
  247. package/build/dist/UI/Components/Forms/ModelForm.js +37 -17
  248. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  249. package/build/dist/UI/Components/MasterPage/MasterPage.js +50 -1
  250. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  251. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +22 -1
  252. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
  253. package/build/dist/UI/Components/ModelDetail/ModelDetail.js +31 -1
  254. package/build/dist/UI/Components/ModelDetail/ModelDetail.js.map +1 -1
  255. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +472 -27
  256. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  257. package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js +189 -0
  258. package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js.map +1 -0
  259. package/build/dist/UI/Components/ModelTable/ColumnPreference.js +258 -0
  260. package/build/dist/UI/Components/ModelTable/ColumnPreference.js.map +1 -0
  261. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +168 -0
  262. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -0
  263. package/build/dist/UI/Components/ModelTable/ExportFromColumns.js +30 -0
  264. package/build/dist/UI/Components/ModelTable/ExportFromColumns.js.map +1 -0
  265. package/build/dist/UI/Components/ModelTable/TableView.js +13 -2
  266. package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
  267. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +84 -0
  268. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -0
  269. package/build/dist/UI/Components/Navbar/NavBar.js +39 -1
  270. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  271. package/build/dist/UI/Components/SideMenu/SideMenu.js +15 -5
  272. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  273. package/build/dist/UI/Components/Table/Table.js +46 -13
  274. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  275. package/build/dist/UI/Components/Table/TableRow.js +11 -6
  276. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  277. package/build/dist/UI/Utils/Project.js +161 -15
  278. package/build/dist/UI/Utils/Project.js.map +1 -1
  279. package/build/dist/UI/Utils/TableColumnsToCsv.js +67 -6
  280. package/build/dist/UI/Utils/TableColumnsToCsv.js.map +1 -1
  281. package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js +52 -0
  282. package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js.map +1 -0
  283. package/build/dist/Utils/UserPreferences.js +33 -0
  284. package/build/dist/Utils/UserPreferences.js.map +1 -1
  285. package/package.json +4 -3
@@ -58,6 +58,24 @@ import TableColumn from "../Table/Types/Column";
58
58
  import FieldType from "../Types/FieldType";
59
59
  import ModelTableColumn from "./Column";
60
60
  import Columns from "./Columns";
61
+ import ColumnCustomizationModal from "./ColumnCustomizationModal";
62
+ import {
63
+ ColumnPreference,
64
+ CustomizableColumn,
65
+ applyColumnPreference,
66
+ buildColumnPreference,
67
+ fromJSON as columnPreferenceFromJSON,
68
+ getCustomizableColumns,
69
+ isEmptyColumnPreference,
70
+ sanitizeColumnPreference,
71
+ getColumnIds,
72
+ toJSON as columnPreferenceToJSON,
73
+ } from "./ColumnPreference";
74
+ import { CustomFieldsColumnKey } from "./CustomFieldColumns";
75
+ import useCustomFieldColumns, {
76
+ CustomFieldColumnsResult,
77
+ } from "./useCustomFieldColumns";
78
+ import { getExportKeysFromColumn } from "./ExportFromColumns";
61
79
  import {
62
80
  getRelationSelectFromColumns,
63
81
  getSelectFromColumns,
@@ -115,6 +133,21 @@ export enum ShowAs {
115
133
  OrderedStatesList,
116
134
  }
117
135
 
136
+ /*
137
+ * "Select All" loads the whole filtered result set - up to LIMIT_PER_PROJECT
138
+ * rows - with the same columns the table itself fetches. Asking for all of
139
+ * them in one request would mean a single wide, relation-joined query for up
140
+ * to 10,000 rows, which is exactly the kind of query the database statement
141
+ * timeout kills. It is paged instead: each request is comparable in cost to a
142
+ * large page load, so a big selection degrades in latency rather than failing.
143
+ *
144
+ * The page size trades those two costs off. Every list request also runs an
145
+ * unbounded count over the filtered set and pays a growing OFFSET, so smaller
146
+ * pages multiply that overhead; 1,000 keeps each query well inside the
147
+ * statement timeout while capping a maxed-out selection at ten round trips.
148
+ */
149
+ export const BULK_SELECT_ALL_PAGE_SIZE: number = 1000;
150
+
118
151
  export interface SaveFilterProps {
119
152
  tableId: string;
120
153
  }
@@ -307,6 +340,26 @@ export interface BaseTableProps<
307
340
  * outlive the page.
308
341
  */
309
342
  disableUrlState?: boolean | undefined;
343
+
344
+ /**
345
+ * Hide the "Columns" control and always render the declared column set.
346
+ *
347
+ * Column customization is on by default for every table that renders
348
+ * columns. Turn it off for tables whose layout is load-bearing — a
349
+ * two-column key/value table, or one the surrounding page reads positions
350
+ * out of.
351
+ */
352
+ disableColumnCustomization?: boolean | undefined;
353
+
354
+ /**
355
+ * The model holding this resource's custom field *definitions* — e.g.
356
+ * `MonitorCustomField` for a table of monitors. Set it and every custom
357
+ * field the project has defined becomes an optional column, off by default
358
+ * and listed in the column picker.
359
+ *
360
+ * Only meaningful for resources with a `customFields` column of their own.
361
+ */
362
+ customFieldsModelType?: { new (): BaseModel } | undefined;
310
363
  }
311
364
 
312
365
  export interface ComponentProps<
@@ -340,6 +393,28 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
340
393
  [],
341
394
  );
342
395
 
396
+ /*
397
+ * "Select All" gets its own loading / error state instead of sharing
398
+ * `isLoading` and `error` with fetchItems. They ran concurrently before, so
399
+ * a page refresh landing mid-selection flipped the spinner off and cleared
400
+ * the other's error - which is how a failed select-all could leave the user
401
+ * with only the current page selected and nothing on screen to say so.
402
+ */
403
+ const [isBulkSelectAllLoading, setIsBulkSelectAllLoading] =
404
+ useState<boolean>(false);
405
+ const [bulkSelectionError, setBulkSelectionError] = useState<string>("");
406
+ const [bulkSelectionTotalCount, setBulkSelectionTotalCount] =
407
+ useState<number>(0);
408
+ const [isBulkSelectionTruncated, setIsBulkSelectionTruncated] =
409
+ useState<boolean>(false);
410
+
411
+ /*
412
+ * The paged select-all spans several round trips, so the user can clear the
413
+ * selection or start another one while it is still running. Every run takes
414
+ * a token and drops its results if a newer one has since started.
415
+ */
416
+ const bulkSelectAllToken: MutableRefObject<number> = React.useRef<number>(0);
417
+
343
418
  let showAs: ShowAs | undefined = props.showAs;
344
419
 
345
420
  if (!showAs) {
@@ -357,6 +432,115 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
357
432
  [],
358
433
  );
359
434
 
435
+ /*
436
+ * ---------------------------------------------------------------------
437
+ * Viewer-customizable columns
438
+ * ---------------------------------------------------------------------
439
+ *
440
+ * The viewer's layout (which columns are on, and in what order) is a
441
+ * personal, presentational preference, so it lives in localStorage next to
442
+ * the page size rather than on the server: saved views are named, explicit,
443
+ * plan-gated and need write permission, none of which should stand between
444
+ * someone and hiding a column they don't care about. A saved view can still
445
+ * carry a layout of its own, and when one is active it wins.
446
+ *
447
+ * Custom fields are appended to the declared columns as additional,
448
+ * off-by-default columns; from here on they are ordinary columns.
449
+ */
450
+ const customFieldColumns: CustomFieldColumnsResult<TBaseModel> =
451
+ useCustomFieldColumns<TBaseModel>({
452
+ customFieldsModelType: props.customFieldsModelType,
453
+ });
454
+
455
+ const isColumnCustomizationEnabled: boolean = Boolean(
456
+ !props.disableColumnCustomization &&
457
+ props.userPreferencesKey &&
458
+ showAs !== ShowAs.OrderedStatesList,
459
+ );
460
+
461
+ const allColumns: Columns<TBaseModel> = useMemo(() => {
462
+ if (customFieldColumns.columns.length === 0) {
463
+ return props.columns || [];
464
+ }
465
+
466
+ return [...(props.columns || []), ...customFieldColumns.columns];
467
+ }, [props.columns, customFieldColumns.columns]);
468
+
469
+ type ReadStoredColumnPreferenceFunction = () => ColumnPreference | null;
470
+
471
+ const readStoredColumnPreference: ReadStoredColumnPreferenceFunction =
472
+ (): ColumnPreference | null => {
473
+ if (!props.userPreferencesKey) {
474
+ return null;
475
+ }
476
+
477
+ return columnPreferenceFromJSON(
478
+ UserPreferences.getUserPreferenceByTypeAsJSON({
479
+ key: props.userPreferencesKey,
480
+ userPreferenceType: UserPreferenceType.BaseModelTableColumns,
481
+ }),
482
+ );
483
+ };
484
+
485
+ const [columnPreference, setColumnPreference] =
486
+ useState<ColumnPreference | null>(readStoredColumnPreference);
487
+
488
+ const [showColumnCustomizationModal, setShowColumnCustomizationModal] =
489
+ useState<boolean>(false);
490
+
491
+ /*
492
+ * Ids are derived over the *whole* declared set, so a column dropping out
493
+ * for lack of permission can never renumber the ones that remain.
494
+ */
495
+ const allColumnIds: Array<string> = useMemo(() => {
496
+ return getColumnIds<TBaseModel>(allColumns);
497
+ }, [allColumns]);
498
+
499
+ const effectiveColumnPreference: ColumnPreference | null = useMemo(() => {
500
+ if (!isColumnCustomizationEnabled) {
501
+ return null;
502
+ }
503
+
504
+ /*
505
+ * A stored layout outlives the release that wrote it, so anything naming
506
+ * a column this table no longer has is dropped before it is applied.
507
+ */
508
+ return sanitizeColumnPreference({
509
+ preference: columnPreference,
510
+ knownColumnIds: allColumnIds,
511
+ });
512
+ }, [columnPreference, allColumnIds, isColumnCustomizationEnabled]);
513
+
514
+ type SaveColumnPreferenceFunction = (
515
+ preference: ColumnPreference | null,
516
+ ) => void;
517
+
518
+ const saveColumnPreference: SaveColumnPreferenceFunction = (
519
+ preference: ColumnPreference | null,
520
+ ): void => {
521
+ setColumnPreference(preference);
522
+
523
+ if (!props.userPreferencesKey) {
524
+ return;
525
+ }
526
+
527
+ const json: JSONObject | null = columnPreferenceToJSON(preference);
528
+
529
+ if (!json) {
530
+ UserPreferences.removeUserPreferenceByType({
531
+ key: props.userPreferencesKey,
532
+ userPreferenceType: UserPreferenceType.BaseModelTableColumns,
533
+ });
534
+ return;
535
+ }
536
+
537
+ UserPreferences.saveUserPreferenceByTypeAsJSON({
538
+ key: props.userPreferencesKey,
539
+ userPreferenceType: UserPreferenceType.BaseModelTableColumns,
540
+ value: json,
541
+ });
542
+ };
543
+
360
544
  const [classicTableFilters, setClassicTableFilters] = useState<
361
545
  Array<ClassicFilterType<TBaseModel>>
362
546
  >([]);
@@ -865,8 +1049,11 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
865
1049
 
866
1050
  const getRelationSelect: GetRelationSelectFunction =
867
1051
  (): Select<TBaseModel> => {
1052
+ /*
1053
+ * Deliberately the *unfiltered* column set. See getSelect() below.
1054
+ */
868
1055
  return getRelationSelectFromColumns<TBaseModel>({
869
- columns: props.columns || [],
1056
+ columns: allColumns,
870
1057
  model: model,
871
1058
  });
872
1059
  };
@@ -912,7 +1099,30 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
912
1099
  const accessControl: Dictionary<ColumnAccessControl> =
913
1100
  model.getColumnAccessControlForAllColumns();
914
1101
 
915
- for (const column of props.columns || []) {
1102
+ /*
1103
+ * The viewer's layout is applied to the *input* of this loop, never to
1104
+ * the finished array: the Actions column is appended below and has to
1105
+ * stay last, and it is generated rather than declared, so it is not the
1106
+ * viewer's to move or switch off.
1107
+ */
1108
+ const columnsToRender: Columns<TBaseModel> =
1109
+ applyColumnPreference<TBaseModel>({
1110
+ columns: allColumns,
1111
+ preference: effectiveColumnPreference,
1112
+ });
1113
+
1114
+ const columnIdByColumn: Map<
1115
+ ModelTableColumn<TBaseModel>,
1116
+ string
1117
+ > = new Map();
1118
+
1119
+ allColumns.forEach(
1120
+ (column: ModelTableColumn<TBaseModel>, index: number) => {
1121
+ columnIdByColumn.set(column, allColumnIds[index] as string);
1122
+ },
1123
+ );
1124
+
1125
+ for (const column of columnsToRender) {
916
1126
  const hasPermission: boolean =
917
1127
  hasPermissionToReadColumn(column) || User.isMasterAdmin();
918
1128
  const key: keyof TBaseModel | null = getColumnKey(column);
@@ -945,8 +1155,21 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
945
1155
 
946
1156
  columns.push({
947
1157
  ...column,
1158
+ id: columnIdByColumn.get(column),
948
1159
  disableSort: column.disableSort || shouldDisableSort(key),
949
1160
  key: columnKey,
1161
+ /*
1162
+ * `key` is only the first declared field, so a cell composed from
1163
+ * several of them would export just that one. Hand the exporter
1164
+ * every field the column declares (and that we actually selected).
1165
+ */
1166
+ exportKeys: getExportKeysFromColumn<TBaseModel>({
1167
+ column: column,
1168
+ columnKey: columnKey ? String(columnKey) : null,
1169
+ hasPermissionToReadField: (field: string): boolean => {
1170
+ return hasPermissionToReadField(field as keyof TBaseModel);
1171
+ },
1172
+ }),
950
1173
  tooltipText,
951
1174
  getElement: column.getElement,
952
1175
  });
@@ -1304,37 +1527,250 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1304
1527
  return fragment;
1305
1528
  };
1306
1529
 
1307
- const fetchAllBulkItems: PromiseVoidFunction = async (): Promise<void> => {
1308
- setError("");
1309
- setIsLoading(true);
1530
+ type FetchAllBulkItemsFunction = () => Promise<boolean>;
1310
1531
 
1311
- try {
1312
- const listResult: ListResult<TBaseModel> = await props.callbacks.getList({
1313
- modelType: props.modelType as
1314
- | DatabaseBaseModelType
1315
- | AnalyticsBaseModelType,
1316
- query: {
1317
- ...props.query,
1318
- ...query,
1319
- ...buildSearchQueryFragment(),
1320
- },
1321
- limit: LIMIT_PER_PROJECT,
1322
- skip: 0,
1323
- select: {
1324
- _id: true,
1325
- },
1326
- sort: {},
1327
- requestOptions: props.fetchRequestOptions,
1328
- });
1532
+ /*
1533
+ * Selects every row matching the table's current query, filters and search -
1534
+ * not just the rows on screen - and returns whether it succeeded.
1535
+ *
1536
+ * It fetches the SAME columns as fetchItems. It used to ask for
1537
+ * `select: { _id: true }`, which picked the right rows but stripped every
1538
+ * field off them, so "Export CSV" over a select-all wrote one blank line per
1539
+ * row and any bulk action that reads a field (or renders an item's name) saw
1540
+ * undefined. Routing the projection through getSelect() also keeps
1541
+ * `selectMoreFields` and per-field read permissions in play, which a
1542
+ * hand-rolled column list would silently drop.
1543
+ */
1544
+ const fetchAllBulkItems: FetchAllBulkItemsFunction =
1545
+ async (): Promise<boolean> => {
1546
+ const token: number = ++bulkSelectAllToken.current;
1329
1547
 
1330
- setBulkSelectedItems(listResult.data);
1331
- } catch (err) {
1332
- setError(API.getFriendlyMessage(err));
1333
- }
1548
+ setBulkSelectionError("");
1549
+ setIsBulkSelectionTruncated(false);
1550
+ setIsBulkSelectAllLoading(true);
1334
1551
 
1335
- setIsLoading(false);
1552
+ const itemsSelected: Array<TBaseModel> = [];
1553
+
1554
+ try {
1555
+ /*
1556
+ * An unsorted table is served in the API's default order, so the
1557
+ * selection has to ask for that order explicitly - once any sort is
1558
+ * sent, the server stops applying its default. Without this the rows
1559
+ * came back in random UUID order, which both scrambled the exported
1560
+ * CSV and, on a table past the selection ceiling, kept an arbitrary
1561
+ * slice of history instead of the newest rows the user was looking at.
1562
+ */
1563
+ const defaultSortColumn: string =
1564
+ (model as AnalyticsBaseModel).defaultSortColumn || "createdAt";
1565
+
1566
+ const bulkSort: Sort<TBaseModel> = (
1567
+ sortBy
1568
+ ? { [sortBy as string]: sortOrder }
1569
+ : { [defaultSortColumn]: SortOrder.Descending }
1570
+ ) as Sort<TBaseModel>;
1571
+
1572
+ /*
1573
+ * skip/limit paging is only stable over a total order. The field that
1574
+ * identifies a selected row (`_id` unless a table overrides it) is
1575
+ * appended as a tiebreaker so rows sharing a sort value - 1,200 labels
1576
+ * imported in one transaction all share a createdAt - cannot land on
1577
+ * two pages while another row is never returned at all.
1578
+ */
1579
+ if (
1580
+ (bulkSort as Dictionary<unknown>)[
1581
+ matchBulkSelectedItemByField as string
1582
+ ] === undefined
1583
+ ) {
1584
+ (bulkSort as Dictionary<SortOrder>)[
1585
+ matchBulkSelectedItemByField as string
1586
+ ] = SortOrder.Ascending;
1587
+ }
1588
+
1589
+ /*
1590
+ * getSelect() throws on a misconfigured column, so it is built inside
1591
+ * the try: otherwise the button would be left spinning forever with an
1592
+ * unhandled rejection instead of a message.
1593
+ */
1594
+ const bulkSelect: Select<TBaseModel> = {
1595
+ ...getSelect(),
1596
+ ...getRelationSelect(),
1597
+ };
1598
+
1599
+ /*
1600
+ * Every ordered column has to be selected for the database to order by
1601
+ * it. `_id` always is; the default sort column usually is not, and a
1602
+ * table matching its selection on some field other than `_id` would
1603
+ * otherwise tick no row checkboxes at all.
1604
+ */
1605
+ for (const sortColumn of Object.keys(bulkSort)) {
1606
+ if (
1607
+ (bulkSelect as Dictionary<unknown>)[sortColumn] === undefined &&
1608
+ model.hasColumn(sortColumn)
1609
+ ) {
1610
+ (bulkSelect as Dictionary<boolean>)[sortColumn] = true;
1611
+ }
1612
+ }
1613
+
1614
+ let totalCount: number = 0;
1615
+ let hasMore: boolean = false;
1616
+ let rowsFetched: number = 0;
1617
+
1618
+ /*
1619
+ * Offset paging over a table that is still receiving writes can hand
1620
+ * back a row that an earlier page already returned. Selecting it twice
1621
+ * would duplicate it in the export and inflate the selected count.
1622
+ */
1623
+ const seenItemIds: Set<string> = new Set<string>();
1624
+
1625
+ while (itemsSelected.length < LIMIT_PER_PROJECT) {
1626
+ const limit: number = Math.min(
1627
+ BULK_SELECT_ALL_PAGE_SIZE,
1628
+ LIMIT_PER_PROJECT - itemsSelected.length,
1629
+ );
1630
+
1631
+ const listResult: ListResult<TBaseModel> =
1632
+ await props.callbacks.getList({
1633
+ modelType: props.modelType as
1634
+ | DatabaseBaseModelType
1635
+ | AnalyticsBaseModelType,
1636
+ query: {
1637
+ ...props.query,
1638
+ ...query,
1639
+ ...buildSearchQueryFragment(),
1640
+ },
1641
+ groupBy: {
1642
+ ...props.groupBy,
1643
+ },
1644
+ limit: limit,
1645
+ skip: rowsFetched,
1646
+ select: bulkSelect,
1647
+ sort: bulkSort,
1648
+ requestOptions: props.fetchRequestOptions,
1649
+ });
1650
+
1651
+ if (token !== bulkSelectAllToken.current) {
1652
+ // A newer select-all, or a cleared selection, superseded this run.
1653
+ return false;
1654
+ }
1655
+
1656
+ totalCount = listResult.count || totalCount;
1657
+ hasMore = Boolean(listResult.hasMore);
1658
+ rowsFetched += listResult.data.length;
1659
+
1660
+ for (const item of listResult.data) {
1661
+ const itemId: string =
1662
+ item[matchBulkSelectedItemByField]?.toString() || "";
1663
+
1664
+ if (itemId) {
1665
+ if (seenItemIds.has(itemId)) {
1666
+ continue;
1667
+ }
1668
+
1669
+ seenItemIds.add(itemId);
1670
+ }
1671
+
1672
+ itemsSelected.push(item);
1673
+ }
1674
+
1675
+ if (listResult.data.length < limit) {
1676
+ // A short page is the last page.
1677
+ hasMore = false;
1678
+ break;
1679
+ }
1680
+
1681
+ if (listResult.hasMore === false) {
1682
+ break;
1683
+ }
1684
+
1685
+ if (totalCount > 0 && rowsFetched >= totalCount) {
1686
+ /*
1687
+ * Everything matching has been read. Stopping here saves the
1688
+ * empty round trip a result set that is an exact multiple of the
1689
+ * page size would otherwise cost - and each of those requests
1690
+ * runs a full count over the filtered set.
1691
+ */
1692
+ break;
1693
+ }
1694
+ }
1695
+
1696
+ setBulkSelectionTotalCount(Math.max(totalCount, itemsSelected.length));
1697
+ setIsBulkSelectionTruncated(totalCount > rowsFetched || hasMore);
1698
+ setBulkSelectedItems(itemsSelected);
1699
+
1700
+ return true;
1701
+ } catch (err) {
1702
+ if (token === bulkSelectAllToken.current) {
1703
+ /*
1704
+ * Leave the existing selection alone. A half-loaded selection that
1705
+ * claims to be everything is worse than a failed one, and the user
1706
+ * can retry because the Select All button stays on screen.
1707
+ */
1708
+ setBulkSelectionError(API.getFriendlyMessage(err));
1709
+ }
1710
+
1711
+ return false;
1712
+ } finally {
1713
+ if (token === bulkSelectAllToken.current) {
1714
+ setIsBulkSelectAllLoading(false);
1715
+ }
1716
+ }
1717
+ };
1718
+
1719
+ type ClearBulkSelectionStateFunction = () => void;
1720
+
1721
+ const clearBulkSelectionState: ClearBulkSelectionStateFunction = (): void => {
1722
+ // Supersede any select-all still in flight so it cannot repopulate this.
1723
+ bulkSelectAllToken.current++;
1724
+ setIsBulkSelectAllLoading(false);
1725
+ setBulkSelectionError("");
1726
+ setIsBulkSelectionTruncated(false);
1727
+ setBulkSelectionTotalCount(0);
1728
+ setBulkSelectedItems((existingItems: Array<TBaseModel>) => {
1729
+ /*
1730
+ * Keep the same array when there is nothing to clear, so calling this on
1731
+ * every query change (below) costs an idle table no re-render.
1732
+ */
1733
+ return existingItems.length === 0 ? existingItems : [];
1734
+ });
1336
1735
  };
1337
1736
 
1737
+ /*
1738
+ * Everything that decides *which rows match* - the caller's query, the
1739
+ * column filters, the search term and the label chips. Serialised because
1740
+ * callers routinely pass `query` as a fresh object literal on every render,
1741
+ * and the label chips get their display names hydrated after mount without
1742
+ * the set of ids ever changing.
1743
+ *
1744
+ * Sort and page are deliberately left out: they re-order or re-window the
1745
+ * same matching set, so a selection made under them is still a selection of
1746
+ * rows that match. Selecting rows across pages is a real workflow, and a
1747
+ * truncated select-all keeps saying "selected N of M matching" after a
1748
+ * re-sort, which stays true.
1749
+ */
1750
+ const effectiveQueryKey: string = JSON.stringify({
1751
+ props: props.query || {},
1752
+ filter: query,
1753
+ search: debouncedSearchText.trim(),
1754
+ labels: selectedLabelIdsKey,
1755
+ });
1756
+
1757
+ /*
1758
+ * A selection refers to the rows the *previous* query returned. Once the
1759
+ * query changes, the table underneath the bulk bar is a different result
1760
+ * set, so carrying the selection over leaves the bar claiming "6,000 Alerts
1761
+ * Selected" above a filtered-down list of 12 - with the Select All button
1762
+ * hidden, as though the selection matched what is on screen, and Delete
1763
+ * still wired to all 6,000 rows the user can no longer see.
1764
+ *
1765
+ * Dropping it here also bumps `bulkSelectAllToken`, so a filter change that
1766
+ * lands while a paged select-all is still running supersedes that run
1767
+ * instead of letting it finish and repopulate the selection against the old
1768
+ * query.
1769
+ */
1770
+ useEffect(() => {
1771
+ clearBulkSelectionState();
1772
+ }, [effectiveQueryKey]);
1773
+
1338
1774
  const fetchItems: PromiseVoidFunction = async (): Promise<void> => {
1339
1775
  setError("");
1340
1776
  setIsLoading(true);
@@ -1429,14 +1865,39 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1429
1865
  type GetSelectFunction = () => Select<TBaseModel>;
1430
1866
 
1431
1867
  const getSelect: GetSelectFunction = (): Select<TBaseModel> => {
1868
+ /*
1869
+ * Every declared column, including the ones the viewer has switched off.
1870
+ *
1871
+ * Hiding must not narrow the request. A column's `getElement` is arbitrary
1872
+ * caller code that can read any field on the row — a "Status" cell that
1873
+ * also reads `currentMonitorStatus`, say — so dropping a hidden column's
1874
+ * fields from the select would silently blank a *different*, visible cell,
1875
+ * and there is no way to detect that statically. The sort field is
1876
+ * likewise independent of what is on screen. The cost is a few unused
1877
+ * fields on the wire.
1878
+ */
1432
1879
  const selectFields: Select<TBaseModel> = getSelectFromColumns<TBaseModel>({
1433
- columns: props.columns || [],
1880
+ columns: allColumns,
1434
1881
  model: model,
1435
1882
  hasPermissionToReadField: (field: string): boolean => {
1436
1883
  return hasPermissionToReadField(field as keyof TBaseModel);
1437
1884
  },
1438
1885
  });
1439
1886
 
1887
+ /*
1888
+ * Custom field columns arrive asynchronously, but the table does not
1889
+ * refetch when its column set changes — so the JSON column that backs
1890
+ * every one of them is selected up front, off the synchronous prop, and
1891
+ * the values are there the moment the definitions land.
1892
+ */
1893
+ if (
1894
+ props.customFieldsModelType &&
1895
+ model.hasColumn(CustomFieldsColumnKey) &&
1896
+ hasPermissionToReadField(CustomFieldsColumnKey as keyof TBaseModel)
1897
+ ) {
1898
+ (selectFields as Dictionary<boolean>)[CustomFieldsColumnKey] = true;
1899
+ }
1900
+
1440
1901
  const selectMoreFields: Array<keyof TBaseModel> = props.selectMoreFields
1441
1902
  ? (Object.keys(props.selectMoreFields) as Array<keyof TBaseModel>)
1442
1903
  : [];
@@ -1488,6 +1949,9 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1488
1949
  currentItemsOnPage={itemsOnPage}
1489
1950
  currentSortOrder={sortOrder}
1490
1951
  currentFacetState={props.currentFacetState}
1952
+ currentColumns={
1953
+ columnPreferenceToJSON(effectiveColumnPreference) || undefined
1954
+ }
1491
1955
  onViewChange={async (tableView: TableView | null) => {
1492
1956
  setTableView(tableView);
1493
1957
 
@@ -1522,6 +1986,20 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1522
1986
  (tableView.facets as JSONObject | undefined) || null,
1523
1987
  );
1524
1988
  }
1989
+
1990
+ /*
1991
+ * A saved view carries the columns it was saved with. Views
1992
+ * created before this existed have none, which restores the
1993
+ * table's default layout rather than leaving the previous
1994
+ * view's columns in place.
1995
+ */
1996
+ if (isColumnCustomizationEnabled) {
1997
+ saveColumnPreference(
1998
+ columnPreferenceFromJSON(
1999
+ (tableView.columns as JSONObject | undefined) || null,
2000
+ ),
2001
+ );
2002
+ }
1525
2003
  } else {
1526
2004
  setQuery({});
1527
2005
  /*
@@ -1539,6 +2017,13 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1539
2017
  if (props.onFacetStateRestored) {
1540
2018
  props.onFacetStateRestored(null);
1541
2019
  }
2020
+
2021
+ /*
2022
+ * Columns are deliberately left alone here. Clearing a saved
2023
+ * view means "stop filtering like that", not "throw away the
2024
+ * column layout I built" - which the viewer may well have set
2025
+ * up long before this view existed.
2026
+ */
1542
2027
  }
1543
2028
  }}
1544
2029
  />
@@ -1672,6 +2157,23 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1672
2157
  });
1673
2158
  }
1674
2159
 
2160
+ /*
2161
+ * Only worth offering once there is a choice to make. A single-column
2162
+ * table has nothing to hide and nothing to reorder.
2163
+ */
2164
+ if (isColumnCustomizationEnabled && allColumns.length > 1) {
2165
+ headerbuttons.push({
2166
+ title: "",
2167
+ buttonStyle: ButtonStyleType.ICON,
2168
+ buttonSize: ButtonSize.Small,
2169
+ className: "",
2170
+ onClick: () => {
2171
+ setShowColumnCustomizationModal(true);
2172
+ },
2173
+ icon: IconProp.TableCells,
2174
+ });
2175
+ }
2176
+
1675
2177
  setCardButtons(headerbuttons);
1676
2178
  };
1677
2179
 
@@ -1845,6 +2347,14 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1845
2347
  serializeToTableColumns();
1846
2348
  }, [props.columns]);
1847
2349
 
2350
+ /*
2351
+ * The viewer changed their layout, or the project's custom field
2352
+ * definitions finally arrived.
2353
+ */
2354
+ useEffect(() => {
2355
+ serializeToTableColumns();
2356
+ }, [effectiveColumnPreference, customFieldColumns.columns]);
2357
+
1848
2358
  const setActionSchema: VoidFunction = () => {
1849
2359
  const permissions: Array<Permission> = PermissionUtil.getAllPermissions();
1850
2360
 
@@ -2236,15 +2746,18 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
2236
2746
  };
2237
2747
  })()}
2238
2748
  onBulkActionEnd={async () => {
2239
- setBulkSelectedItems([]);
2749
+ clearBulkSelectionState();
2240
2750
  await fetchItems();
2241
2751
  }}
2242
2752
  onBulkActionStart={() => {}}
2243
2753
  bulkSelectedItems={bulkSelectedItems}
2244
2754
  onBulkSelectedItemAdded={(item: TBaseModel) => {
2755
+ // The user has moved on from the failed select-all.
2756
+ setBulkSelectionError("");
2245
2757
  setBulkSelectedItems([...bulkSelectedItems, item]);
2246
2758
  }}
2247
2759
  onBulkSelectedItemRemoved={(item: TBaseModel) => {
2760
+ setBulkSelectionError("");
2248
2761
  setBulkSelectedItems(
2249
2762
  bulkSelectedItems.filter((i: TBaseModel) => {
2250
2763
  return (
@@ -2278,11 +2791,15 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
2278
2791
  setBulkSelectedItems(uniqueItems);
2279
2792
  }}
2280
2793
  onBulkClearAllItems={() => {
2281
- setBulkSelectedItems([]);
2794
+ clearBulkSelectionState();
2282
2795
  }}
2283
- onBulkSelectAllItems={async () => {
2284
- await fetchAllBulkItems();
2796
+ onBulkSelectAllItems={async (): Promise<boolean> => {
2797
+ return await fetchAllBulkItems();
2285
2798
  }}
2799
+ bulkSelectionError={bulkSelectionError}
2800
+ isBulkSelectAllLoading={isBulkSelectAllLoading}
2801
+ isBulkSelectionTruncated={isBulkSelectionTruncated}
2802
+ bulkSelectionTotalCount={bulkSelectionTotalCount}
2286
2803
  matchBulkSelectedItemByField={matchBulkSelectedItemByField || "_id"}
2287
2804
  bulkItemToString={(item: TBaseModel) => {
2288
2805
  const label: string = props.singularName || item.singularName || "";
@@ -3105,6 +3622,8 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3105
3622
  return "Watch Demo";
3106
3623
  case IconProp.Search:
3107
3624
  return "Search";
3625
+ case IconProp.TableCells:
3626
+ return "Columns";
3108
3627
  default:
3109
3628
  return "Action";
3110
3629
  }
@@ -3311,6 +3830,133 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3311
3830
  return originalTitle;
3312
3831
  };
3313
3832
 
3833
+ type IsPickableColumnFunction = (
3834
+ column: ModelTableColumn<TBaseModel>,
3835
+ ) => boolean;
3836
+
3837
+ /*
3838
+ * A column the viewer cannot read is left out of the picker entirely.
3839
+ * Listing it would advertise a field they have no access to, and switching
3840
+ * it on would put that field in the select — which the API rejects for the
3841
+ * whole request, blanking the table.
3842
+ */
3843
+ const isPickableColumn: IsPickableColumnFunction = (
3844
+ column: ModelTableColumn<TBaseModel>,
3845
+ ): boolean => {
3846
+ if (column.isNotCustomizable) {
3847
+ return false;
3848
+ }
3849
+
3850
+ return hasPermissionToReadColumn(column) || User.isMasterAdmin();
3851
+ };
3852
+
3853
+ type GetPickerEntriesFunction = (
3854
+ preference: ColumnPreference | null,
3855
+ ) => Array<CustomizableColumn<TBaseModel>>;
3856
+
3857
+ const getPickerEntries: GetPickerEntriesFunction = (
3858
+ preference: ColumnPreference | null,
3859
+ ): Array<CustomizableColumn<TBaseModel>> => {
3860
+ return getCustomizableColumns<TBaseModel>({
3861
+ columns: allColumns,
3862
+ preference: preference,
3863
+ }).filter((entry: CustomizableColumn<TBaseModel>) => {
3864
+ return isPickableColumn(entry.column);
3865
+ });
3866
+ };
3867
+
3868
+ type GetColumnSortKeyFunction = (
3869
+ column: ModelTableColumn<TBaseModel>,
3870
+ ) => string | null;
3871
+
3872
+ // Mirrors how serializeToTableColumns derives the key the header sorts on.
3873
+ const getColumnSortKey: GetColumnSortKeyFunction = (
3874
+ column: ModelTableColumn<TBaseModel>,
3875
+ ): string | null => {
3876
+ const key: keyof TBaseModel | null = getColumnKey(column);
3877
+
3878
+ if (!key) {
3879
+ return null;
3880
+ }
3881
+
3882
+ return column.selectedProperty
3883
+ ? `${String(key)}.${column.selectedProperty}`
3884
+ : String(key);
3885
+ };
3886
+
3887
+ type OnColumnCustomizationSaveFunction = (
3888
+ entries: Array<CustomizableColumn<TBaseModel>>,
3889
+ ) => void;
3890
+
3891
+ const onColumnCustomizationSave: OnColumnCustomizationSaveFunction = (
3892
+ entries: Array<CustomizableColumn<TBaseModel>>,
3893
+ ): void => {
3894
+ const preference: ColumnPreference =
3895
+ buildColumnPreference<TBaseModel>(entries);
3896
+
3897
+ /*
3898
+ * If the layout the viewer just saved matches what the table ships with,
3899
+ * store nothing. Otherwise every table anyone ever opened the picker on
3900
+ * would be pinned to the column set of the release they opened it in, and
3901
+ * columns added later would arrive already stale.
3902
+ */
3903
+ const defaultPreference: ColumnPreference =
3904
+ buildColumnPreference<TBaseModel>(getPickerEntries(null));
3905
+
3906
+ const isBackToDefault: boolean =
3907
+ JSON.stringify(preference) === JSON.stringify(defaultPreference);
3908
+
3909
+ /*
3910
+ * Sorting by a column that is no longer on screen leaves the viewer with
3911
+ * an ordering they can neither see nor undo — there is no header left to
3912
+ * click. Fall back to the table's own default sort.
3913
+ */
3914
+ if (sortBy) {
3915
+ const hiddenIds: Set<string> = new Set(preference.hidden);
3916
+
3917
+ const isSortedColumnHidden: boolean = entries.some(
3918
+ (entry: CustomizableColumn<TBaseModel>) => {
3919
+ return (
3920
+ hiddenIds.has(entry.id) &&
3921
+ getColumnSortKey(entry.column) === String(sortBy)
3922
+ );
3923
+ },
3924
+ );
3925
+
3926
+ if (isSortedColumnHidden) {
3927
+ setSortBy((props.sortBy as keyof TBaseModel | undefined) || null);
3928
+ setSortOrder(props.sortOrder || SortOrder.Ascending);
3929
+ setCurrentPageNumber(1);
3930
+ }
3931
+ }
3932
+
3933
+ saveColumnPreference(isBackToDefault ? null : preference);
3934
+ setShowColumnCustomizationModal(false);
3935
+ };
3936
+
3937
+ const getColumnCustomizationModal: () => ReactElement | null =
3938
+ (): ReactElement | null => {
3939
+ if (!showColumnCustomizationModal || !isColumnCustomizationEnabled) {
3940
+ return null;
3941
+ }
3942
+
3943
+ return (
3944
+ <ColumnCustomizationModal<TBaseModel>
3945
+ columns={getPickerEntries(effectiveColumnPreference)}
3946
+ isDefaultLayout={isEmptyColumnPreference(effectiveColumnPreference)}
3947
+ title={tx("Customize Columns")}
3948
+ onSave={onColumnCustomizationSave}
3949
+ onReset={() => {
3950
+ saveColumnPreference(null);
3951
+ setShowColumnCustomizationModal(false);
3952
+ }}
3953
+ onClose={() => {
3954
+ setShowColumnCustomizationModal(false);
3955
+ }}
3956
+ />
3957
+ );
3958
+ };
3959
+
3314
3960
  const getCardComponent: GetReactElementFunction = (): ReactElement => {
3315
3961
  const headerButtons: Array<CardButtonSchema | ReactElement> =
3316
3962
  getHeaderButtonsWithSearch();
@@ -3331,7 +3977,12 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3331
3977
  getCardTitle(props.cardProps.title || ""),
3332
3978
  )}
3333
3979
  >
3334
- {tableColumns.length === 0 && props.columns.length > 0 ? (
3980
+ {/*
3981
+ * A viewer's layout can never empty this out — applyColumnPreference
3982
+ * refuses to return nothing — so zero columns still means exactly
3983
+ * what it always did: every column was denied by permission.
3984
+ */}
3985
+ {tableColumns.length === 0 && allColumns.length > 0 ? (
3335
3986
  <ErrorMessage
3336
3987
  message={`You are not authorized to view this table. You need any one of these permissions: ${PermissionHelper.getPermissionTitles(
3337
3988
  model.getReadPermissions(),
@@ -3529,6 +4180,8 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3529
4180
  </div>
3530
4181
  </Modal>
3531
4182
  )}
4183
+
4184
+ {getColumnCustomizationModal()}
3532
4185
  </>
3533
4186
  );
3534
4187
  };