jupyterlab-kernel-terminal-workspace-culler-extension 1.0.21__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 (23) hide show
  1. jupyterlab_kernel_terminal_workspace_culler_extension/__init__.py +54 -0
  2. jupyterlab_kernel_terminal_workspace_culler_extension/_version.py +4 -0
  3. jupyterlab_kernel_terminal_workspace_culler_extension/cli.py +430 -0
  4. jupyterlab_kernel_terminal_workspace_culler_extension/culler.py +499 -0
  5. jupyterlab_kernel_terminal_workspace_culler_extension/routes.py +176 -0
  6. jupyterlab_kernel_terminal_workspace_culler_extension/tests/__init__.py +1 -0
  7. jupyterlab_kernel_terminal_workspace_culler_extension/tests/test_culler.py +269 -0
  8. jupyterlab_kernel_terminal_workspace_culler_extension/tests/test_routes.py +17 -0
  9. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/etc/jupyter/jupyter_server_config.d/jupyterlab_kernel_terminal_workspace_culler_extension.json +7 -0
  10. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/install.json +5 -0
  11. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/package.json +219 -0
  12. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/schemas/jupyterlab_kernel_terminal_workspace_culler_extension/package.json.orig +214 -0
  13. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/schemas/jupyterlab_kernel_terminal_workspace_culler_extension/plugin.json +64 -0
  14. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/static/728.b056947597422f9e496c.js +1 -0
  15. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/static/750.b2aa372edac477cffcb9.js +1 -0
  16. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/static/remoteEntry.61977aac2cfde9b88947.js +1 -0
  17. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/static/style.js +4 -0
  18. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/static/third-party-licenses.json +16 -0
  19. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/METADATA +208 -0
  20. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/RECORD +23 -0
  21. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/WHEEL +4 -0
  22. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/entry_points.txt +2 -0
  23. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,499 @@
