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.
- jupyterlab_kernel_terminal_workspace_culler_extension/__init__.py +54 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/_version.py +4 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/cli.py +430 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/culler.py +499 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/routes.py +176 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/tests/__init__.py +1 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/tests/test_culler.py +269 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/tests/test_routes.py +17 -0
- 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
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/install.json +5 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/package.json +219 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/METADATA +208 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/RECORD +23 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/WHEEL +4 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/entry_points.txt +2 -0
- 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."""
|