plexora 0.0.1__py3-none-any.whl

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 (248) hide show
  1. plexora/__init__.py +129 -0
  2. plexora/__main__.py +15 -0
  3. plexora/_url.py +105 -0
  4. plexora/api/__init__.py +55 -0
  5. plexora/api/dataset.py +398 -0
  6. plexora/api/http.py +19 -0
  7. plexora/api/plugin.py +541 -0
  8. plexora/api/store.py +221 -0
  9. plexora/cli.py +777 -0
  10. plexora/client/dist/354_bundle.js +2 -0
  11. plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
  12. plexora/client/dist/418_bundle.js +2 -0
  13. plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
  14. plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
  15. plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
  16. plexora/client/dist/770_bundle.js +1 -0
  17. plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
  18. plexora/client/dist/vendor_bundle.js +2 -0
  19. plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
  20. plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
  21. plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
  22. plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
  23. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
  24. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
  25. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
  26. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
  27. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
  28. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
  29. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
  30. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
  31. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
  32. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
  33. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
  34. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
  35. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
  36. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
  37. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
  38. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
  39. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
  40. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
  41. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
  42. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
  43. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
  44. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
  45. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
  46. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
  47. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
  48. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
  49. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
  50. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
  51. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
  52. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
  53. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
  54. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
  55. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
  56. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
  57. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
  58. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
  59. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
  60. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
  61. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
  62. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
  63. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
  64. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
  65. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
  66. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
  67. plexora/client/src/css/import.css +474 -0
  68. plexora/client/src/css/main.css +903 -0
  69. plexora/client/src/css/openProject.css +363 -0
  70. plexora/client/src/css/quickView.css +166 -0
  71. plexora/client/src/css/tokens.css +51 -0
  72. plexora/client/src/css/viewer.css +1655 -0
  73. plexora/client/src/img/apple-touch-icon.png +0 -0
  74. plexora/client/src/img/favicon.ico +0 -0
  75. plexora/client/src/img/logo.ai +8892 -33
  76. plexora/client/src/img/logo.svg +1 -0
  77. plexora/client/src/img/logo_with_text.ai +9011 -34
  78. plexora/client/src/img/logo_with_text.svg +1 -0
  79. plexora/client/src/js/main.js +704 -0
  80. plexora/client/src/js/pluginRegistry.js +108 -0
  81. plexora/client/src/js/services/appStatus.js +286 -0
  82. plexora/client/src/js/services/browsePicker.js +73 -0
  83. plexora/client/src/js/services/dataLayer.js +400 -0
  84. plexora/client/src/js/services/datasetContext.js +143 -0
  85. plexora/client/src/js/services/glRenderer.js +143 -0
  86. plexora/client/src/js/services/importFormValidation.js +257 -0
  87. plexora/client/src/js/services/numericData.js +100 -0
  88. plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
  89. plexora/client/src/js/services/simpleEventHandler.js +25 -0
  90. plexora/client/src/js/vendor.js +29 -0
  91. plexora/client/src/js/views/channelList.js +663 -0
  92. plexora/client/src/js/views/colorSwatchPicker.js +202 -0
  93. plexora/client/src/js/views/columnClassifier.js +146 -0
  94. plexora/client/src/js/views/coordinateField.js +133 -0
  95. plexora/client/src/js/views/dataSourceField.js +276 -0
  96. plexora/client/src/js/views/imageViewer.js +3306 -0
  97. plexora/client/src/js/views/miniMap.js +787 -0
  98. plexora/client/src/js/views/navbarControls.js +178 -0
  99. plexora/client/src/js/views/openProjectPage.js +239 -0
  100. plexora/client/src/js/views/projectEdit.js +335 -0
  101. plexora/client/src/js/views/quickViewLanding.js +136 -0
  102. plexora/client/src/js/views/rainbow.js +326 -0
  103. plexora/client/src/js/views/requirementsModal.js +572 -0
  104. plexora/client/src/js/views/rgbImageViewer.js +115 -0
  105. plexora/client/src/js/views/searchableSelect.js +411 -0
  106. plexora/client/src/js/views/segmentationProgress.js +122 -0
  107. plexora/client/src/js/views/toolLoader.js +747 -0
  108. plexora/client/src/js/views/viewerControls.js +689 -0
  109. plexora/client/src/js/views/viewerManager.js +434 -0
  110. plexora/client/src/js/views/viewerSidebar.js +1117 -0
  111. plexora/client/src/js/workers/tileDecoder.js +130 -0
  112. plexora/client/src/shaders/frag.glsl +528 -0
  113. plexora/client/src/shaders/vert.glsl +12 -0
  114. plexora/client/templates/base.html +212 -0
  115. plexora/client/templates/index.html +238 -0
  116. plexora/client/templates/open_project.html +96 -0
  117. plexora/client/templates/project_columns.html +86 -0
  118. plexora/client/templates/project_edit.html +196 -0
  119. plexora/client/templates/upload.html +118 -0
  120. plexora/connect.py +528 -0
  121. plexora/datasource.py +782 -0
  122. plexora/jupyter.py +406 -0
  123. plexora/notebook_env.py +232 -0
  124. plexora/paths.py +486 -0
  125. plexora/plugins/__init__.py +12 -0
  126. plexora/plugins/cell_explorer/__init__.py +100 -0
  127. plexora/plugins/cell_explorer/server/__init__.py +11 -0
  128. plexora/plugins/cell_explorer/server/routes.py +167 -0
  129. plexora/plugins/cell_explorer/server/state.py +281 -0
  130. plexora/plugins/cell_explorer/server/values.py +145 -0
  131. plexora/plugins/cell_explorer/server/variables.py +367 -0
  132. plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
  133. plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
  134. plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
  135. plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
  136. plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
  137. plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
  138. plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
  139. plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
  140. plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
  141. plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
  142. plexora/plugins/figure_builder/__init__.py +125 -0
  143. plexora/plugins/figure_builder/server/__init__.py +7 -0
  144. plexora/plugins/figure_builder/server/compose.py +365 -0
  145. plexora/plugins/figure_builder/server/export.py +784 -0
  146. plexora/plugins/figure_builder/server/export_jobs.py +148 -0
  147. plexora/plugins/figure_builder/server/operations.py +556 -0
  148. plexora/plugins/figure_builder/server/pixels.py +142 -0
  149. plexora/plugins/figure_builder/server/provenance.py +174 -0
  150. plexora/plugins/figure_builder/server/render.py +278 -0
  151. plexora/plugins/figure_builder/server/repository.py +701 -0
  152. plexora/plugins/figure_builder/server/routes.py +594 -0
  153. plexora/plugins/figure_builder/server/schema.py +1023 -0
  154. plexora/plugins/figure_builder/server/sources.py +120 -0
  155. plexora/plugins/figure_builder/server/textmetrics.py +155 -0
  156. plexora/plugins/figure_builder/static/figureActions.js +363 -0
  157. plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
  158. plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
  159. plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
  160. plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
  161. plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
  162. plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
  163. plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
  164. plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
  165. plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
  166. plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
  167. plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
  168. plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
  169. plexora/plugins/figure_builder/static/figureRichText.js +634 -0
  170. plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
  171. plexora/plugins/figure_builder/static/figureSchema.js +218 -0
  172. plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
  173. plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
  174. plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
  175. plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
  176. plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
  177. plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
  178. plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
  179. plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
  180. plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
  181. plexora/plugins/gating/__init__.py +76 -0
  182. plexora/plugins/gating/server/__init__.py +0 -0
  183. plexora/plugins/gating/server/anndata_gates.py +367 -0
  184. plexora/plugins/gating/server/database.py +16 -0
  185. plexora/plugins/gating/server/model.py +320 -0
  186. plexora/plugins/gating/server/routes.py +235 -0
  187. plexora/plugins/gating/static/csvGatingList.js +947 -0
  188. plexora/plugins/gating/static/gating.css +289 -0
  189. plexora/plugins/gating/static/gatingApi.js +221 -0
  190. plexora/plugins/gating/static/gatingSidebarController.js +513 -0
  191. plexora/plugins/gating/templates/gating/legacy.html +26 -0
  192. plexora/plugins/gating/templates/gating/panel.html +69 -0
  193. plexora/plugins/roi/__init__.py +95 -0
  194. plexora/plugins/roi/server/__init__.py +8 -0
  195. plexora/plugins/roi/server/adapters.py +655 -0
  196. plexora/plugins/roi/server/geojson.py +267 -0
  197. plexora/plugins/roi/server/geometry.py +181 -0
  198. plexora/plugins/roi/server/mapping.py +169 -0
  199. plexora/plugins/roi/server/operations.py +293 -0
  200. plexora/plugins/roi/server/repository.py +215 -0
  201. plexora/plugins/roi/server/routes.py +409 -0
  202. plexora/plugins/roi/server/schema.py +323 -0
  203. plexora/plugins/roi/static/roi.css +583 -0
  204. plexora/plugins/roi/static/roiApi.js +177 -0
  205. plexora/plugins/roi/static/roiFigureBridge.js +107 -0
  206. plexora/plugins/roi/static/roiGeometry.js +350 -0
  207. plexora/plugins/roi/static/roiRenderer.js +278 -0
  208. plexora/plugins/roi/static/roiSidebarController.js +1038 -0
  209. plexora/plugins/roi/static/roiState.js +518 -0
  210. plexora/plugins/roi/static/roiTools.js +993 -0
  211. plexora/plugins/roi/templates/roi/panel.html +240 -0
  212. plexora/proxy.py +57 -0
  213. plexora/server/models/adapters/__init__.py +94 -0
  214. plexora/server/models/adapters/anndata_adapter.py +388 -0
  215. plexora/server/models/adapters/base.py +93 -0
  216. plexora/server/models/adapters/classify.py +192 -0
  217. plexora/server/models/adapters/csv_adapter.py +105 -0
  218. plexora/server/models/adapters/inspection.py +265 -0
  219. plexora/server/models/adapters/spatialdata_adapter.py +248 -0
  220. plexora/server/models/centroid_tiles.py +305 -0
  221. plexora/server/models/data_model.py +1659 -0
  222. plexora/server/models/database_model.py +169 -0
  223. plexora/server/models/project.py +1270 -0
  224. plexora/server/plugins.py +199 -0
  225. plexora/server/routes/browse_routes.py +41 -0
  226. plexora/server/routes/data_routes.py +294 -0
  227. plexora/server/routes/import_routes.py +603 -0
  228. plexora/server/routes/page_routes.py +139 -0
  229. plexora/server/routes/project_routes.py +438 -0
  230. plexora/server/routes/quick_view_routes.py +62 -0
  231. plexora/server/routes/system_routes.py +39 -0
  232. plexora/server/routes/tool_routes.py +418 -0
  233. plexora/server/utils/addHEColumns.py +11 -0
  234. plexora/server/utils/fast_png.py +97 -0
  235. plexora/server/utils/fullConversion.py +50 -0
  236. plexora/server/utils/native_dialog.py +168 -0
  237. plexora/server/utils/pre_normalization.py +47 -0
  238. plexora/server/utils/segmentation_pyramid.py +598 -0
  239. plexora/server/utils/smallestenclosingcircle.py +126 -0
  240. plexora/server/utils/tiffsurgeon.py +372 -0
  241. plexora/server_cli.py +48 -0
  242. plexora/test.py +0 -0
  243. plexora-0.0.1.dist-info/METADATA +341 -0
  244. plexora-0.0.1.dist-info/RECORD +248 -0
  245. plexora-0.0.1.dist-info/WHEEL +5 -0
  246. plexora-0.0.1.dist-info/entry_points.txt +12 -0
  247. plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
  248. plexora-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,139 @@
