shareddata 6.85.14__tar.gz → 6.85.15__tar.gz

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 (188) hide show
  1. {shareddata-6.85.14/src/shareddata.egg-info → shareddata-6.85.15}/PKG-INFO +1 -1
  2. {shareddata-6.85.14 → shareddata-6.85.15}/setup.py +1 -1
  3. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/JobStore.py +2 -2
  4. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/WorkerLib.py +38 -1
  5. shareddata-6.85.15/src/SharedData/Web/mongo_view.py +399 -0
  6. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/proc.py +86 -9
  7. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/standalone.py +2 -1
  8. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/standalone.css +18 -3
  9. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/standalone.js +120 -24
  10. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/standalone.html +3 -2
  11. {shareddata-6.85.14 → shareddata-6.85.15/src/shareddata.egg-info}/PKG-INFO +1 -1
  12. shareddata-6.85.14/src/SharedData/Web/mongo_view.py +0 -125
  13. {shareddata-6.85.14 → shareddata-6.85.15}/LICENSE +0 -0
  14. {shareddata-6.85.14 → shareddata-6.85.15}/MANIFEST.in +0 -0
  15. {shareddata-6.85.14 → shareddata-6.85.15}/README.md +0 -0
  16. {shareddata-6.85.14 → shareddata-6.85.15}/pyproject.toml +0 -0
  17. {shareddata-6.85.14 → shareddata-6.85.15}/setup.cfg +0 -0
  18. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/ServerGunicorn.py +0 -0
  19. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/ServerWaitress.py +0 -0
  20. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/__init__.py +0 -0
  21. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/apidocs-dark.css +0 -0
  22. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/apidocs.yml +0 -0
  23. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/auth.py +0 -0
  24. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/constants.py +0 -0
  25. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/download_paths.py +0 -0
  26. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/download_slots.py +0 -0
  27. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/install/worker.ps1 +0 -0
  28. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/install/worker.sh +0 -0
  29. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/listcache.py +0 -0
  30. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/__init__.py +0 -0
  31. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/cache.py +0 -0
  32. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/collections.py +0 -0
  33. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/data.py +0 -0
  34. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/downloads.py +0 -0
  35. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/metadata.py +0 -0
  36. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/operations.py +0 -0
  37. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/schedules.py +0 -0
  38. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/sqlite.py +0 -0
  39. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/system.py +0 -0
  40. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/tables.py +0 -0
  41. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/timeseries.py +0 -0
  42. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/web_routes.py +0 -0
  43. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/workers.py +0 -0
  44. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/utils.py +0 -0
  45. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Auth/__init__.py +0 -0
  46. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Auth/bootstrap.py +0 -0
  47. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Auth/migrate.py +0 -0
  48. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Auth/store.py +0 -0
  49. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Auth/workers.py +0 -0
  50. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/CacheRedis.py +0 -0
  51. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/CollectionMongoDB.py +0 -0
  52. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/__init__.py +0 -0
  53. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/catalog.py +0 -0
  54. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/config.py +0 -0
  55. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/errors.py +0 -0
  56. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/indexes.py +0 -0
  57. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/readers.py +0 -0
  58. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/resolve.py +0 -0
  59. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/service.py +0 -0
  60. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Database.py +0 -0
  61. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Defaults.py +0 -0
  62. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/AWSEC2.py +0 -0
  63. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/AWSS3.py +0 -0
  64. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/AutoDocstrings.py +0 -0
  65. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/ClientAPI.py +0 -0
  66. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/ClientSocket.py +0 -0
  67. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/ClientWebSocket.py +0 -0
  68. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/LogHandlerAPI.py +0 -0
  69. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/LogReader.py +0 -0
  70. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/MongoDBClient.py +0 -0
  71. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/SaveTables.py +0 -0
  72. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/ServerSocket.py +0 -0
  73. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/ServerWebSocket.py +0 -0
  74. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/StreamsCache.py +0 -0
  75. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/StreamsPersist.py +0 -0
  76. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/SyncTable.py +0 -0
  77. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/TunnelWebSocket.py +0 -0
  78. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/__init__.py +0 -0
  79. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Listing.py +0 -0
  80. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/LockWaiters.py +0 -0
  81. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Logger.py +0 -0
  82. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/MCP/__init__.py +0 -0
  83. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/MCP/__main__.py +0 -0
  84. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/MCP/client.py +0 -0
  85. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/MCP/server.py +0 -0
  86. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Metadata.py +0 -0
  87. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/MultiProc.py +0 -0
  88. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/OpenFIGI.py +0 -0
  89. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Redaction.py +0 -0
  90. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/ArchiveOldCollections.py +0 -0
  91. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/BatchJob.py +0 -0
  92. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/ContainerHealth.py +0 -0
  93. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/RoutineCommand.py +0 -0
  94. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/RoutineOrigin.py +0 -0
  95. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/Schedule.py +0 -0
  96. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/ScheduleMirror.py +0 -0
  97. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
  98. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/ScheduleStore.py +0 -0
  99. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/Scheduler.py +0 -0
  100. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/UpdateActiveJobs.py +0 -0
  101. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/Worker.py +0 -0
  102. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/WorkerBootstrap.py +0 -0
  103. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/WorkerPool.py +0 -0
  104. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/__init__.py +0 -0
  105. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/SQLite.py +0 -0
  106. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/SharedData.py +0 -0
  107. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/SharedNumpy.py +0 -0
  108. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/StreamKafka.py +0 -0
  109. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Symbol.py +0 -0
  110. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Table.py +0 -0
  111. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableDisk.py +0 -0
  112. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndex.py +0 -0
  113. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndexJitFunctions.py +0 -0
  114. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
  115. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndexJitGenerate.py +0 -0
  116. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndexJitHash.py +0 -0
  117. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndexJitLoc.py +0 -0
  118. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableProtocol.py +0 -0
  119. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableRead.py +0 -0
  120. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableRewrite.py +0 -0
  121. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TimeSeriesDisk.py +0 -0
  122. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TimeseriesContainer.py +0 -0
  123. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Users.py +0 -0
  124. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Utils.py +0 -0
  125. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/ADDING_A_PAGE.md +0 -0
  126. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/__init__.py +0 -0
  127. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/activity_logger.py +0 -0
  128. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/app.py +0 -0
  129. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/dashAgGridComponentFunctions.js +0 -0
  130. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/dashAgGridFunctions.js +0 -0
  131. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/google_logo.png +0 -0
  132. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/kafka_keys.js +0 -0
  133. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/logs.css +0 -0
  134. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/sched.css +0 -0
  135. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/styles.css +0 -0
  136. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/auth.py +0 -0
  137. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/backends.py +0 -0
  138. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/config.py +0 -0
  139. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/containers.py +0 -0
  140. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/identity.py +0 -0
  141. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/kafka_view.py +0 -0
  142. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/redis_view.py +0 -0
  143. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/route_health.py +0 -0
  144. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/route_registry.py +0 -0
  145. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/sched.py +0 -0
  146. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/schedule_editor.py +0 -0
  147. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/server.py +0 -0
  148. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/services.py +0 -0
  149. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/shell.py +0 -0
  150. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/account.js +0 -0
  151. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/custom.css +0 -0
  152. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/favicon.ico +0 -0
  153. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/favicon.png +0 -0
  154. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/logo.png +0 -0
  155. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/logo_96x96.png +0 -0
  156. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/shareddata-mark.svg +0 -0
  157. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/shell.css +0 -0
  158. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/shell.js +0 -0
  159. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/suggest.py +0 -0
  160. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/account.html +0 -0
  161. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/base.html +0 -0
  162. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/claim.html +0 -0
  163. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/claim_done.html +0 -0
  164. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/dash.html +0 -0
  165. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/index.html +0 -0
  166. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/login.html +0 -0
  167. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/login_2fa.html +0 -0
  168. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/partials/navbar.html +0 -0
  169. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/partials/scripts.html +0 -0
  170. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/recovery_codes.html +0 -0
  171. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/shell.html +0 -0
  172. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/twofactor.py +0 -0
  173. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/utils.py +0 -0
  174. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/workerpool.py +0 -0
  175. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/__init__.py +0 -0
  176. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/build_info.py +0 -0
  177. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/fleet.py +0 -0
  178. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/preflight.py +0 -0
  179. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/private_files.py +0 -0
  180. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/release_backup.py +0 -0
  181. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/server.py +0 -0
  182. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/services.py +0 -0
  183. {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/sharedmutexwin.pyd +0 -0
  184. {shareddata-6.85.14 → shareddata-6.85.15}/src/shareddata.egg-info/SOURCES.txt +0 -0
  185. {shareddata-6.85.14 → shareddata-6.85.15}/src/shareddata.egg-info/dependency_links.txt +0 -0
  186. {shareddata-6.85.14 → shareddata-6.85.15}/src/shareddata.egg-info/entry_points.txt +0 -0
  187. {shareddata-6.85.14 → shareddata-6.85.15}/src/shareddata.egg-info/requires.txt +0 -0
  188. {shareddata-6.85.14 → shareddata-6.85.15}/src/shareddata.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shareddata
3
- Version: 6.85.14
3
+ Version: 6.85.15
4
4
  Summary: Memory Mapped / Shared Memory Database with S3 repository
5
5
  Home-page: https://shareddata.net
6
6
  Author: shareddata
@@ -66,7 +66,7 @@ test_requires = [
66
66
 
67
67
  setup(
68
68
  name='shareddata',
69
- version='6.85.14',
69
+ version='6.85.15',
70
70
  description='Memory Mapped / Shared Memory Database with S3 repository',
71
71
  author='shareddata',
72
72
  author_email='admin@shareddata.net',
@@ -285,7 +285,7 @@ def active(now=None):
285
285
 
286
286
 
287
287
  # What a list of jobs can be grouped by: the columns (SQL) that make a group, by name.
288
- GROUPS = {'time': ('minute', 'repo', 'branch', 'routine'), 'routine': ('repo', 'branch', 'routine'), 'status': ('status',), 'computer': ('computer',)}
288
+ GROUPS = {'time': ('minute', 'repo', 'branch', 'routine'), 'routine': ('repo', 'branch', 'routine'), 'repo': ('repo',), 'status': ('status',), 'computer': ('computer',)}
289
289
  _GROUP_SQL = {'minute': 'substr(date, 1, 16)', 'repo': "COALESCE(repo, '')", 'branch': "COALESCE(branch, '')", 'routine': "COALESCE(routine, '')",
290
290
  'status': 'status', 'computer': "COALESCE(target, computer, '')"}
291
291
 
@@ -333,7 +333,7 @@ def find(month=None, statuses=None, text=None, limit=200, offset=0, now=None, gr
333
333
 
334
334
  def groups(by='time', month=None, statuses=None, text=None, limit=500, now=None):
335
335
  """The groups of a list without their jobs: how many jobs of each status every group has.
336
- by: 'time' (the minute a job is due, with its routine), 'routine', 'status' or 'computer'.
336
+ by: 'time' (the minute a job is due, with its routine), 'routine', 'repo', 'status' or 'computer'.
337
337
  -> {'groups': [{'key': {...}, 'counts': {status: n}, 'total': n}], 'groups_total': n,
338
338
  'total': n, 'counts': {status: n}, 'months': [...]}; oldest minute first, then by name."""
339
339
  if by not in GROUPS:
@@ -859,6 +859,31 @@ def install_repo(command, quiet=False):
859
859
  installed_repo['isinstalling'] = False
860
860
 
861
861
 
862
+ def _git_text(args, timeout=30):
863
+ """stdout of a git command, or None when it failed or timed out (a missing or unreachable server)."""
864
+ try:
865
+ done = subprocess.run(['git', *args], capture_output=True, text=True, timeout=timeout)
866
+ except (OSError, subprocess.SubprocessError):
867
+ return None
868
+ return done.stdout.strip() if done.returncode == 0 else None
869
+
870
+
871
+ def _repo_is_current(repo_path, url, ref):
872
+ """True when the clone is on `ref` at exactly the commit the server has for it.
873
+
874
+ One `git ls-remote` (a reference list, no objects) replaces the checkout and the pull of every start:
875
+ code is exactly as new as before, and nothing runs, logs or changes when nothing was pushed. Anything
876
+ that cannot be proven (server unreachable, branch gone, other branch checked out) is not 'current'
877
+ and takes the full checkout + pull path, with its retries and its fallback to the installed copy.
878
+ """
879
+ remote = _git_text(['ls-remote', url, 'refs/heads/' + ref])
880
+ if not remote:
881
+ return False
882
+ remote_sha = remote.split()[0]
883
+ return (_git_text(['-C', str(repo_path), 'rev-parse', 'HEAD']) == remote_sha
884
+ and _git_text(['-C', str(repo_path), 'rev-parse', '--abbrev-ref', 'HEAD']) == ref)
885
+
886
+
862
887
  def _install_repo(command, quiet=False):
863
888
 
864
889
  """
@@ -901,7 +926,19 @@ def _install_repo(command, quiet=False):
901
926
  os.environ['GIT_ACRONYM']+'/'+command['repo']
902
927
 
903
928
  # GIT PULL OR GIT CLONE
904
- if repo_exists:
929
+ current = (repo_exists and venv_exists and requirements_path.is_file()
930
+ and _repo_is_current(repo_path, GIT_URL, command['branch'] if hasbranch else 'main'))
931
+ if repo_exists and current:
932
+ Logger.log.debug('Repo %s is up to date' % (command['repo']))
933
+ pulled = True # the code is as new as the server's: the requirements check below still applies
934
+ runroutine = True
935
+ install_requirements = False
936
+ if requirements_path.is_file():
937
+ venv_root = repo_path / "venv"
938
+ new_hash = hash_file(requirements_path)
939
+ stored_hash = load_last_hash(venv_root)
940
+ install_requirements = (new_hash is None or stored_hash is None or new_hash != stored_hash)
941
+ elif repo_exists:
905
942
  if not quiet:
906
943
  Logger.log.info('Pulling repo %s' % (command['repo']))
907
944
  requirements_lastmod = 0
@@ -0,0 +1,399 @@
1
+ """The MongoDB this server uses, as the MONGO page shows it: every server it is told of (and,
2
+ behind a router, every router of the cluster), the members of its replica set or the shards
3
+ behind its routers, its databases, and what is wrong.
4
+
5
+ Read-only commands, on connections of the page's own with short limits. Each server is asked
6
+ directly and at the same time (one connection to that server only), so the numbers of a row are
7
+ that server's, and a server that is down is reported instead of waited for.
8
+ """
9
+ import hashlib
10
+ import ipaddress
11
+ import re
12
+ import threading
13
+ import time
14
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as Late, wait as wait_for
15
+ from datetime import timezone
16
+
17
+ WAIT = 7
18
+ BEHIND = 60 # seconds a secondary may be behind before it is pointed out
19
+ SERVER_SECONDS = 2.5 # a server that takes longer is reported as not answering
20
+ FORGOTTEN = 3600 # a router the cluster has not heard of for longer is taken as retired
21
+ PORT = 27017
22
+ MAX_PENDING = 64 # includes running and queued work across all page requests
23
+ MAX_SERVERS = 64 # bound cluster-controlled discovery and candidate scans
24
+
25
+
26
+ def configured():
27
+ return bool(_env().get('MONGODB_HOST'))
28
+
29
+
30
+ def _env():
31
+ from SharedData.Web import services
32
+ return services.values('mongodb')
33
+
34
+
35
+ def _seeds(env):
36
+ """host:port of every server MONGODB_HOST names, in its order."""
37
+ seeds = []
38
+ for part in (env.get('MONGODB_HOST') or '').split(','):
39
+ part = part.strip()
40
+ if not part:
41
+ continue
42
+ host, sep, port = part.rpartition(':')
43
+ address = part if sep and host and port.isdigit() else '%s:%d' % (part, PORT)
44
+ if address not in seeds:
45
+ seeds.append(address)
46
+ return seeds
47
+
48
+
49
+ def _server_uri(env, address):
50
+ """The address of one server alone. Without replicaSet: with it the driver finds the set
51
+ again and sends the commands to its primary, whichever server was asked for."""
52
+ from SharedData.Web.services import mongo_uri
53
+ one = {name: value for name, value in env.items() if name != 'MONGODB_REPLICA_SET'}
54
+ one['MONGODB_HOST'] = address
55
+ uri = mongo_uri(one)
56
+ return uri + ('&' if '?' in uri else '?') + 'directConnection=true'
57
+
58
+
59
+ def _named(host, address):
60
+ """name:port as a server gives its own name (serverStatus says the name without the port)."""
61
+ if not host:
62
+ return ''
63
+ return host if ':' in host else '%s:%s' % (host, address.rpartition(':')[2])
64
+
65
+
66
+ def _ask(address, env):
67
+ from pymongo import timeout
68
+ with timeout(SERVER_SECONDS): # one budget for selection, hello and serverStatus together
69
+ return _ask_server(address, env)
70
+
71
+
72
+ def _ask_server(address, env):
73
+ """What one server says of itself, or why it does not answer. -> {'address', 'host', 'role',
74
+ 'set', 'answers', 'error', 'seconds', 'version', 'uptime', 'connections', 'connections_free',
75
+ 'memory_mb', 'operations'}"""
76
+ from pymongo import MongoClient
77
+ started = time.time()
78
+ out = {'address': address, 'host': '', 'role': '', 'set': None, 'answers': False, 'error': None, 'seconds': None, 'version': None, 'uptime': None,
79
+ 'connections': None, 'connections_free': None, 'memory_mb': None, 'operations': {}}
80
+ limit = int(SERVER_SECONDS * 1000)
81
+ try:
82
+ client = MongoClient(_server_uri(env, address), serverSelectionTimeoutMS=limit, connectTimeoutMS=limit, socketTimeoutMS=limit)
83
+ try:
84
+ hello = client.admin.command('hello')
85
+ out.update(answers=True, set=hello.get('setName'),
86
+ role='router' if hello.get('msg') == 'isdbgrid' else 'primary' if hello.get('setName') and hello.get('isWritablePrimary')
87
+ else 'secondary' if hello.get('secondary') else 'arbiter' if hello.get('arbiterOnly') else 'member' if hello.get('setName') else 'single')
88
+ try:
89
+ status = client.admin.command('serverStatus')
90
+ out.update(host=_named(status.get('host'), address), version=status.get('version'), uptime=status.get('uptime'),
91
+ connections=status.get('connections', {}).get('current'), connections_free=status.get('connections', {}).get('available'),
92
+ memory_mb=status.get('mem', {}).get('resident'),
93
+ operations={name: status.get('opcounters', {}).get(name) for name in ('insert', 'query', 'update', 'delete', 'command')})
94
+ except Exception as exc: # it answers, but this user may not read its numbers
95
+ out['error'] = 'its numbers cannot be read (%s: %s)' % (type(exc).__name__, str(exc)[:120])
96
+ finally:
97
+ client.close()
98
+ except Exception as exc:
99
+ out['error'] = '%s: %s' % (type(exc).__name__, str(exc).split(' (configured timeouts')[0].split(', Timeout:')[0][:160])
100
+ out['seconds'] = round(time.time() - started, 3)
101
+ return out
102
+
103
+
104
+ def _locate(name, candidates, env):
105
+ """Router names are labels, never network destinations. Probe only the validated cluster
106
+ addresses passed by _servers and require the server's own identity and router role."""
107
+ from pymongo import timeout
108
+ started = time.monotonic()
109
+ with timeout(SERVER_SECONDS):
110
+ for address in candidates[:MAX_SERVERS]:
111
+ if time.monotonic() - started >= SERVER_SECONDS:
112
+ break
113
+ reply = _ask(address, env)
114
+ if reply.get('answers') and reply.get('host') == name and reply.get('role') == 'router':
115
+ return reply
116
+ return {'address': '', 'host': name, 'role': 'router', 'set': None, 'answers': False, 'seconds': round(time.monotonic() - started, 3), 'version': None, 'uptime': None,
117
+ 'connections': None, 'connections_free': None, 'memory_mb': None, 'operations': {},
118
+ 'error': 'no approved address of the cluster answers as this router'}
119
+
120
+
121
+ def _candidates(name, hosts, seeds):
122
+ # config.mongos is writable metadata: it cannot choose a port, URI, DNS target or IP.
123
+ # getShardMap comes from the authenticated cluster command, not from config.mongos.
124
+ # Auto-discovery is restricted to numeric private unicast addresses and configured ports.
125
+ if not isinstance(name, str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,253}:[0-9]{1,5}', name):
126
+ return ()
127
+ port = name.rpartition(':')[2]
128
+ if port not in {seed.rpartition(':')[2] for seed in seeds} or not 0 < int(port) < 65536:
129
+ return ()
130
+ addresses = []
131
+ for host in hosts[:MAX_SERVERS]:
132
+ try:
133
+ ip = ipaddress.ip_address(host)
134
+ except ValueError:
135
+ continue
136
+ if (ip.version != 4 or not ip.is_private or ip.is_loopback or ip.is_link_local
137
+ or ip.is_multicast or ip.is_unspecified or ip.is_reserved):
138
+ continue
139
+ address = '%s:%s' % (ip, port)
140
+ if address not in addresses and address not in seeds:
141
+ addresses.append(address)
142
+ return tuple(addresses)
143
+
144
+
145
+ def _identity(env):
146
+ # Separate in-flight work and cached identities when credentials/settings change.
147
+ return hashlib.sha256(repr(sorted(env.items())).encode()).hexdigest()
148
+
149
+
150
+ _pool = ThreadPoolExecutor(max_workers=16, thread_name_prefix='mongo-view')
151
+ _pending = {} # what is asked -> a question still without an answer (never asked twice at once)
152
+ _pending_lock = threading.Lock()
153
+ _names = {} # address -> the name it last answered as, so a server that stops answering keeps its name
154
+ _located = {} # a router's name that does not resolve here -> the address that answered as it
155
+
156
+
157
+ def _submit(questions):
158
+ """{key: (function, args)} -> {key: future}, a question already on its way not asked again."""
159
+ with _pending_lock:
160
+ for key, future in list(_pending.items()):
161
+ if future.done():
162
+ _pending.pop(key, None)
163
+ asked = {}
164
+ for key, (function, args) in questions.items():
165
+ future = _pending.get(key)
166
+ if future is None and len(_pending) < MAX_PENDING:
167
+ future = _pool.submit(function, *args)
168
+ _pending[key] = future
169
+ asked[key] = future # saturated: an explicit no-answer, no new task
170
+ return asked
171
+
172
+
173
+ def _collect(asked, until):
174
+ """The replies in by *until* (time.monotonic()); a question not answered by then is reported as
175
+ such and left to end by itself. -> {key: reply}"""
176
+ wait_for([future for future in asked.values() if future is not None], timeout=max(until - time.monotonic(), 0))
177
+ replies = {}
178
+ with _pending_lock:
179
+ for key, future in asked.items():
180
+ if future is not None and future.done():
181
+ if _pending.get(key) is future:
182
+ _pending.pop(key, None)
183
+ try:
184
+ replies[key] = future.result()
185
+ except Exception as exc:
186
+ replies[key] = {'answers': False, 'error': '%s: %s' % (type(exc).__name__, str(exc)[:160])}
187
+ else:
188
+ replies[key] = {'answers': False, 'error': 'no answer in %g seconds' % SERVER_SECONDS}
189
+ return replies
190
+
191
+
192
+ def _read(seconds):
193
+ from pymongo import timeout
194
+ with timeout(seconds): # all cluster commands and dbStats share this deadline
195
+ return _read_cluster(seconds)
196
+
197
+
198
+ def _read_cluster(seconds):
199
+ """What the cluster says of itself, through the connection SharedData uses."""
200
+ from pymongo import MongoClient
201
+ from SharedData.Web.services import mongo_uri
202
+ limit = seconds * 1000
203
+ client = MongoClient(mongo_uri(_env()), serverSelectionTimeoutMS=limit, connectTimeoutMS=limit, socketTimeoutMS=limit)
204
+ try:
205
+ hello = client.admin.command('hello')
206
+ out = {'kind': 'sharded' if hello.get('msg') == 'isdbgrid' else 'replica set' if hello.get('setName') else 'single', 'set': hello.get('setName'),
207
+ 'members': [], 'shards': [], 'routers': [], 'cluster_hosts': []}
208
+ if out['kind'] == 'replica set':
209
+ try:
210
+ members = client.admin.command('replSetGetStatus').get('members', [])
211
+ primary = next((member for member in members if member.get('stateStr') == 'PRIMARY'), None)
212
+ for member in members:
213
+ lag = None
214
+ if primary and member.get('stateStr') == 'SECONDARY' and member.get('optimeDate') and primary.get('optimeDate'):
215
+ lag = max((primary['optimeDate'] - member['optimeDate']).total_seconds(), 0)
216
+ out['members'].append({'name': member.get('name'), 'state': member.get('stateStr'), 'healthy': member.get('health') == 1, 'lag': lag,
217
+ 'uptime': member.get('uptime'), 'copies_from': member.get('syncSourceHost') or '', 'message': member.get('lastHeartbeatMessage') or ''})
218
+ except Exception: # this user may not read the set's status: what the server says of it
219
+ out['members'] = [{'name': name, 'state': 'PRIMARY' if name == hello.get('primary') else 'SECONDARY' if name in hello.get('hosts', []) else 'ARBITER',
220
+ 'healthy': True, 'lag': None, 'uptime': None, 'copies_from': '', 'message': ''}
221
+ for name in list(hello.get('hosts', [])) + list(hello.get('arbiters', []))]
222
+ out['limited'] = True
223
+ elif out['kind'] == 'sharded':
224
+ out['shards'] = [{'name': shard.get('_id'), 'servers': shard.get('host'), 'ready': shard.get('state', 1) == 1} for shard in client.admin.command('listShards').get('shards', [])]
225
+ try: # every router of the cluster, as the routers report themselves
226
+ now = time.time() # the driver gives the ping in UTC, without its zone
227
+ out['routers'] = [{'name': router['_id'], 'heard': max(now - router['ping'].replace(tzinfo=timezone.utc).timestamp(), 0) if router.get('ping') else None,
228
+ 'version': router.get('mongoVersion')} for router in client.config.mongos.find({}, {'_id': 1, 'ping': 1, 'mongoVersion': 1}).limit(MAX_SERVERS)]
229
+ except Exception:
230
+ out['routers'] = []
231
+ try: # where the cluster's servers are, for a router whose name does not resolve here
232
+ hosts = set()
233
+ for servers in client.admin.command('getShardMap').get('map', {}).values():
234
+ for address in servers.rpartition('/')[2].split(','):
235
+ if address:
236
+ hosts.add(address.rpartition(':')[0])
237
+ out['cluster_hosts'] = sorted(hosts)
238
+ except Exception:
239
+ out['cluster_hosts'] = sorted({address.rpartition(':')[0] for shard in out['shards'] for address in str(shard['servers'] or '').rpartition('/')[2].split(',') if address})
240
+ databases = []
241
+ for database in client.admin.command('listDatabases').get('databases', []):
242
+ row = {'name': database['name'], 'size': database.get('sizeOnDisk'), 'collections': None, 'documents': None, 'indexes': None}
243
+ if len(databases) < 40:
244
+ try:
245
+ stats = client[database['name']].command('dbStats')
246
+ row.update(collections=stats.get('collections'), documents=stats.get('objects'), indexes=stats.get('indexes'), data=stats.get('dataSize'), index_size=stats.get('indexSize'))
247
+ except Exception:
248
+ pass
249
+ databases.append(row)
250
+ out['databases'] = sorted(databases, key=lambda row: -(row['size'] or 0))
251
+ return out
252
+ finally:
253
+ client.close()
254
+
255
+
256
+ def _row(reply, address, host, listed, heard):
257
+ row = {'address': address, 'host': host, 'role': '', 'set': None, 'answers': False, 'error': None, 'seconds': None, 'version': None, 'uptime': None,
258
+ 'connections': None, 'connections_free': None, 'memory_mb': None, 'operations': {}}
259
+ row.update({name: value for name, value in reply.items() if name in row})
260
+ row['address'], row['host'] = row['address'] or address, row['host'] or host
261
+ row.update(listed=listed, heard=None if heard is None else round(heard))
262
+ return row
263
+
264
+
265
+ def _servers(env, seeds, asked, found, started):
266
+ """One row per server: those MONGODB_HOST names, then the cluster's other routers. A router
267
+ the cluster has not heard of for an hour is left out (config.mongos keeps every router that
268
+ ever ran). Unlocated names may belong to unnamed down seeds, so they are diagnostics
269
+ rather than extra server counts in that case. -> (rows, retired count, unlocated names)"""
270
+ replies = _collect(asked, started + SERVER_SECONDS + 1.5)
271
+ identity = _identity(env)
272
+ routers = {router['name']: router for router in (found or {}).get('routers', [])[:MAX_SERVERS] if isinstance(router.get('name'), str)}
273
+ rows = []
274
+ for address in seeds:
275
+ reply = replies[(identity, address)]
276
+ host = reply.get('host') or _names.get((identity, address), '')
277
+ if reply.get('answers') and reply.get('host'):
278
+ _names[(identity, address)] = reply['host']
279
+ row = _row(reply, address, host, 'configured', routers.get(host, {}).get('heard'))
280
+ same = next((i for i, other in enumerate(rows) if host and other['host'] == host), None)
281
+ if same is None:
282
+ rows.append(row)
283
+ elif row['answers'] and not rows[same]['answers']:
284
+ rows[same] = row
285
+ known = {row['host'] for row in rows if row['host']}
286
+ others = [router for name, router in sorted(routers.items()) if name not in known]
287
+ left_out = [router for router in others if router['heard'] is not None and router['heard'] > FORGOTTEN]
288
+ others = [router for router in others if router not in left_out]
289
+ unlocated = []
290
+ if others:
291
+ hosts = (found or {}).get('cluster_hosts', [])
292
+ questions = {}
293
+ for router in others:
294
+ candidates = list(_candidates(router['name'], hosts, seeds))
295
+ located_key = (identity, router['name'])
296
+ if _located.get(located_key) in candidates: # where it answered the last time first
297
+ candidates.remove(_located[located_key])
298
+ candidates.insert(0, _located[located_key])
299
+ questions[(identity, 'router|' + router['name'])] = (_locate, (router['name'], tuple(candidates), env))
300
+ replies = _collect(_submit(questions), started + WAIT - 0.25)
301
+ for router in others:
302
+ reply = replies[(identity, 'router|' + router['name'])]
303
+ if not reply.get('answers') and any(not row['host'] for row in rows):
304
+ unlocated.append(router['name'])
305
+ continue # identity unknown: do not count a down seed twice or invent an address
306
+ if reply.get('answers') and reply.get('address') and reply['address'] != router['name']:
307
+ _located[(identity, router['name'])] = reply['address']
308
+ rows.append(_row(dict(reply, host=router['name'], role=reply.get('role') or 'router'), reply.get('address') or _located.get((identity, router['name']), ''),
309
+ router['name'], 'cluster', router['heard']))
310
+ return rows, len(left_out), unlocated
311
+
312
+
313
+ def view():
314
+ """-> {'configured', 'state', 'kind', 'summary', 'members', 'shards', 'servers', 'databases', 'problems', 'seconds'}
315
+ 'servers' is one row per server, as that server answers for itself: {'address' (where it is
316
+ asked), 'host' (the name it gives), 'role', 'set', 'answers', 'error', 'seconds', 'version',
317
+ 'uptime', 'connections', 'connections_free', 'memory_mb', 'operations', 'listed'
318
+ ('configured': MONGODB_HOST names it, 'cluster': a router the cluster lists), 'heard'
319
+ (seconds since the cluster last heard of that router)}."""
320
+ started = time.monotonic()
321
+ empty = {'kind': '', 'summary': {}, 'members': [], 'shards': [], 'servers': [], 'databases': [], 'problems': []}
322
+ if not configured():
323
+ return dict(empty, configured=False, state='none', seconds=0)
324
+ env = _env()
325
+ seeds = _seeds(env)
326
+ identity = _identity(env)
327
+ cluster_key = (identity, 'cluster')
328
+ cluster = _submit({cluster_key: (_read, (WAIT - 3,))})[cluster_key]
329
+ asked = _submit({(identity, address): (_ask, (address, env)) for address in seeds})
330
+ try:
331
+ if cluster is None:
332
+ raise Late()
333
+ found = cluster.result(timeout=max(0, started + WAIT - 3 - time.monotonic()))
334
+ except Late:
335
+ why = 'no answer in %g seconds' % (WAIT - 3)
336
+ found = None
337
+ except Exception as exc:
338
+ why, found = '%s: %s' % (type(exc).__name__, str(exc).split(', Timeout:')[0][:200]), None
339
+ finally:
340
+ with _pending_lock:
341
+ if cluster is not None and cluster.done() and _pending.get(cluster_key) is cluster:
342
+ _pending.pop(cluster_key, None)
343
+ servers, left_out, unlocated = _servers(env, seeds, asked, found, started)
344
+ answering = [row for row in servers if row['answers']]
345
+ counts = {'addresses': env.get('MONGODB_HOST'), 'servers': len(servers), 'answering': len(answering),
346
+ 'versions': sorted({row['version'] for row in answering if row['version']})}
347
+ if found is None:
348
+ return dict(empty, configured=True, state='down', seconds=round(time.monotonic() - started, 3), summary=counts, servers=servers,
349
+ problems=[{'level': 'error', 'text': 'MongoDB does not answer at %s (%s)' % (env.get('MONGODB_HOST'), why)}])
350
+ problems = []
351
+ if found['kind'] == 'replica set':
352
+ if not any(member['state'] == 'PRIMARY' for member in found['members']):
353
+ problems.append({'level': 'error', 'text': 'the replica set has no primary: nothing can be written'})
354
+ for member in found['members']:
355
+ if not member['healthy'] or member['state'] not in ('PRIMARY', 'SECONDARY', 'ARBITER'):
356
+ problems.append({'level': 'warning', 'text': '%s is %s%s' % (member['name'], (member['state'] or 'not healthy').lower(), ': ' + member['message'] if member['message'] else '')})
357
+ elif member['lag'] is not None and member['lag'] > BEHIND:
358
+ problems.append({'level': 'warning', 'text': '%s is %d seconds behind the primary' % (member['name'], member['lag'])})
359
+ holding = [member for member in found['members'] if member['state'] in ('PRIMARY', 'SECONDARY')]
360
+ if len(holding) == 1 and not found.get('limited'):
361
+ problems.append({'level': 'note', 'text': 'the data is on one member only (%s): the set has no second copy' % holding[0]['name']})
362
+ for shard in found['shards']:
363
+ if not shard['ready']:
364
+ problems.append({'level': 'error', 'text': 'shard %s is not ready' % shard['name']})
365
+ for row in servers:
366
+ name = row['host'] or row['address']
367
+ at = ' at ' + row['address'] if row['host'] and row['address'] and row['address'] != row['host'] else ''
368
+ if not row['answers']:
369
+ problems.append({'level': 'error', 'text': '%s%s does not answer (%s)' % (name, at, row['error'])})
370
+ elif row['error']:
371
+ problems.append({'level': 'warning', 'text': '%s answers, but %s' % (name, row['error'])})
372
+ free = row['connections_free']
373
+ if free is not None and row['connections'] and free < row['connections'] * 0.1:
374
+ problems.append({'level': 'warning', 'text': '%s has few connections left: %d in use, %d free' % (name, row['connections'], free)})
375
+ if left_out:
376
+ problems.append({'level': 'note', 'text': '%d %s the cluster lists %s not been heard of for over an hour: taken as retired and not shown'
377
+ % (left_out, 'router' if left_out == 1 else 'routers', 'has' if left_out == 1 else 'have')})
378
+ if unlocated:
379
+ problems.append({'level': 'warning', 'text': 'Router identities could not be located: %s. They may be the unnamed servers that do not answer; not counted twice.' % ', '.join(unlocated)})
380
+ state = 'degraded' if any(problem['level'] == 'error' for problem in problems) else 'ok'
381
+ user = [row for row in found['databases'] if row['name'] not in ('admin', 'local', 'config')]
382
+ summary = dict(counts, set=found.get('set'), members=len(found['members']), shards=len(found['shards']), databases=len(user),
383
+ size=sum(row['size'] or 0 for row in found['databases']), collections=sum(row['collections'] or 0 for row in user),
384
+ version=', '.join(counts['versions']) or None)
385
+ return {'configured': True, 'state': state, 'kind': found['kind'], 'summary': summary, 'members': found['members'], 'shards': found['shards'], 'servers': servers,
386
+ 'databases': found['databases'], 'problems': problems, 'limited': bool(found.get('limited')), 'seconds': round(time.monotonic() - started, 3)}
387
+
388
+
389
+ def health():
390
+ found = view()
391
+ if not found['configured']:
392
+ return None
393
+ summary = found['summary']
394
+ worst = next((problem['text'] for problem in found['problems'] if problem['level'] in ('error', 'warning')), '')
395
+ state = found['state'] if found['state'] != 'ok' or not any(problem['level'] == 'warning' for problem in found['problems']) else 'warning'
396
+ text = ('does not answer' if found['state'] == 'down' else
397
+ '%d of %d servers answer' % (summary['answering'], summary['servers']) if summary['answering'] < summary['servers'] else
398
+ '%d members' % summary['members'] if found['kind'] == 'replica set' else '%d shards' % summary['shards'] if found['kind'] == 'sharded' else 'one server')
399
+ return {'name': 'MongoDB', 'state': state, 'text': text + (' · ' + worst if worst and found['state'] != 'down' else '')}
@@ -3,10 +3,18 @@ a master may send from it (restart, stop).
3
3
 
4
4
  A routine is one line however many processes it has (a routine with several workers is one
5
5
  card). Its numbers are those of all its processes together. Platform processes (the Worker, the
6
- Scheduler, this server) are shown but are not restarted or stopped from here: their Worker keeps
7
- them running, and stopping the server would stop the page. So are processes that are not a script
8
- the Worker runs (an installed program such as vllm or rtxclaw): a restart would try to start that
9
- file as a Python script.
6
+ Scheduler, this server) are restarted and stopped like the others, with one exception: the
7
+ Scheduler is not commanded from here, because its Worker starts it again the moment it ends — a
8
+ stop of it would only be a delayed restart. Processes that are not a script the Worker runs (an
9
+ installed program such as vllm or rtxclaw) are not commanded either: a restart would try to
10
+ start that file as a Python script.
11
+
12
+ A restart is the Worker's own restart job: it ends the process and starts it again (the
13
+ Worker's own row restarts with the reset job). A stop is its kill job: the Worker ends the
14
+ process and does not start it again itself. What else may start it again (the Scheduler, for a
15
+ realtime routine of a schedule kept here) and what goes with it (this page, the API every host
16
+ uses, every process of the Worker's service) is what the page's confirmation says, row by row:
17
+ a stop is never silently undone.
10
18
  """
11
19
  import hashlib
12
20
  import os
@@ -23,6 +31,10 @@ PIDS_SHOWN = 20 # process ids listed on a card
23
31
  SAMPLE_FRESH = 30 # seconds a CPU reading can serve as the start of the next one
24
32
  SAMPLE_WAIT = 0.25 # seconds the first reading of a process is taken over
25
33
  BAD_STATES = ('zombie', 'dead', 'stopped', 'stop')
34
+ WORKER = 'SharedData.Routines.Worker' # the Worker program itself, when it is among the routines
35
+ WEB = 'SharedData.Web.server' # the server that serves this page, when it runs as a routine
36
+ API = 'SharedData.API.ServerGunicorn' # the SharedData API of this server, which every host and routine of it uses
37
+ SUPERVISED_KEPT = 10 # seconds the schedule's realtime scripts are kept between readings
26
38
  # What the page asks for -> the worker's job (its own 'stop' does nothing).
27
39
  JOBS = {job: sched.JOBS[job] for job in ('restart', 'stop')}
28
40
 
@@ -92,6 +104,49 @@ def _routines():
92
104
  return found
93
105
 
94
106
 
107
+ def _worker_pid():
108
+ """The Worker that owns this WebUI, not another Worker's process on the same host.
109
+ A reset is addressed to local_worker(), so it must never be offered on another row."""
110
+ try:
111
+ for parent in psutil.Process().parents():
112
+ words = parent.cmdline()
113
+ if any(words[i:i + 2] == ['-m', WORKER] for i in range(len(words) - 1)):
114
+ return parent.pid
115
+ except psutil.Error:
116
+ pass
117
+ return None
118
+
119
+
120
+ _supervised = (0.0, frozenset()) # (when, scripts the Scheduler keeps running here)
121
+
122
+
123
+ def _supervised_scripts():
124
+ """The scripts the Scheduler of this server keeps running: the realtime routines of the
125
+ schedules kept here, for this host. A killed one is started again once it has been silent,
126
+ so a stop of it is not durable. The set is kept a few seconds: the page asks every few."""
127
+ global _supervised
128
+ when, found = _supervised
129
+ if time.time() - when < SUPERVISED_KEPT:
130
+ return found
131
+ from SharedData.Web import workerpool
132
+ host = str(workerpool.local_worker() or '').upper()
133
+ scripts = set()
134
+ try:
135
+ from SharedData.Routines import ScheduleStore
136
+ day = datetime.now().strftime('%Y%m%d')
137
+ for name in ScheduleStore.names():
138
+ for record in ScheduleStore.state(name, day).to_dict('records'):
139
+ if record.get('isrealtime') and str(record.get('computer') or '').upper() == host:
140
+ script = str(record.get('script') or '')
141
+ if script:
142
+ scripts.add(script)
143
+ except Exception: # unknown is not evidence that a stop will hold
144
+ _supervised = (time.time(), None)
145
+ return None
146
+ _supervised = (time.time(), frozenset(scripts))
147
+ return _supervised[1]
148
+
149
+
95
150
  def rows():
96
151
  """The routines running here, one dict each. '_args' and '_branch' are the real values and
97
152
  never leave this module (an argument can be a secret)."""
@@ -127,15 +182,23 @@ def rows():
127
182
  row['states'].add(item['state'])
128
183
  row['started'] = item['started'] if row['started'] is None else min(row['started'], item['started'])
129
184
  out = []
185
+ supervised = _supervised_scripts()
186
+ worker_pid = _worker_pid()
130
187
  for row in grouped.values():
131
188
  states = sorted(row.pop('states'))
132
189
  row['states'] = states
133
190
  bad = [state for state in states if state in BAD_STATES]
134
191
  script_run = row.pop('script_run')
135
192
  row['kind'] = 'platform' if row['platform'] else 'routine' if script_run else 'external'
193
+ row['role'] = ('' if row['kind'] != 'platform' else 'scheduler' if is_scheduler(row['script'])
194
+ else 'worker' if row['script'] == WORKER else 'web' if row['script'] == WEB else 'api' if row['script'] == API else '')
195
+ row['_own_worker'] = row['processes'] == 1 and worker_pid in row['pids']
196
+ row['supervised'] = (None if supervised is None else row['script'] in supervised) if row['kind'] == 'platform' else False
136
197
  row.update(state=(bad or states or ['unknown'])[0], level='error' if bad else 'running',
137
198
  cpu=round(row['cpu'], 1), memory_mb=int(row['memory_mb']), pids=sorted(row['pids'])[:PIDS_SHOWN],
138
- started=_iso(row['started']), commandable=row['kind'] == 'routine')
199
+ started=_iso(row['started']),
200
+ commandable=row['kind'] == 'routine' or (row['kind'] == 'platform' and row['role'] != 'scheduler'
201
+ and (row['role'] != 'worker' or row['_own_worker'])))
139
202
  out.append(row)
140
203
  return sorted(out, key=lambda row: (row['script'].lower(), row['key']))
141
204
 
@@ -152,7 +215,13 @@ def view():
152
215
 
153
216
  def command(key, job):
154
217
  """The worker command for *job* on the routine whose card has this key. LookupError when it
155
- no longer runs here, PermissionError for a platform process, ValueError for a wrong job."""
218
+ no longer runs here, PermissionError for the Scheduler and for an installed program,
219
+ ValueError for a wrong job.
220
+
221
+ A restart is the Worker's restart job (it ends the process and starts it again); the Worker's
222
+ own row restarts with reset instead, because a restart job would end the Worker itself before
223
+ the thread that starts it again could run. A stop is the Worker's kill job: the Worker ends
224
+ the process and does not start it again itself; what else may start it, the page says."""
156
225
  from SharedData.Web import workerpool
157
226
  if not isinstance(job, str) or job not in JOBS:
158
227
  raise ValueError('job must be one of: ' + ', '.join(JOBS))
@@ -160,9 +229,17 @@ def command(key, job):
160
229
  if not found:
161
230
  raise LookupError('that routine no longer runs on this server')
162
231
  row = found[0]
163
- if row['kind'] != 'routine' or is_scheduler(row['script']):
164
- raise PermissionError('this process is not a routine of the Worker (a platform process, or an installed program): it is not restarted or stopped from here')
165
- command = {'sender': 'MASTER', 'job': JOBS[job], 'target': workerpool.local_worker(), 'repo': row['repo'], 'routine': row['routine']}
232
+ if row['kind'] == 'external':
233
+ raise PermissionError('this process is an installed program, not a script the Worker runs: it is not restarted or stopped from here')
234
+ if is_scheduler(row['script']):
235
+ raise PermissionError('the Scheduler is not restarted or stopped from here: its Worker keeps it running '
236
+ '(end its process on that host and the Worker starts it again)')
237
+ target = workerpool.local_worker()
238
+ if row['role'] == 'worker' and not row['_own_worker']:
239
+ raise PermissionError('this is not the single Worker process that owns this WebUI; it cannot be commanded from here')
240
+ if row['role'] == 'worker' and job == 'restart':
241
+ return {'sender': 'MASTER', 'job': 'reset', 'target': target, 'reset_nginx': False} # only the Worker and its children
242
+ command = {'sender': 'MASTER', 'job': JOBS[job], 'target': target, 'repo': row['repo'], 'routine': row['routine']}
166
243
  if row['branch'] and row['branch'] != 'nan':
167
244
  command['branch'] = row['branch']
168
245
  if row['_args'] and str(row['_args']) != 'nan':