1
+ """Resource culler for idle kernels, terminals, and workspaces."""
2
+
3
+ import logging
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from tornado.ioloop import PeriodicCallback
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ResourceCuller:
14
+ """Culls idle kernels, terminals, and workspaces based on configurable timeouts."""
15
+
16
+ def __init__(self, server_app: Any) -> None:
17
+ self._server_app = server_app
18
+ self._periodic_callback: PeriodicCallback | None = None
19
+
20
+ # Default settings
21
+ self._kernel_cull_enabled = True
22
+ self._kernel_cull_idle_timeout = 60 # minutes (1 hour)
23
+ self._terminal_cull_enabled = True
24
+ self._terminal_cull_idle_timeout = 60 # minutes (1 hour)
25
+ self._terminal_cull_disconnected_only = True # only cull terminals with no active tab
26
+ self._workspace_cull_enabled = True
27
+ self._workspace_cull_idle_timeout = 10080 # minutes (7 days)
28
+ self._cull_check_interval = 5 # minutes
29
+
30
+ # Last culling result for notification polling
31
+ self._last_cull_result: dict[str, list[str]] = {
32
+ "kernels_culled": [],
33
+ "terminals_culled": [],
34
+ "workspaces_culled": [],
35
+ }
36
+ self._result_consumed = True # Track if frontend has fetched the result
37
+
38
+ # Active terminals reported by frontend (terminals with open tabs)
39
+ self._active_terminals: set[str] = set()
40
+
41
+ # Workspace manager (lazy initialization)
42
+ self._workspace_manager: Any = None
43
+
44
+ @property
45
+ def kernel_manager(self) -> Any:
46
+ """Access the kernel manager from jupyter_server."""
47
+ return self._server_app.kernel_manager
48
+
49
+ @property
50
+ def terminal_manager(self) -> Any:
51
+ """Access the terminal manager from jupyter_server."""
52
+ # Terminal manager may be on server_app or in web_app settings
53
+ if hasattr(self._server_app, "terminal_manager"):
54
+ return self._server_app.terminal_manager
55
+ return self._server_app.web_app.settings.get("terminal_manager")
56
+
57
+ @property
58
+ def workspace_manager(self) -> Any:
59
+ """Access the workspace manager from jupyterlab_server."""
60
+ if self._workspace_manager is None:
61
+ try:
62
+ from jupyterlab_server.workspaces_handler import WorkspacesManager
63
+
64
+ # Get workspaces directory from jupyterlab settings
65
+ workspaces_dir = Path.home() / ".jupyter" / "lab" / "workspaces"
66
+ if workspaces_dir.exists():
67
+ self._workspace_manager = WorkspacesManager(str(workspaces_dir))
68
+ else:
69
+ logger.warning(
70
+ f"[Culler] Workspaces directory not found: {workspaces_dir}"
71
+ )
72
+ except ImportError:
73
+ logger.warning(
74
+ "[Culler] jupyterlab_server not available for workspace management"
75
+ )
76
+ return self._workspace_manager
77
+
78
+ def update_settings(self, settings: dict[str, Any]) -> None:
79
+ """Update culler settings from frontend (camelCase -> snake_case)."""
80
+ if "kernelCullEnabled" in settings:
81
+ self._kernel_cull_enabled = settings["kernelCullEnabled"]
82
+ if "kernelCullIdleTimeout" in settings:
83
+ self._kernel_cull_idle_timeout = settings["kernelCullIdleTimeout"]
84
+ if "terminalCullEnabled" in settings:
85
+ self._terminal_cull_enabled = settings["terminalCullEnabled"]
86
+ if "terminalCullIdleTimeout" in settings:
87
+ self._terminal_cull_idle_timeout = settings["terminalCullIdleTimeout"]
88
+ if "terminalCullDisconnectedOnly" in settings:
89
+ self._terminal_cull_disconnected_only = settings["terminalCullDisconnectedOnly"]
90
+ if "workspaceCullEnabled" in settings:
91
+ self._workspace_cull_enabled = settings["workspaceCullEnabled"]
92
+ if "workspaceCullIdleTimeout" in settings:
93
+ self._workspace_cull_idle_timeout = settings["workspaceCullIdleTimeout"]
94
+ if "cullCheckInterval" in settings:
95
+ new_interval = settings["cullCheckInterval"]
96
+ if new_interval != self._cull_check_interval:
97
+ self._cull_check_interval = new_interval
98
+ # Restart periodic callback with new interval
99
+ if self._periodic_callback is not None:
100
+ self.stop()
101
+ self.start()
102
+
103
+ logger.info(
104
+ f"[Culler] Settings updated: kernel={self._kernel_cull_enabled}/{self._kernel_cull_idle_timeout}min, "
105
+ f"terminal={self._terminal_cull_enabled}/{self._terminal_cull_idle_timeout}min"
106
+ f"(disconnected_only={self._terminal_cull_disconnected_only}), "
107
+ f"workspace={self._workspace_cull_enabled}/{self._workspace_cull_idle_timeout}min, "
108
+ f"interval={self._cull_check_interval}min"
109
+ )
110
+
111
+ def get_settings(self) -> dict[str, Any]:
112
+ """Return current settings."""
113
+ return {
114
+ "kernelCullEnabled": self._kernel_cull_enabled,
115
+ "kernelCullIdleTimeout": self._kernel_cull_idle_timeout,
116
+ "terminalCullEnabled": self._terminal_cull_enabled,
117
+ "terminalCullIdleTimeout": self._terminal_cull_idle_timeout,
118
+ "terminalCullDisconnectedOnly": self._terminal_cull_disconnected_only,
119
+ "workspaceCullEnabled": self._workspace_cull_enabled,
120
+ "workspaceCullIdleTimeout": self._workspace_cull_idle_timeout,
121
+ "cullCheckInterval": self._cull_check_interval,
122
+ }
123
+
124
+ def get_status(self) -> dict[str, Any]:
125
+ """Return culler status including settings and running state."""
126
+ return {
127
+ "running": self._periodic_callback is not None,
128
+ "settings": self.get_settings(),
129
+ }
130
+
131
+ def start(self) -> None:
132
+ """Start the periodic culling task."""
133
+ if self._periodic_callback is not None:
134
+ logger.warning("[Culler] Already running, ignoring start request")
135
+ return
136
+
137
+ interval_ms = self._cull_check_interval * 60 * 1000
138
+ self._periodic_callback = PeriodicCallback(
139
+ self._cull_idle_resources, interval_ms
140
+ )
141
+ self._periodic_callback.start()
142
+ logger.info(
143
+ f"[Culler] Started with check interval of {self._cull_check_interval} minutes"
144
+ )
145
+
146
+ def stop(self) -> None:
147
+ """Stop the periodic culling task."""
148
+ if self._periodic_callback is not None:
149
+ self._periodic_callback.stop()
150
+ self._periodic_callback = None
151
+ logger.info("[Culler] Stopped")
152
+
153
+ def get_last_cull_result(self) -> dict[str, list[str]]:
154
+ """Return last culling result and mark as consumed."""
155
+ if self._result_consumed:
156
+ return {"kernels_culled": [], "terminals_culled": [], "workspaces_culled": []}
157
+ self._result_consumed = True
158
+ return self._last_cull_result
159
+
160
+ def get_terminals_connection_status(self) -> dict[str, bool]:
161
+ """Return connection status (has active tab) for all terminals."""
162
+ result: dict[str, bool] = {}
163
+ terminal_mgr = self.terminal_manager
164
+ if terminal_mgr is None:
165
+ return result
166
+
167
+ try:
168
+ terminals = terminal_mgr.list()
169
+ for terminal in terminals:
170
+ name = terminal.get("name")
171
+ if name:
172
+ result[name] = self._terminal_has_active_tab(name)
173
+ except Exception:
174
+ pass
175
+
176
+ return result
177
+
178
+ def set_active_terminals(self, terminals: list[str]) -> None:
179
+ """Update the set of terminals that have open tabs in the frontend."""
180
+ self._active_terminals = set(terminals)
181
+ logger.debug(f"[Culler] Active terminals updated: {self._active_terminals}")
182
+
183
+ def _terminal_has_active_tab(self, name: str) -> bool:
184
+ """Check if a terminal has an active tab in the frontend."""
185
+ return name in self._active_terminals
186
+
187
+ def list_workspaces(self) -> list[dict[str, Any]]:
188
+ """Return list of workspaces with their metadata."""
189
+ result: list[dict[str, Any]] = []
190
+ ws_mgr = self.workspace_manager
191
+ if ws_mgr is None:
192
+ return result
193
+
194
+ try:
195
+ for ws in ws_mgr.list_workspaces():
196
+ metadata = ws.get("metadata", {})
197
+ result.append({
198
+ "id": metadata.get("id", "unknown"),
199
+ "last_modified": metadata.get("last_modified"),
200
+ "created": metadata.get("created"),
201
+ })
202
+ except Exception as e:
203
+ logger.error(f"[Culler] Failed to list workspaces: {e}")
204
+
205
+ return result
206
+
207
+ async def _cull_idle_resources(self) -> None:
208
+ """Main culling routine called by periodic callback."""
209
+ kernels_culled: list[str] = []
210
+ terminals_culled: list[str] = []
211
+ workspaces_culled: list[str] = []
212
+
213
+ if self._kernel_cull_enabled:
214
+ kernels_culled = await self._cull_kernels()
215
+
216
+ if self._terminal_cull_enabled:
217
+ terminals_culled = await self._cull_terminals()
218
+
219
+ if self._workspace_cull_enabled:
220
+ workspaces_culled = self._cull_workspaces()
221
+
222
+ # Store result for notification polling
223
+ if kernels_culled or terminals_culled or workspaces_culled:
224
+ self._last_cull_result = {
225
+ "kernels_culled": kernels_culled,
226
+ "terminals_culled": terminals_culled,
227
+ "workspaces_culled": workspaces_culled,
228
+ }
229
+ self._result_consumed = False
230
+
231
+ async def _cull_kernels(self) -> list[str]:
232
+ """Cull idle kernels exceeding timeout threshold."""
233
+ culled: list[str] = []
234
+ now = datetime.now(timezone.utc)
235
+ timeout_seconds = self._kernel_cull_idle_timeout * 60
236
+
237
+ try:
238
+ kernel_ids = list(self.kernel_manager.list_kernel_ids())
239
+ except Exception as e:
240
+ logger.error(f"[Culler] Failed to list kernels: {e}")
241
+ return culled
242
+
243
+ for kernel_id in kernel_ids:
244
+ try:
245
+ kernel = self.kernel_manager.get_kernel(kernel_id)
246
+ if kernel is None:
247
+ continue
248
+
249
+ # Check execution state - skip busy kernels
250
+ execution_state = getattr(kernel, "execution_state", "idle")
251
+ if execution_state == "busy":
252
+ continue
253
+
254
+ # Check last activity
255
+ last_activity = getattr(kernel, "last_activity", None)
256
+ if last_activity is None:
257
+ continue
258
+
259
+ # Ensure timezone-aware comparison
260
+ if last_activity.tzinfo is None:
261
+ last_activity = last_activity.replace(tzinfo=timezone.utc)
262
+
263
+ idle_seconds = (now - last_activity).total_seconds()
264
+ idle_minutes = idle_seconds / 60
265
+
266
+ if idle_seconds > timeout_seconds:
267
+ logger.info(
268
+ f"[Culler] CULLING KERNEL {kernel_id} - idle {idle_minutes:.1f} minutes "
269
+ f"(threshold: {self._kernel_cull_idle_timeout})"
270
+ )
271
+ await self.kernel_manager.shutdown_kernel(kernel_id)
272
+ logger.info(f"[Culler] Kernel {kernel_id} culled successfully")
273
+ culled.append(kernel_id)
274
+
275
+ except Exception as e:
276
+ logger.error(f"[Culler] Failed to cull kernel {kernel_id}: {e}")
277
+
278
+ return culled
279
+
280
+ async def _cull_terminals(self) -> list[str]:
281
+ """Cull idle terminals exceeding timeout threshold."""
282
+ culled: list[str] = []
283
+ now = datetime.now(timezone.utc)
284
+ timeout_seconds = self._terminal_cull_idle_timeout * 60
285
+
286
+ terminal_mgr = self.terminal_manager
287
+ if terminal_mgr is None:
288
+ logger.warning("[Culler] Terminal manager not available")
289
+ return culled
290
+
291
+ try:
292
+ terminals = terminal_mgr.list()
293
+ except Exception as e:
294
+ logger.error(f"[Culler] Failed to list terminals: {e}")
295
+ return culled
296
+
297
+ for terminal in terminals:
298
+ try:
299
+ name = terminal.get("name")
300
+ if name is None:
301
+ continue
302
+
303
+ # Check for active tabs if setting enabled
304
+ if self._terminal_cull_disconnected_only:
305
+ if self._terminal_has_active_tab(name):
306
+ logger.debug(
307
+ f"[Culler] Skipping terminal {name} - has active tab"
308
+ )
309
+ continue
310
+
311
+ last_activity = terminal.get("last_activity")
312
+ if last_activity is None:
313
+ continue
314
+
315
+ # Parse datetime if string
316
+ if isinstance(last_activity, str):
317
+ last_activity = datetime.fromisoformat(
318
+ last_activity.replace("Z", "+00:00")
319
+ )
320
+
321
+ # Ensure timezone-aware comparison
322
+ if last_activity.tzinfo is None:
323
+ last_activity = last_activity.replace(tzinfo=timezone.utc)
324
+
325
+ idle_seconds = (now - last_activity).total_seconds()
326
+ idle_minutes = idle_seconds / 60
327
+
328
+ if idle_seconds > timeout_seconds:
329
+ logger.info(
330
+ f"[Culler] CULLING TERMINAL {name} - idle {idle_minutes:.1f} minutes "
331
+ f"(threshold: {self._terminal_cull_idle_timeout})"
332
+ )
333
+ await terminal_mgr.terminate(name)
334
+ logger.info(f"[Culler] Terminal {name} culled successfully")
335
+ culled.append(name)
336
+
337
+ except Exception as e:
338
+ logger.error(f"[Culler] Failed to cull terminal {name}: {e}")
339
+
340
+ return culled
341
+
342
+ def _cull_workspaces(self) -> list[str]:
343
+ """Cull idle workspaces exceeding timeout threshold."""
344
+ culled: list[str] = []
345
+ now = datetime.now(timezone.utc)
346
+ timeout_seconds = self._workspace_cull_idle_timeout * 60
347
+
348
+ ws_mgr = self.workspace_manager
349
+ if ws_mgr is None:
350
+ logger.warning("[Culler] Workspace manager not available")
351
+ return culled
352
+
353
+ try:
354
+ workspaces = list(ws_mgr.list_workspaces())
355
+ except Exception as e:
356
+ logger.error(f"[Culler] Failed to list workspaces: {e}")
357
+ return culled
358
+
359
+ for workspace in workspaces:
360
+ try:
361
+ metadata = workspace.get("metadata", {})
362
+ workspace_id = metadata.get("id")
363
+ if workspace_id is None:
364
+ continue
365
+
366
+ # Never cull the default workspace
367
+ if workspace_id == "default":
368
+ logger.debug("[Culler] Skipping default workspace")
369
+ continue
370
+
371
+ last_modified = metadata.get("last_modified")
372
+ if last_modified is None:
373
+ continue
374
+
375
+ # Parse datetime if string
376
+ if isinstance(last_modified, str):
377
+ last_modified = datetime.fromisoformat(
378
+ last_modified.replace("Z", "+00:00")
379
+ )
380
+
381
+ # Ensure timezone-aware comparison
382
+ if last_modified.tzinfo is None:
383
+ last_modified = last_modified.replace(tzinfo=timezone.utc)
384
+
385
+ idle_seconds = (now - last_modified).total_seconds()
386
+ idle_minutes = idle_seconds / 60
387
+
388
+ if idle_seconds > timeout_seconds:
389
+ logger.info(
390
+ f"[Culler] CULLING WORKSPACE {workspace_id} - idle {idle_minutes:.1f} minutes "
391
+ f"(threshold: {self._workspace_cull_idle_timeout})"
392
+ )
393
+ ws_mgr.delete(workspace_id)
394
+ logger.info(f"[Culler] Workspace {workspace_id} culled successfully")
395
+ culled.append(workspace_id)
396
+
397
+ except Exception as e:
398
+ logger.error(f"[Culler] Failed to cull workspace {workspace_id}: {e}")
399
+
400
+ return culled
401
+
402
+ def cull_workspaces_with_timeout(
403
+ self, timeout_minutes: int, dry_run: bool = False
404
+ ) -> list[dict[str, Any]]:
405
+ """Cull workspaces with specified timeout (for CLI use).
406
+
407
+ Args:
408
+ timeout_minutes: Idle timeout in minutes
409
+ dry_run: If True, return what would be culled without actually culling
410
+
411
+ Returns:
412
+ List of workspace dicts with id, idle_time, and action
413
+ """
414
+ result: list[dict[str, Any]] = []
415
+ now = datetime.now(timezone.utc)
416
+ timeout_seconds = timeout_minutes * 60
417
+
418
+ ws_mgr = self.workspace_manager
419
+ if ws_mgr is None:
420
+ logger.warning("[Culler] Workspace manager not available")
421
+ return result
422
+
423
+ try:
424
+ workspaces = list(ws_mgr.list_workspaces())
425
+ except Exception as e:
426
+ logger.error(f"[Culler] Failed to list workspaces: {e}")
427
+ return result
428
+
429
+ for workspace in workspaces:
430
+ try:
431
+ metadata = workspace.get("metadata", {})
432
+ workspace_id = metadata.get("id")
433
+ if workspace_id is None:
434
+ continue
435
+
436
+ # Never cull the default workspace
437
+ if workspace_id == "default":
438
+ continue
439
+
440
+ last_modified = metadata.get("last_modified")
441
+ if last_modified is None:
442
+ continue
443
+
444
+ # Parse datetime if string
445
+ if isinstance(last_modified, str):
446
+ last_modified = datetime.fromisoformat(
447
+ last_modified.replace("Z", "+00:00")
448
+ )
449
+
450
+ # Ensure timezone-aware comparison
451
+ if last_modified.tzinfo is None:
452
+ last_modified = last_modified.replace(tzinfo=timezone.utc)
453
+
454
+ idle_seconds = (now - last_modified).total_seconds()
455
+ idle_minutes = idle_seconds / 60
456
+
457
+ if idle_seconds > timeout_seconds:
458
+ if dry_run:
459
+ # Format idle time for display
460
+ if idle_seconds < 3600:
461
+ idle_time = f"{idle_minutes:.1f}m"
462
+ elif idle_seconds < 86400:
463
+ idle_time = f"{idle_seconds / 3600:.1f}h"
464
+ else:
465
+ idle_time = f"{idle_seconds / 86400:.1f}d"
466
+ result.append({
467
+ "id": workspace_id,
468
+ "idle_time": idle_time,
469
+ "action": "would_cull",
470
+ })
471
+ else:
472
+ logger.info(
473
+ f"[Culler] CLI CULLING WORKSPACE {workspace_id} - "
474
+ f"idle {idle_minutes:.1f} minutes (threshold: {timeout_minutes})"
475
+ )
476
+ ws_mgr.delete(workspace_id)
477
+ logger.info(f"[Culler] Workspace {workspace_id} culled successfully")
478
+ # Format idle time for display
479
+ if idle_seconds < 3600:
480
+ idle_time = f"{idle_minutes:.1f}m"
481
+ elif idle_seconds < 86400:
482
+ idle_time = f"{idle_seconds / 3600:.1f}h"
483
+ else:
484
+ idle_time = f"{idle_seconds / 86400:.1f}d"
485
+ result.append({
486
+ "id": workspace_id,
487
+ "idle_time": idle_time,
488
+ "action": "culled",
489
+ })
490
+
491
+ except Exception as e:
492
+ logger.error(f"[Culler] Failed to cull workspace {workspace_id}: {e}")
493
+ result.append({
494
+ "id": workspace_id,
495
+ "idle_time": "unknown",
496
+ "action": "failed",
497
+ })
498
+
499
+ return result
@@ -0,0 +1,176 @@
1
+ """Route handlers for the resource culler extension."""
2
+
3
+ import json
4
+ from typing import TYPE_CHECKING
5
+
6
+ import tornado
7
+ from jupyter_server.base.handlers import APIHandler
8
+ from jupyter_server.utils import url_path_join
9
+
10
+ if TYPE_CHECKING:
11
+ from .culler import ResourceCuller
12
+
13
+ # Global reference to culler instance - set by __init__.py
14
+ _culler: "ResourceCuller | None" = None
15
+
16
+
17
+ def set_culler(culler: "ResourceCuller") -> None:
18
+ """Set the global culler instance."""
19
+ global _culler
20
+ _culler = culler
21
+
22
+
23
+ def get_culler() -> "ResourceCuller | None":
24
+ """Get the global culler instance."""
25
+ return _culler
26
+
27
+
28
+ class SettingsHandler(APIHandler):
29
+ """Handler for updating culler settings."""
30
+
31
+ @tornado.web.authenticated
32
+ def post(self) -> None:
33
+ """Update culler settings from frontend."""
34
+ if _culler is None:
35
+ self.set_status(503)
36
+ self.finish(json.dumps({"error": "Culler not initialized"}))
37
+ return
38
+
39
+ try:
40
+ settings = json.loads(self.request.body)
41
+ _culler.update_settings(settings)
42
+ self.finish(json.dumps({"status": "ok"}))
43
+ except json.JSONDecodeError:
44
+ self.set_status(400)
45
+ self.finish(json.dumps({"error": "Invalid JSON"}))
46
+ except Exception as e:
47
+ self.set_status(500)
48
+ self.finish(json.dumps({"error": str(e)}))
49
+
50
+
51
+ class StatusHandler(APIHandler):
52
+ """Handler for returning culler status."""
53
+
54
+ @tornado.web.authenticated
55
+ def get(self) -> None:
56
+ """Return culler status and settings."""
57
+ if _culler is None:
58
+ self.set_status(503)
59
+ self.finish(json.dumps({"error": "Culler not initialized"}))
60
+ return
61
+
62
+ self.finish(json.dumps(_culler.get_status()))
63
+
64
+
65
+ class CullResultHandler(APIHandler):
66
+ """Handler for returning last culling summary."""
67
+
68
+ @tornado.web.authenticated
69
+ def get(self) -> None:
70
+ """Return last culling result for notification polling."""
71
+ if _culler is None:
72
+ self.finish(
73
+ json.dumps(
74
+ {
75
+ "kernels_culled": [],
76
+ "terminals_culled": [],
77
+ "workspaces_culled": [],
78
+ }
79
+ )
80
+ )
81
+ return
82
+
83
+ self.finish(json.dumps(_culler.get_last_cull_result()))
84
+
85
+
86
+ class TerminalsConnectionHandler(APIHandler):
87
+ """Handler for returning terminal connection status."""
88
+
89
+ @tornado.web.authenticated
90
+ def get(self) -> None:
91
+ """Return connection status for all terminals."""
92
+ if _culler is None:
93
+ self.finish(json.dumps({}))
94
+ return
95
+
96
+ self.finish(json.dumps(_culler.get_terminals_connection_status()))
97
+
98
+
99
+ class ActiveTerminalsHandler(APIHandler):
100
+ """Handler for receiving active terminal list from frontend."""
101
+
102
+ @tornado.web.authenticated
103
+ def post(self) -> None:
104
+ """Update active terminals list."""
105
+ if _culler is None:
106
+ self.set_status(503)
107
+ self.finish(json.dumps({"error": "Culler not initialized"}))
108
+ return
109
+ try:
110
+ data = json.loads(self.request.body)
111
+ terminals = data.get("terminals", [])
112
+ _culler.set_active_terminals(terminals)
113
+ self.finish(json.dumps({"status": "ok"}))
114
+ except json.JSONDecodeError:
115
+ self.set_status(400)
116
+ self.finish(json.dumps({"error": "Invalid JSON"}))
117
+ except Exception as e:
118
+ self.set_status(500)
119
+ self.finish(json.dumps({"error": str(e)}))
120
+
121
+
122
+ class WorkspacesHandler(APIHandler):
123
+ """Handler for listing workspaces."""
124
+
125
+ @tornado.web.authenticated
126
+ def get(self) -> None:
127
+ """Return list of workspaces with metadata."""
128
+ if _culler is None:
129
+ self.finish(json.dumps([]))
130
+ return
131
+
132
+ self.finish(json.dumps(_culler.list_workspaces()))
133
+
134
+
135
+ class CullWorkspacesHandler(APIHandler):
136
+ """Handler for culling workspaces via CLI."""
137
+
138
+ @tornado.web.authenticated
139
+ def post(self) -> None:
140
+ """Cull workspaces based on timeout parameter."""
141
+ if _culler is None:
142
+ self.set_status(503)
143
+ self.finish(json.dumps({"error": "Culler not initialized"}))
144
+ return
145
+
146
+ try:
147
+ data = json.loads(self.request.body)
148
+ timeout_minutes = data.get("timeout", 10080) # default 7 days
149
+ dry_run = data.get("dry_run", False)
150
+ culled = _culler.cull_workspaces_with_timeout(timeout_minutes, dry_run)
151
+ self.finish(json.dumps({"workspaces_culled": culled}))
152
+ except json.JSONDecodeError:
153
+ self.set_status(400)
154
+ self.finish(json.dumps({"error": "Invalid JSON"}))
155
+ except Exception as e:
156
+ self.set_status(500)
157
+ self.finish(json.dumps({"error": str(e)}))
158
+
159
+
160
+ def setup_route_handlers(web_app: tornado.web.Application) -> None:
161
+ """Set up route handlers for the extension."""
162
+ host_pattern = ".*$"
163
+ base_url = web_app.settings["base_url"]
164
+ namespace = "jupyterlab-kernel-terminal-workspace-culler-extension"
165
+
166
+ handlers = [
167
+ (url_path_join(base_url, namespace, "settings"), SettingsHandler),
168
+ (url_path_join(base_url, namespace, "status"), StatusHandler),
169
+ (url_path_join(base_url, namespace, "cull-result"), CullResultHandler),
170
+ (url_path_join(base_url, namespace, "terminals-connection"), TerminalsConnectionHandler),
171
+ (url_path_join(base_url, namespace, "active-terminals"), ActiveTerminalsHandler),
172
+ (url_path_join(base_url, namespace, "workspaces"), WorkspacesHandler),
173
+ (url_path_join(base_url, namespace, "cull-workspaces"), CullWorkspacesHandler),
174
+ ]
175
+
176
+ web_app.add_handlers(host_pattern, handlers)
@@ -0,0 +1 @@
1
+ """Python unit tests for jupyterlab_kernel_terminal_workspace_culler_extension."""