1
+ from plexora import app, get_config, get_config_names, paths
2
+ from plexora.server.models.project import (
3
+ Project, config_transaction, read_config, write_config,
4
+ )
5
+ from plexora.server import plugins as plugin_registry
6
+ from flask import abort, render_template, send_from_directory, request
7
+ from pathlib import Path
8
+ import datetime
9
+ import os
10
+
11
+
12
+ def template_data(**values):
13
+ base_url = app.config.get('PLEXORA_BASE_URL', '')
14
+ data = {
15
+ 'datasource': '',
16
+ 'datasources': get_config_names(),
17
+ 'is_docker': app.config.get('IS_DOCKER', False),
18
+ # Whether this process is a notebook sidecar or behind a hosted proxy.
19
+ # Templates use it to hide controls that act on the SERVER's machine --
20
+ # Quit, native file dialogs -- which in that mode is not the user's.
21
+ 'notebook_mode': app.config.get('PLEXORA_NOTEBOOK_MODE', False),
22
+ 'base_url': base_url,
23
+ # Menu entries contributed by installed plugins, as data rather than
24
+ # markup -- core renders them with its own classes. Filled in here, in
25
+ # the one place every page's context is built, because base.html's File
26
+ # menu is on every page and a key it reads must never be absent.
27
+ # Empty on a core-only build, where every consumer renders nothing.
28
+ 'plugin_nav_items': plugin_registry.nav_items(app, base_url),
29
+ # Which tool this page view asked to open, and which tools the
30
+ # navbar should offer. Both are per-request: several plugins can be
31
+ # installed at once, and which of them apply depends on the
32
+ # datasource, so neither can be a process-wide flag.
33
+ 'active_tool': '',
34
+ 'available_tools': [],
35
+ # Assets and panel templates of the active plugin, so templates never
36
+ # name one. Empty on every page that has no tool open.
37
+ 'active_tool_scripts': [],
38
+ 'active_tool_styles': [],
39
+ 'active_tool_panels': {},
40
+ }
41
+ data.update(values)
42
+ return data
43
+
44
+
45
+ @app.route("/")
46
+ def my_index():
47
+ return render_template("index.html", data=template_data())
48
+
49
+
50
+ @app.route("/favicon.ico")
51
+ def favicon():
52
+ return send_from_directory(
53
+ Path(app.config['CLIENT_PATH']) / 'src' / 'img', 'favicon.ico', conditional=True
54
+ )
55
+
56
+
57
+ def _stamp_last_opened(datasource):
58
+ """Record when a project was last opened, for the Open Project page's
59
+ "Recently Opened" sort. Best-effort: a failure here should never break
60
+ opening the viewer itself.
61
+
62
+ Skipped outright for a project on a read-only shared root. Catching the
63
+ failure would be correct but slow: write_config retries for two seconds
64
+ past what looks like a transient Windows sharing violation, and paying that
65
+ on every open of a shared project would be a visible stall for a sort key.
66
+ """
67
+ config_file = Project.config_path_for(datasource)
68
+ if not paths.is_writable(config_file.parent):
69
+ return
70
+ try:
71
+ with config_transaction():
72
+ config_data = read_config(config_file)
73
+ if datasource in config_data:
74
+ config_data[datasource]['lastOpenedAt'] = datetime.datetime.now().isoformat()
75
+ write_config(config_file, config_data)
76
+ except (OSError, ValueError):
77
+ pass
78
+
79
+
80
+ @app.route('/<string:datasource>')
81
+ def image_viewer(datasource):
82
+ datasources = get_config_names()
83
+ if datasource not in datasources:
84
+ # This rule matches any single path segment, so it is the last thing
85
+ # standing between a wrong URL and a 404. It used to render the empty
86
+ # viewer instead, which meant a request for a route that does not exist
87
+ # -- a removed plugin's endpoint, a typo, a probe -- came back 200 with
88
+ # a full HTML page. Callers expecting JSON got HTML and no error.
89
+ abort(404)
90
+ _stamp_last_opened(datasource)
91
+ project = Project.load(datasource)
92
+ image_kind = project.image.kind
93
+
94
+ # Two different lists on purpose. The menu offers every tool COMPATIBLE
95
+ # with this datasource, including ones still missing an input -- opening
96
+ # those is how the user gets asked for it. A tool is only ACTIVATED if it
97
+ # can actually run, so a stale ?tool= link to an uninstalled, inapplicable
98
+ # or not-yet-ready tool renders the plain viewer rather than a panel that
99
+ # cannot work.
100
+ base_url = app.config.get('PLEXORA_BASE_URL', '')
101
+ offered = plugin_registry.tools_for(app, project)
102
+ ready = plugin_registry.ready_tools(app, project)
103
+ requested_tool = request.args.get('tool', '')
104
+ active_tool = requested_tool if any(p.name == requested_tool for p in ready) else ''
105
+
106
+ active = next((p for p in ready if p.name == active_tool), None)
107
+ return render_template(
108
+ 'index.html',
109
+ data=template_data(
110
+ datasource=datasource,
111
+ datasources=datasources,
112
+ image_kind=image_kind,
113
+ active_tool=active_tool,
114
+ available_tools=[p.describe() for p in offered],
115
+ active_tool_scripts=active.asset_urls('scripts', base_url) if active else [],
116
+ active_tool_styles=active.asset_urls('styles', base_url) if active else [],
117
+ active_tool_panels=dict(active.panels) if active else {},
118
+ ),
119
+ )
120
+
121
+
122
+
123
+ @app.route("/upload_page")
124
+ def upload_page():
125
+ """The one import screen: image, optional mask, optional data.
126
+
127
+ It has no "attach to an existing project" mode any more. Adding data to a
128
+ project that already exists is an edit, and the edit page does it -- so
129
+ there is one place that knows how to change a project rather than an
130
+ import form with a second personality.
131
+ """
132
+ return render_template("upload.html", data=template_data())
133
+
134
+
135
+
136
+
137
+ @app.route('/client/<path:filename>')
138
+ def serveClient(filename):
139
+ return send_from_directory(app.config['CLIENT_PATH'], filename, conditional=True)
@@ -0,0 +1,438 @@
1
+ # Open Project page -- lists prior projects with search/sort/grid-list view,
2
+ # replacing the old inline project list in the File dropdown (base.html).
3
+ from plexora import app, get_config, paths
4
+ from plexora.server import plugins as plugin_registry
5
+ from plexora.server.models import data_model
6
+ from plexora.server.models.adapters.inspection import source_layers, source_obsm
7
+ from plexora.server.models.project import ROLE_LABELS, ROLE_NAMES, Project
8
+ from plexora.server.routes import tool_routes
9
+ from plexora.server.routes.page_routes import template_data
10
+
11
+ from flask import render_template, jsonify, redirect, request, send_file, abort
12
+ from pathlib import Path
13
+ import datetime
14
+ import io
15
+ import shutil
16
+
17
+
18
+ def _base_url():
19
+ return app.config.get('PLEXORA_BASE_URL', '')
20
+
21
+ _THUMBNAIL_CACHE_NAME = '.thumbnail.webp'
22
+
23
+
24
+ @app.route('/open_project')
25
+ def open_project_page():
26
+ return render_template('open_project.html', data=template_data())
27
+
28
+
29
+ def _fallback_timestamp(name, home_root=None):
30
+ """Best-effort date for projects saved before createdAt/lastOpenedAt
31
+ existed: macOS reports true creation time via st_birthtime, everything
32
+ else falls back to st_ctime (metadata-change time, the closest
33
+ approximation available)."""
34
+ try:
35
+ stat = paths.project_dir(name, home_root).stat()
36
+ except OSError:
37
+ return None
38
+ seconds = getattr(stat, 'st_birthtime', stat.st_ctime)
39
+ return datetime.datetime.fromtimestamp(seconds).isoformat()
40
+
41
+
42
+ @app.route('/projects')
43
+ def list_projects():
44
+ config = get_config()
45
+ # Which root each name came from, read once for the whole listing rather
46
+ # than per project -- `shared` decides whether the card offers rename and
47
+ # delete, so it cannot be left for the client to guess.
48
+ homes = Project.load_roots()
49
+ own = paths.data_root()
50
+ projects = []
51
+ for name, entry in config.items():
52
+ entry = entry or {}
53
+ home_root = homes.get(name)
54
+ created_at = entry.get('createdAt') or _fallback_timestamp(name, home_root)
55
+ last_opened_at = entry.get('lastOpenedAt') or created_at
56
+ projects.append({
57
+ 'name': name,
58
+ 'createdAt': created_at,
59
+ 'lastOpenedAt': last_opened_at,
60
+ 'thumbnailUrl': f'/project_thumbnail/{name}',
61
+ 'shared': home_root is not None and home_root != own,
62
+ })
63
+ return jsonify(projects)
64
+
65
+
66
+ @app.route('/project_thumbnail/<string:name>')
67
+ def project_thumbnail(name):
68
+ config = get_config()
69
+ if name not in config:
70
+ abort(404)
71
+
72
+ # A derived image, so it follows the same rule as every other derived
73
+ # artifact: beside the project when that root takes writes, in this user's
74
+ # own root when it does not.
75
+ cache_path = paths.derived_root(name) / _THUMBNAIL_CACHE_NAME
76
+ if cache_path.exists():
77
+ return send_file(cache_path, mimetype='image/webp')
78
+
79
+ result = data_model.generate_thumbnail(name)
80
+ if result is None:
81
+ abort(404)
82
+ encoded, mimetype = result
83
+ try:
84
+ cache_path.write_bytes(encoded)
85
+ except OSError:
86
+ pass # Cache is best-effort -- still serve the freshly generated bytes.
87
+ return send_file(io.BytesIO(encoded), mimetype=mimetype)
88
+
89
+
90
+ # --------------------------------------------------------------------------
91
+ # Editing an existing project
92
+ #
93
+ # The old edit page read a project as if its source file were a CSV, rendered
94
+ # the CSV column-matching screen, and posted to a handler that rebuilt the
95
+ # config entry from `{}` -- which discarded the data type and the AnnData read
96
+ # spec, repointed the source at a CSV that did not exist, and reported the
97
+ # whole thing as HTTP 200. Editing an AnnData project destroyed it, silently.
98
+ #
99
+ # What replaces it is generated from the project record, so it shows only the
100
+ # fields that project actually has, and saves through Project.patch(), which
101
+ # merges. There is no code path here that can drop a key it did not name.
102
+ # --------------------------------------------------------------------------
103
+
104
+ @app.route('/edit_config/<string:name>')
105
+ @app.route('/edit_config', methods=['GET'])
106
+ def edit_project_page(name=None):
107
+ name = name or request.args.get('config')
108
+ project = Project.find(name) if name else None
109
+ if project is None:
110
+ return redirect(f"{_base_url()}/open_project")
111
+
112
+ return render_template('project_edit.html', data=template_data(
113
+ datasetName=name,
114
+ project=_describe(project),
115
+ needs=_needs_for_tool(project, request.args.get('needs')),
116
+ ))
117
+
118
+
119
+ def known_layers(project):
120
+ """The project with `dataset.layers` filled in, reading the file when they
121
+ were never recorded.
122
+
123
+ In memory only -- nothing is written back, so this is safe on the GET paths
124
+ that render a picker. Shared with tool_routes, which needs the same list to
125
+ put the matrix question in the requirements modal: without the backfill, a
126
+ project imported before layers were recorded would be offered a choice of
127
+ one, which is exactly the project that most needs the choice.
128
+ """
129
+ if project.dataset and not project.dataset.layers:
130
+ return project.with_layers(source_layers(project.dataset))
131
+ return project
132
+
133
+
134
+ def known_obsm(project):
135
+ """The project with `dataset.obsm` filled in, reading the file when it was
136
+ never recorded.
137
+
138
+ Exactly `known_layers`' job for the coordinate question, and needed more
139
+ urgently: a project imported before obsm was recorded had its coordinate
140
+ source chosen by a name heuristic and offers no way to see, let alone
141
+ correct, what was chosen.
142
+ """
143
+ if project.dataset and not project.dataset.obsm:
144
+ return project.with_obsm(source_obsm(project.dataset))
145
+ return project
146
+
147
+
148
+ def _describe(project):
149
+ """The project as the edit page and the requirements modal consume it.
150
+
151
+ Deliberately not the raw entry: the page renders sections from `has`, so
152
+ what it asks about is decided here, once, from the record -- rather than by
153
+ a template guessing which fields might apply to which kind of project.
154
+ """
155
+ project = known_obsm(known_layers(project))
156
+ spec = project.dataset
157
+ layers = list(spec.layers) if spec else []
158
+ return {
159
+ "name": project.name,
160
+ "image": {
161
+ "src": project.image.src,
162
+ "kind": project.image.kind,
163
+ "channelCount": project.image.num_channels,
164
+ "size": [project.image.width, project.image.height],
165
+ },
166
+ "segmentation": {
167
+ "src": project.segmentation.source,
168
+ "available": project.segmentation.available,
169
+ "pending": project.segmentation.pending,
170
+ "mode": project.segmentation.mode,
171
+ },
172
+ "data": None if spec is None else {
173
+ "type": spec.type,
174
+ "src": spec.src,
175
+ "table": spec.table,
176
+ "coordinates": dict(spec.coordinates),
177
+ "features": dict(spec.features),
178
+ "subset": dict(spec.subset),
179
+ "isTransformed": spec.is_transformed,
180
+ "layers": layers,
181
+ },
182
+ # Which matrix the intensities are read from, in the same "X" /
183
+ # "layer:<name>" vocabulary the import form's picker uses, plus whether
184
+ # they are log1p'd on the way in. Two halves of one question -- what
185
+ # numbers is this project actually showing.
186
+ "featureSource": project.feature_source,
187
+ "featureOptions": project.feature_options,
188
+ "featureLog": project.log_transformed,
189
+ # Both, because they are different facts. `cellLayer` is what the
190
+ # project resolves to and is what the select shows; `cellLayerChoice`
191
+ # is whether that was a decision or a default, which is the difference
192
+ # between a form the user can leave alone and one that records an
193
+ # override every time it is saved.
194
+ "cellLayer": project.cell_layer,
195
+ "cellLayerChoice": project.cell_layer_choice,
196
+ "cellLayerOptions": project.cell_layer_options,
197
+ "roles": project.roles.to_dict(),
198
+ "roleLabels": dict(ROLE_LABELS),
199
+ # The role selects, in the vocabulary the question is asked in -- obs
200
+ # columns for AnnData and SpatialData, table columns for a CSV. See
201
+ # Project.role_columns; this page and the requirements modal render
202
+ # from the same three keys so they cannot drift.
203
+ "roleColumns": project.role_columns,
204
+ "roleAnswers": project.role_answers,
205
+ "roleDefaults": project.role_defaults,
206
+ # The coordinate question's own material: every obsm array with its
207
+ # shape, every obs column, and the source recorded now. Separate from
208
+ # the role keys because its answer is a read spec rather than a column
209
+ # name -- an obsm array supplies both axes at once.
210
+ "coordinateOptions": project.coordinate_options,
211
+ "singleImage": bool(spec and spec.single_image),
212
+ "rowNumberIds": bool(spec and spec.row_number_ids),
213
+ "editableRoles": _editable_roles(project),
214
+ "columns": {
215
+ "markers": list(project.columns.markers),
216
+ "metadata": list(project.columns.metadata),
217
+ },
218
+ # Which sections the page renders. A project with only an image has no
219
+ # roles to assign and no columns to classify, so it is not shown empty
220
+ # controls for them.
221
+ "has": {
222
+ "data": project.has_table,
223
+ "columns": project.has_table and spec.type == "csv",
224
+ "readSpec": project.has_table and spec.type in ("anndata", "spatialdata"),
225
+ "segmentation": project.segmentation.requested,
226
+ # Any project with a table. The matrix select needs more than one
227
+ # matrix to be worth showing -- the template guards it on
228
+ # `data.layers`, so a CSV gets the log switch alone -- but that
229
+ # switch is a real question for every format: whether the numbers
230
+ # are raw counts is not something the file says, and a CSV of raw
231
+ # counts is the commonest case of all. This used to be restricted
232
+ # to the formats that state their own marker split, which left CSV
233
+ # projects with no way to reach the transform at all.
234
+ "features": project.has_table,
235
+ # Offered only once there is something to choose between: a project
236
+ # with neither a mask nor coordinates cannot draw cells at all.
237
+ "cellLayer": bool(project.cell_layer_options),
238
+ },
239
+ }
240
+
241
+
242
+ def _editable_roles(project):
243
+ """Which roles this project's user may reassign.
244
+
245
+ All of them, for every format. `cell_id`, `x` and `y` used to be withheld
246
+ from AnnData and SpatialData projects because the adapter synthesizes those
247
+ columns, so repointing the role named a column the loaded table did not
248
+ have. They are answerable now because the answer goes into the read spec
249
+ instead -- which is the only way to say "the mask's label values are in this
250
+ obs column", and without it a segmentation overlay could never line up.
251
+ """
252
+ return list(ROLE_NAMES) if project.has_table else []
253
+
254
+
255
+ def _needs_for_tool(project, tool_name):
256
+ """The requirements a named tool is still missing, so the edit page can
257
+ highlight exactly those fields. This is the no-JavaScript twin of the
258
+ requirements modal -- same question, same answer, different surface."""
259
+ if not tool_name:
260
+ return None
261
+ plugin = plugin_registry.find(app, tool_name)
262
+ if plugin is None or not plugin.requires.applies_to(project):
263
+ return None
264
+ return {
265
+ "tool": plugin.name,
266
+ "label": plugin.label,
267
+ "missing": [r.describe() for r in plugin.requires.missing_from(project)],
268
+ "confirm": [r.describe() for r in plugin.requires.unconfirmed_from(project)],
269
+ "optional": [r.describe() for r in plugin.requires.optional_missing_from(project)],
270
+ }
271
+
272
+
273
+ @app.route('/project/<string:name>', methods=['POST'])
274
+ def save_project(name):
275
+ """Apply an edit. Everything except the image can change.
276
+
277
+ Merge semantics throughout: a key the request does not mention is carried
278
+ across untouched. The expensive work -- re-reading a new data file,
279
+ converting a new mask -- runs only when that input actually changed, which
280
+ is what makes saving an unchanged form free.
281
+ """
282
+ payload = request.get_json(silent=True) or {}
283
+ project = Project.find(name)
284
+ if project is None:
285
+ return jsonify(success=False, error="Unknown project"), 404
286
+
287
+ try:
288
+ changed = _apply_edit(project, payload)
289
+ except ValueError as exc:
290
+ # A bad path or an unreadable file is the user's to fix, and they need
291
+ # to be told which. The old handler returned success=False with HTTP
292
+ # 200 and no message, and the client navigated away regardless.
293
+ return jsonify(success=False, error=str(exc)), 400
294
+
295
+ if changed:
296
+ # An answer is only accepted once the file can be read with it, so a
297
+ # role that rewrites the read spec into something the adapter refuses
298
+ # is put back rather than left stored -- see tool_routes.
299
+ error = tool_routes._reload_or_restore(name, project)
300
+ if error:
301
+ return jsonify(success=False, error=error), 400
302
+
303
+ project = Project.find(name)
304
+ return jsonify(success=True, reloaded=changed,
305
+ segmentation_pending=project.segmentation.pending)
306
+
307
+
308
+ def _apply_edit(project, payload):
309
+ """Returns whether anything changed that needs a datasource reload."""
310
+ from plexora.server.routes import import_routes
311
+
312
+ changed = False
313
+
314
+ if "segmentation" in payload:
315
+ wanted = (payload.get("segmentation") or "").strip() or None
316
+ current = project.segmentation.source
317
+ if (wanted or None) != (current or None):
318
+ if wanted and not Path(wanted).expanduser().exists():
319
+ raise ValueError(f"No such segmentation mask: {wanted}")
320
+ import_routes.attach_segmentation(project.name, wanted)
321
+ changed = True
322
+
323
+ swapped = False
324
+ if "data" in payload:
325
+ wanted = (payload.get("data") or "").strip() or None
326
+ current = project.dataset.src if project.dataset else None
327
+ if (wanted or None) != (current or None):
328
+ import_routes.replace_project_data(project.name, wanted, payload)
329
+ changed = True
330
+ swapped = True
331
+
332
+ # Only when the file itself did not change: replace_project_data already
333
+ # reads `features_layer` out of this same payload, and applying it twice
334
+ # would validate the new choice against the old file's layer list.
335
+ if not swapped:
336
+ changed = apply_feature_choice(project, payload) or changed
337
+
338
+ roles = payload.get("roles")
339
+ columns = payload.get("columns")
340
+ coordinates = payload.get("coordinates")
341
+ single_image = payload.get("single_image")
342
+ row_number_ids = payload.get("row_number_ids")
343
+ # Presence, not truth: "" is the client saying "no override, draw whatever
344
+ # this project resolves to", which is a change worth applying and would be
345
+ # dropped by a plain truthiness test.
346
+ chose_layer = "cellLayer" in payload
347
+ cell_layer = payload.get("cellLayer")
348
+ confirm = payload.get("confirm")
349
+ if (roles or columns or coordinates or single_image or row_number_ids
350
+ or chose_layer or confirm):
351
+ def _apply(current):
352
+ if columns:
353
+ current = current.with_columns(columns.get("markers") or [],
354
+ columns.get("metadata") or [])
355
+ # The same helper the requirements modal applies its answers with,
356
+ # so the two surfaces cannot disagree about what a payload means.
357
+ current = tool_routes.apply_column_answers(current, payload)
358
+ if chose_layer:
359
+ current = current.with_cell_layer(cell_layer)
360
+ # Saving this page is the user looking at these values and
361
+ # accepting them, which is exactly what the requirements modal asks
362
+ # for -- so a tool opened afterwards must not ask again.
363
+ return current.with_confirmed(confirm or ())
364
+
365
+ Project.mutate(project.name, _apply)
366
+ # A cell-layer choice on its own changes nothing the datasource holds;
367
+ # it is read from the config by the viewer. Reloading for it would
368
+ # re-open the image to record a preference.
369
+ changed = changed or bool(roles or columns or coordinates
370
+ or row_number_ids)
371
+
372
+ return changed
373
+
374
+
375
+ def apply_feature_choice(project, payload):
376
+ """Point the reader at a matrix and/or switch the log1p transform.
377
+
378
+ Shared with the requirements modal (tool_routes), because both surfaces ask
379
+ the same question and getting the answer to stick means the same two writes.
380
+ Returns whether anything changed -- both halves change what every number in
381
+ the app is, so either one needs the datasource re-read.
382
+ """
383
+ layer = payload.get("features_layer")
384
+ has_log = "features_log" in payload
385
+ log = bool(payload.get("features_log"))
386
+ wants_layer = bool(layer) and layer != project.feature_source
387
+ wants_log = has_log and log != project.log_transformed
388
+ if not wants_layer and not wants_log:
389
+ return False
390
+
391
+ # Read outside the write lock: the layer list may come off disk, and holding
392
+ # config.json's lock across a file read serializes every other writer behind
393
+ # it for no reason.
394
+ layers = source_layers(project.dataset) if wants_layer else None
395
+
396
+ def _change(current):
397
+ # The available list goes in first, so a project imported before it was
398
+ # recorded validates the choice against the file rather than against an
399
+ # empty tuple that would reject every layer.
400
+ if wants_layer:
401
+ current = current.with_layers(layers).with_feature_source(layer)
402
+ if wants_log:
403
+ current = current.with_log_transform(log)
404
+ return current
405
+
406
+ Project.mutate(project.name, _change)
407
+ return True
408
+
409
+
410
+ @app.route('/project/<string:name>/delete', methods=['POST'])
411
+ def delete_project(name):
412
+ """Remove a project and everything derived for it.
413
+
414
+ POST, not GET: this is an irreversible rmtree, and it used to sit behind a
415
+ link that any crawler, prefetcher or stale bookmark could follow.
416
+ """
417
+ project = Project.find(name)
418
+ if project is None:
419
+ return jsonify(success=False, error="Unknown project"), 404
420
+ if project.is_shared:
421
+ # A shared project belongs to whoever provisioned the root it sits on.
422
+ # Refused here rather than allowed to fail against a read-only
423
+ # filesystem, so the answer is a sentence rather than a traceback --
424
+ # and so a WRITABLE shared root cannot let one user delete a project
425
+ # every other user on the machine is working from.
426
+ return jsonify(
427
+ success=False,
428
+ error="This project is on a shared data directory and cannot be "
429
+ "deleted from Plexora.",
430
+ ), 403
431
+
432
+ project.delete()
433
+ # Only this user's own copy of the project directory. For a project the
434
+ # user owns that is the whole of it; there is nothing else anywhere.
435
+ directory = paths.project_state_dir(name)
436
+ if directory.is_dir():
437
+ shutil.rmtree(directory, ignore_errors=True)
438
+ return jsonify(success=True)
@@ -0,0 +1,62 @@
1
+ # "Quick view" landing page: register a datasource from just a local image
2
+ # path (no CSV/segmentation/h5ad) and hand the client a redirect straight
3
+ # into the viewer. Paths only, never bytes -- see datasource.py's
4
+ # register_image_datasource/register_rgb_datasource docstrings.
5
+
6
+ from pathlib import Path
7
+
8
+ from flask import jsonify, request, send_file
9
+
10
+ from plexora import app, get_config, get_config_names
11
+ from plexora.datasource import (
12
+ _dedupe_dataset_name,
13
+ _derive_dataset_name_from_path,
14
+ _find_existing_datasource_for_image,
15
+ _sniff_quick_view_kind,
16
+ register_image_datasource,
17
+ register_rgb_datasource,
18
+ )
19
+ from plexora.server.routes.import_routes import trim_filepath_quotes
20
+
21
+
22
+ @app.route('/quick_view', methods=['POST'])
23
+ def quick_view():
24
+ payload = request.get_json(silent=True) or {}
25
+ path = trim_filepath_quotes((payload.get('path') or '').strip())
26
+
27
+ if not path or not Path(path).is_file():
28
+ return jsonify(success=False, error="File does not exist."), 400
29
+
30
+ base_url = app.config.get('PLEXORA_BASE_URL', '')
31
+
32
+ # Same image already registered (quick-viewed before, or imported through
33
+ # the full wizard) -- reopen that project instead of creating a duplicate.
34
+ existing_name = _find_existing_datasource_for_image(path, get_config())
35
+ if existing_name:
36
+ return jsonify(success=True, name=existing_name, redirect=f"{base_url}/{existing_name}")
37
+
38
+ try:
39
+ kind = _sniff_quick_view_kind(path)
40
+ except ValueError as exc:
41
+ return jsonify(success=False, error=str(exc)), 400
42
+
43
+ name = _dedupe_dataset_name(_derive_dataset_name_from_path(path), get_config_names())
44
+
45
+ try:
46
+ if kind == 'ome_tiff':
47
+ register_image_datasource(name=name, image=path)
48
+ else:
49
+ register_rgb_datasource(name=name, image=path)
50
+ except Exception as exc:
51
+ return jsonify(success=False, error=str(exc)), 400
52
+
53
+ return jsonify(success=True, name=name, redirect=f"{base_url}/{name}")
54
+
55
+
56
+ @app.route('/generated/rgb/<string:datasource>')
57
+ def generate_rgb_image(datasource):
58
+ config = get_config()
59
+ entry = config.get(datasource)
60
+ if not entry or entry.get('image_kind') != 'rgb':
61
+ return jsonify(error="Not an RGB quick-view datasource."), 404
62
+ return send_file(entry['channelFile'])
@@ -0,0 +1,39 @@
1
+ # Process lifecycle routes for the local desktop app (File > Quit).
2
+ #
3
+ # Plexora has no native window/process the frontend already controls -- it's
4
+ # a headless waitress server the user opens in their own browser -- so Quit
5
+ # has to be a request that tells this process to terminate itself. os._exit
6
+ # skips normal interpreter teardown, which is fine here: waitress's serve()
7
+ # loop never returns on its own, so there's no clean in-process shutdown path
8
+ # to call instead, and the user closing the terminal/window today has the
9
+ # same abrupt effect.
10
+ from plexora import app
11
+ from flask import Response, jsonify
12
+ import os
13
+
14
+
15
+ @app.route('/shutdown', methods=['POST'])
16
+ def shutdown():
17
+ # In a notebook the server is a sidecar the kernel owns: it was started by
18
+ # PlexoraViewer, it is tracked in that module's registry, and atexit is
19
+ # what stops it. os._exit here would kill it behind the kernel's back,
20
+ # leaving a viewer object whose iframe silently stops loading and no way to
21
+ # get it back short of restarting the kernel. Under a hosted proxy it is
22
+ # worse still -- the "process" the button would end is one the hub spawned.
23
+ if app.config.get('PLEXORA_NOTEBOOK_MODE'):
24
+ return jsonify(error="Shutdown is managed by the notebook session."), 403
25
+ os._exit(0)
26
+ return Response(status=204)
27
+
28
+
29
+ @app.route('/health', methods=['GET'])
30
+ def health():
31
+ """Liveness probe for the navbar status indicator (appStatus.js).
32
+
33
+ Deliberately does no work -- not even touching the loaded datasource -- so
34
+ the 10 s poll can never contend with tile serving. It answers exactly one
35
+ question: is this process still accepting requests? An idle page issues no
36
+ other requests, so without this a server that died minutes ago would still
37
+ show as connected.
38
+ """
39
+ return Response(status=204)