dbxj 0.1.0__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 (182) hide show
  1. dbxj/__init__.py +0 -0
  2. dbxj/auth.py +131 -0
  3. dbxj/body.py +530 -0
  4. dbxj/dialogs.py +100 -0
  5. dbxj/jobs.py +89 -0
  6. dbxj/main.py +206 -0
  7. dbxj/main.tcss +104 -0
  8. dbxj/mock_workspace_client.py +203 -0
  9. dbxj/sample_data/job_100000000000000.json +112 -0
  10. dbxj/sample_data/job_100007919000000.json +112 -0
  11. dbxj/sample_data/job_100015838000000.json +64 -0
  12. dbxj/sample_data/job_100023757000000.json +90 -0
  13. dbxj/sample_data/job_100031676000000.json +222 -0
  14. dbxj/sample_data/job_100039595000000.json +24 -0
  15. dbxj/sample_data/jobs.json +44 -0
  16. dbxj/sample_data/run_500000000000000.json +55 -0
  17. dbxj/sample_data/run_500000006363900.json +55 -0
  18. dbxj/sample_data/run_500000007613857.json +55 -0
  19. dbxj/sample_data/run_500000010463046.json +55 -0
  20. dbxj/sample_data/run_500000012320447.json +55 -0
  21. dbxj/sample_data/run_500000021057221.json +55 -0
  22. dbxj/sample_data/run_500000022726564.json +55 -0
  23. dbxj/sample_data/run_500000029348400.json +55 -0
  24. dbxj/sample_data/run_500000033531502.json +55 -0
  25. dbxj/sample_data/run_500000035086732.json +55 -0
  26. dbxj/sample_data/run_500000042336152.json +55 -0
  27. dbxj/sample_data/run_500000044098755.json +55 -0
  28. dbxj/sample_data/run_500000048300009.json +55 -0
  29. dbxj/sample_data/run_500000050235776.json +55 -0
  30. dbxj/sample_data/run_500000058538215.json +55 -0
  31. dbxj/sample_data/run_500000065934619.json +55 -0
  32. dbxj/sample_data/run_500000074305872.json +55 -0
  33. dbxj/sample_data/run_500000081730156.json +55 -0
  34. dbxj/sample_data/run_500000090522447.json +55 -0
  35. dbxj/sample_data/run_500000093602030.json +55 -0
  36. dbxj/sample_data/run_500000099538223.json +52 -0
  37. dbxj/sample_data/run_500000102534750.json +55 -0
  38. dbxj/sample_data/run_500000110308525.json +55 -0
  39. dbxj/sample_data/run_500000111816980.json +55 -0
  40. dbxj/sample_data/run_500000114356189.json +55 -0
  41. dbxj/sample_data/run_500000116138533.json +55 -0
  42. dbxj/sample_data/run_500000120413628.json +21 -0
  43. dbxj/sample_data/run_500000121904221.json +21 -0
  44. dbxj/sample_data/run_500000127804657.json +21 -0
  45. dbxj/sample_data/run_500000135946235.json +21 -0
  46. dbxj/sample_data/run_500000140333349.json +21 -0
  47. dbxj/sample_data/run_500000142021075.json +21 -0
  48. dbxj/sample_data/run_500000145023317.json +21 -0
  49. dbxj/sample_data/run_500000148675603.json +21 -0
  50. dbxj/sample_data/run_500000150290158.json +21 -0
  51. dbxj/sample_data/run_500000159118905.json +21 -0
  52. dbxj/sample_data/run_500000161442181.json +21 -0
  53. dbxj/sample_data/run_500000168044623.json +21 -0
  54. dbxj/sample_data/run_500000171263123.json +21 -0
  55. dbxj/sample_data/run_500000174626665.json +21 -0
  56. dbxj/sample_data/run_500000179866343.json +21 -0
  57. dbxj/sample_data/run_500000187823419.json +21 -0
  58. dbxj/sample_data/run_500000194167913.json +21 -0
  59. dbxj/sample_data/run_500000199873140.json +21 -0
  60. dbxj/sample_data/run_500000201175365.json +21 -0
  61. dbxj/sample_data/run_500000204761223.json +21 -0
  62. dbxj/sample_data/run_500000211355865.json +21 -0
  63. dbxj/sample_data/run_500000218642938.json +21 -0
  64. dbxj/sample_data/run_500000219850861.json +21 -0
  65. dbxj/sample_data/run_500000222186309.json +21 -0
  66. dbxj/sample_data/run_500000224860447.json +21 -0
  67. dbxj/sample_data/run_500000226058958.json +21 -0
  68. dbxj/sample_data/run_500000234309676.json +21 -0
  69. dbxj/sample_data/run_500000241009887.json +21 -0
  70. dbxj/sample_data/run_500000243507822.json +55 -0
  71. dbxj/sample_data/run_500000248153207.json +55 -0
  72. dbxj/sample_data/run_output_500000000000001.json +3 -0
  73. dbxj/sample_data/run_output_500000000000002.json +3 -0
  74. dbxj/sample_data/run_output_500000000000003.json +3 -0
  75. dbxj/sample_data/run_output_500000006363901.json +3 -0
  76. dbxj/sample_data/run_output_500000006363902.json +3 -0
  77. dbxj/sample_data/run_output_500000006363903.json +3 -0
  78. dbxj/sample_data/run_output_500000007613858.json +3 -0
  79. dbxj/sample_data/run_output_500000007613859.json +3 -0
  80. dbxj/sample_data/run_output_500000007613860.json +3 -0
  81. dbxj/sample_data/run_output_500000010463047.json +3 -0
  82. dbxj/sample_data/run_output_500000010463048.json +3 -0
  83. dbxj/sample_data/run_output_500000010463049.json +3 -0
  84. dbxj/sample_data/run_output_500000012320448.json +3 -0
  85. dbxj/sample_data/run_output_500000012320449.json +3 -0
  86. dbxj/sample_data/run_output_500000012320450.json +3 -0
  87. dbxj/sample_data/run_output_500000021057222.json +3 -0
  88. dbxj/sample_data/run_output_500000021057223.json +3 -0
  89. dbxj/sample_data/run_output_500000021057224.json +3 -0
  90. dbxj/sample_data/run_output_500000022726565.json +3 -0
  91. dbxj/sample_data/run_output_500000022726566.json +3 -0
  92. dbxj/sample_data/run_output_500000022726567.json +3 -0
  93. dbxj/sample_data/run_output_500000029348401.json +3 -0
  94. dbxj/sample_data/run_output_500000029348402.json +3 -0
  95. dbxj/sample_data/run_output_500000029348403.json +3 -0
  96. dbxj/sample_data/run_output_500000033531503.json +3 -0
  97. dbxj/sample_data/run_output_500000033531504.json +3 -0
  98. dbxj/sample_data/run_output_500000033531505.json +3 -0
  99. dbxj/sample_data/run_output_500000035086733.json +3 -0
  100. dbxj/sample_data/run_output_500000035086734.json +3 -0
  101. dbxj/sample_data/run_output_500000035086735.json +3 -0
  102. dbxj/sample_data/run_output_500000042336153.json +3 -0
  103. dbxj/sample_data/run_output_500000042336154.json +4 -0
  104. dbxj/sample_data/run_output_500000044098756.json +3 -0
  105. dbxj/sample_data/run_output_500000044098757.json +3 -0
  106. dbxj/sample_data/run_output_500000044098758.json +3 -0
  107. dbxj/sample_data/run_output_500000048300010.json +3 -0
  108. dbxj/sample_data/run_output_500000048300011.json +3 -0
  109. dbxj/sample_data/run_output_500000048300012.json +3 -0
  110. dbxj/sample_data/run_output_500000050235777.json +3 -0
  111. dbxj/sample_data/run_output_500000050235778.json +4 -0
  112. dbxj/sample_data/run_output_500000058538216.json +3 -0
  113. dbxj/sample_data/run_output_500000058538217.json +3 -0
  114. dbxj/sample_data/run_output_500000058538218.json +3 -0
  115. dbxj/sample_data/run_output_500000065934620.json +3 -0
  116. dbxj/sample_data/run_output_500000065934621.json +3 -0
  117. dbxj/sample_data/run_output_500000065934622.json +3 -0
  118. dbxj/sample_data/run_output_500000074305873.json +3 -0
  119. dbxj/sample_data/run_output_500000074305874.json +3 -0
  120. dbxj/sample_data/run_output_500000074305875.json +3 -0
  121. dbxj/sample_data/run_output_500000081730157.json +3 -0
  122. dbxj/sample_data/run_output_500000081730158.json +3 -0
  123. dbxj/sample_data/run_output_500000081730159.json +3 -0
  124. dbxj/sample_data/run_output_500000090522448.json +3 -0
  125. dbxj/sample_data/run_output_500000090522449.json +4 -0
  126. dbxj/sample_data/run_output_500000093602031.json +3 -0
  127. dbxj/sample_data/run_output_500000093602032.json +3 -0
  128. dbxj/sample_data/run_output_500000093602033.json +3 -0
  129. dbxj/sample_data/run_output_500000099538224.json +3 -0
  130. dbxj/sample_data/run_output_500000099538225.json +3 -0
  131. dbxj/sample_data/run_output_500000102534751.json +3 -0
  132. dbxj/sample_data/run_output_500000102534752.json +3 -0
  133. dbxj/sample_data/run_output_500000102534753.json +3 -0
  134. dbxj/sample_data/run_output_500000110308526.json +3 -0
  135. dbxj/sample_data/run_output_500000110308527.json +3 -0
  136. dbxj/sample_data/run_output_500000110308528.json +3 -0
  137. dbxj/sample_data/run_output_500000111816981.json +3 -0
  138. dbxj/sample_data/run_output_500000111816982.json +3 -0
  139. dbxj/sample_data/run_output_500000111816983.json +3 -0
  140. dbxj/sample_data/run_output_500000114356190.json +3 -0
  141. dbxj/sample_data/run_output_500000114356191.json +3 -0
  142. dbxj/sample_data/run_output_500000114356192.json +3 -0
  143. dbxj/sample_data/run_output_500000116138534.json +3 -0
  144. dbxj/sample_data/run_output_500000116138535.json +3 -0
  145. dbxj/sample_data/run_output_500000116138536.json +3 -0
  146. dbxj/sample_data/run_output_500000121904222.json +3 -0
  147. dbxj/sample_data/run_output_500000127804658.json +3 -0
  148. dbxj/sample_data/run_output_500000140333350.json +3 -0
  149. dbxj/sample_data/run_output_500000142021076.json +3 -0
  150. dbxj/sample_data/run_output_500000145023318.json +3 -0
  151. dbxj/sample_data/run_output_500000148675604.json +3 -0
  152. dbxj/sample_data/run_output_500000150290159.json +3 -0
  153. dbxj/sample_data/run_output_500000159118906.json +3 -0
  154. dbxj/sample_data/run_output_500000161442182.json +3 -0
  155. dbxj/sample_data/run_output_500000168044624.json +4 -0
  156. dbxj/sample_data/run_output_500000171263124.json +3 -0
  157. dbxj/sample_data/run_output_500000174626666.json +3 -0
  158. dbxj/sample_data/run_output_500000179866344.json +3 -0
  159. dbxj/sample_data/run_output_500000187823420.json +3 -0
  160. dbxj/sample_data/run_output_500000194167914.json +3 -0
  161. dbxj/sample_data/run_output_500000199873141.json +3 -0
  162. dbxj/sample_data/run_output_500000201175366.json +3 -0
  163. dbxj/sample_data/run_output_500000204761224.json +3 -0
  164. dbxj/sample_data/run_output_500000211355866.json +3 -0
  165. dbxj/sample_data/run_output_500000218642939.json +3 -0
  166. dbxj/sample_data/run_output_500000219850862.json +3 -0
  167. dbxj/sample_data/run_output_500000222186310.json +3 -0
  168. dbxj/sample_data/run_output_500000224860448.json +3 -0
  169. dbxj/sample_data/run_output_500000226058959.json +3 -0
  170. dbxj/sample_data/run_output_500000234309677.json +3 -0
  171. dbxj/sample_data/run_output_500000241009888.json +3 -0
  172. dbxj/sample_data/run_output_500000243507823.json +3 -0
  173. dbxj/sample_data/run_output_500000243507824.json +3 -0
  174. dbxj/sample_data/run_output_500000243507825.json +3 -0
  175. dbxj/sample_data/run_output_500000248153208.json +3 -0
  176. dbxj/sample_data/run_output_500000248153209.json +3 -0
  177. dbxj/sample_data/run_output_500000248153210.json +3 -0
  178. dbxj-0.1.0.dist-info/METADATA +101 -0
  179. dbxj-0.1.0.dist-info/RECORD +182 -0
  180. dbxj-0.1.0.dist-info/WHEEL +4 -0
  181. dbxj-0.1.0.dist-info/entry_points.txt +2 -0
  182. dbxj-0.1.0.dist-info/licenses/LICENSE +21 -0
dbxj/__init__.py ADDED
File without changes
dbxj/auth.py ADDED
@@ -0,0 +1,131 @@
1
+ import os
2
+ import subprocess
3
+ from configparser import ConfigParser
4
+ from pathlib import Path
5
+
6
+ from databricks.sdk import WorkspaceClient
7
+ from rich.markup import escape
8
+ from textual import work
9
+ from textual.app import ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Vertical
12
+ from textual.screen import ModalScreen
13
+ from textual.widgets import Static
14
+
15
+
16
+ class AuthError(Exception):
17
+ pass
18
+
19
+
20
+ def config_path() -> Path:
21
+ return Path(os.environ.get("DATABRICKS_CONFIG_FILE", "~/.databrickscfg")).expanduser()
22
+
23
+
24
+ def available_profiles() -> list[str]:
25
+ parser = ConfigParser()
26
+ parser.read(config_path())
27
+ # ConfigParser treats DEFAULT specially so it doesn't appear in sections()
28
+ profiles = ["DEFAULT"] if parser.defaults() else []
29
+ return profiles + [s for s in parser.sections() if s != "__settings__"]
30
+
31
+
32
+ def connect(profile: str | None) -> tuple[WorkspaceClient, int, str]:
33
+ """Create a client and make API calls to confirm the auth is valid."""
34
+ try:
35
+ ws_client = WorkspaceClient(profile=profile)
36
+ user_name = ws_client.current_user.me().user_name or "Unknown"
37
+ workspace_id = ws_client.get_workspace_id()
38
+ except Exception as e:
39
+ raise AuthError(str(e)) from e
40
+ return ws_client, workspace_id, user_name
41
+
42
+
43
+ class ReauthScreen(ModalScreen[bool]):
44
+ """Runs `databricks auth login`, which opens the browser and waits for the login."""
45
+
46
+ BINDINGS = [
47
+ Binding("enter", "retry", "Retry", show=False),
48
+ Binding("r", "retry", "Retry", show=False),
49
+ Binding("q", "quit", "Quit", show=False),
50
+ Binding("escape", "quit", "Quit", show=False),
51
+ ]
52
+
53
+ process: subprocess.Popen | None = None
54
+ waiting = False
55
+ cancelled = False
56
+
57
+ def __init__(self, profile: str | None, error: str) -> None:
58
+ self.profile = profile
59
+ self.error = error
60
+ super().__init__()
61
+
62
+ def compose(self) -> ComposeResult:
63
+ profile = f"'{self.profile}'" if self.profile else "default profile"
64
+ with Vertical(id="reauth_dialog") as dialog:
65
+ dialog.border_title = f"Login expired: {profile}"
66
+ yield Static(self.error, id="reauth_error", markup=False)
67
+ yield Static(id="reauth_status")
68
+
69
+ def on_mount(self) -> None:
70
+ self.action_retry()
71
+
72
+ def set_waiting(self, waiting: bool) -> None:
73
+ self.waiting = waiting
74
+ dialog = self.query_one("#reauth_dialog")
75
+ if waiting:
76
+ self.query_one("#reauth_status", Static).update(
77
+ "Opening browser to log in... waiting for login to complete"
78
+ )
79
+ dialog.border_subtitle = "q quit"
80
+ else:
81
+ dialog.border_subtitle = "enter retry · q quit"
82
+
83
+ def action_retry(self) -> None:
84
+ if not self.waiting:
85
+ self.set_waiting(True)
86
+ self.login()
87
+
88
+ def action_quit(self) -> None:
89
+ self.cancelled = True
90
+ if self.process is not None:
91
+ self.process.terminate()
92
+ self.dismiss(False)
93
+
94
+ @work(exclusive=True, thread=True)
95
+ def login(self) -> None:
96
+ command = ["databricks", "auth", "login"]
97
+ if self.profile:
98
+ command += ["--profile", self.profile]
99
+
100
+ try:
101
+ self.process = subprocess.Popen(
102
+ command,
103
+ stdin=subprocess.DEVNULL,
104
+ stdout=subprocess.PIPE,
105
+ stderr=subprocess.PIPE,
106
+ text=True,
107
+ )
108
+ stdout, stderr = self.process.communicate()
109
+ returncode = self.process.returncode
110
+ except FileNotFoundError:
111
+ self.app.call_from_thread(
112
+ self.login_failed,
113
+ "Databricks CLI not found - install it and make sure it's on PATH",
114
+ )
115
+ return
116
+ finally:
117
+ self.process = None
118
+
119
+ if self.cancelled:
120
+ return
121
+ if returncode == 0:
122
+ self.app.call_from_thread(self.dismiss, True)
123
+ else:
124
+ message = stderr.strip() or stdout.strip() or "Login failed"
125
+ if not self.profile:
126
+ message += "\nTry launching with --profile <name>"
127
+ self.app.call_from_thread(self.login_failed, message)
128
+
129
+ def login_failed(self, message: str) -> None:
130
+ self.set_waiting(False)
131
+ self.query_one("#reauth_status", Static).update(f"[red]{escape(message)}[/]")
dbxj/body.py ADDED
@@ -0,0 +1,530 @@
1
+ from concurrent.futures import ThreadPoolExecutor, as_completed
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Dict, List
5
+
6
+ from databricks.sdk import WorkspaceClient
7
+ from databricks.sdk.service.jobs import RunLifecycleStateV2State, TerminationCodeCode
8
+ from rich.text import Text
9
+ from textual import events, work, on
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Vertical, VerticalScroll
13
+ from textual.message import Message
14
+ from textual.widget import Widget
15
+ from textual.widgets import DataTable, Static, Input
16
+ from textual.widgets.data_table import CellDoesNotExist, RowDoesNotExist
17
+ from textual.worker import get_current_worker
18
+
19
+ from dbxj.dialogs import ConfirmScreen
20
+ from dbxj.jobs import JobsAPI, RunResult
21
+
22
+ RECENT_RUNS = 5
23
+ RUN_SYMBOLS = {
24
+ RunResult.SUCCESS: ("✓", "green"),
25
+ RunResult.FAILED: ("✗", "red"),
26
+ RunResult.RUNNING: ("●", "yellow"),
27
+ RunResult.CANCELLED: ("⊘", "grey50"),
28
+ }
29
+
30
+
31
+ def render_recent_runs(results: List[RunResult] | None) -> Text:
32
+ """Oldest to newest, left padded with '-' when there are fewer than RECENT_RUNS runs.
33
+ None means the fetch failed."""
34
+ if results is None:
35
+ return Text("?", style="red")
36
+ text = Text()
37
+ for _ in range(RECENT_RUNS - len(results)):
38
+ text.append("- ", style="dim")
39
+ for result in results:
40
+ symbol, style = RUN_SYMBOLS[result]
41
+ text.append(symbol + " ", style=style)
42
+ text.rstrip()
43
+ return text
44
+
45
+
46
+ def format_start_time(ms: int | None) -> str:
47
+ """Epoch milliseconds as local time."""
48
+ if not ms:
49
+ return "-"
50
+ return datetime.fromtimestamp(ms / 1000).strftime("%Y-%m-%d %H:%M:%S")
51
+
52
+
53
+ def format_duration(ms: int | None) -> str:
54
+ """Milliseconds as e.g. '45s', '12m 34s', '2h 05m 10s', '1d 03h 12m'.
55
+ None (still running) shows as '-'."""
56
+ if ms is None:
57
+ return "-"
58
+ seconds = ms // 1000
59
+ days, seconds = divmod(seconds, 86400)
60
+ hours, seconds = divmod(seconds, 3600)
61
+ minutes, seconds = divmod(seconds, 60)
62
+ if days:
63
+ return f"{days}d {hours:02}h {minutes:02}m"
64
+ if hours:
65
+ return f"{hours}h {minutes:02}m {seconds:02}s"
66
+ if minutes:
67
+ return f"{minutes}m {seconds:02}s"
68
+ return f"{seconds}s"
69
+
70
+
71
+ class BodyState(Enum):
72
+ JOBS = 0
73
+ JOB_RUNS = 1
74
+ JOB_RUN = 2
75
+ TASK = 3
76
+
77
+
78
+ class FilterState(Enum):
79
+ NONE = 0
80
+ TYPING = 1
81
+ SELECTING = 2
82
+
83
+
84
+ class DataTablePlus(DataTable, can_focus=True):
85
+ BINDINGS = [
86
+ Binding("j", "cursor_down", "Down", show=False),
87
+ Binding("k", "cursor_up", "Up", show=False),
88
+ Binding("h", "scroll_left", "Scroll left", show=False),
89
+ Binding("l", "scroll_right", "Scroll right", show=False),
90
+ Binding("u", "page_up", "Page up", show=False),
91
+ Binding("d", "page_down", "Page down", show=False),
92
+ Binding("<", "scroll_top", "Top", show=False),
93
+ Binding(">", "scroll_bottom", "Bottom", show=False),
94
+ ]
95
+
96
+
97
+ class VerticalScrollPlus(VerticalScroll):
98
+ BINDINGS = [
99
+ Binding("j", "scroll_down", "Scroll down", show=False),
100
+ Binding("k", "scroll_up", "Scroll up", show=False),
101
+ Binding("d", "page_down", "Page down", show=False),
102
+ Binding("u", "page_up", "Page up", show=False),
103
+ Binding("g", "scroll_home", "Scroll home", show=False),
104
+ Binding("G", "scroll_end", "Scroll end", show=False),
105
+ ]
106
+
107
+
108
+ class DetailsPanel(Widget, can_focus=True):
109
+ def compose(self) -> ComposeResult:
110
+ yield VerticalScrollPlus(Static(id="details_panel_label"), id="details_panel_scroll")
111
+
112
+
113
+ class FilterPanel(Widget, can_focus=True):
114
+ filter_input: Input
115
+
116
+ def __init__(self, **kwargs) -> None:
117
+ self.filter_input = Input(id="filter_input", placeholder="Filter jobs by name")
118
+ super().__init__(**kwargs)
119
+
120
+ def compose(self) -> ComposeResult:
121
+ yield self.filter_input
122
+
123
+ def clear(self) -> None:
124
+ self.filter_input.clear()
125
+
126
+ def set_select_message(self, message: str) -> None:
127
+ self.filter_input.value = message
128
+
129
+ def focus_input(self) -> None:
130
+ self.filter_input.focus()
131
+
132
+
133
+ class Body(Widget):
134
+ state = BodyState.JOBS
135
+ filter_state = FilterState.NONE
136
+ jobs_initialised = False
137
+ # the text being filtered on once the filter is in SELECTING (the input then shows "Matches for")
138
+ job_filter = ""
139
+ current_job_id: str | None = None
140
+ current_job_key: str | None = None
141
+ current_run_id: str | None = None
142
+ current_run_key: str | None = None
143
+ current_task_run_id: str | None = None
144
+ current_task_url: str | None = None
145
+ workspace_id: int | None = None
146
+ # run page URLs for rows in the runs and tasks tables, keyed by row key
147
+ row_urls: Dict[str, str]
148
+ data_table: DataTablePlus
149
+ details_panel: Widget
150
+ filter_panel: FilterPanel
151
+
152
+ def compose(self) -> ComposeResult:
153
+ with Vertical():
154
+ yield FilterPanel(id="filter_panel")
155
+ yield DataTablePlus(show_header=True, show_cursor=True, id="table", cursor_type="row")
156
+ yield DetailsPanel(id="details_panel")
157
+
158
+ class JobsInitialised(Message):
159
+ pass
160
+
161
+ def initialise(self, ws_client: WorkspaceClient, workspace_id: int | None = None) -> None:
162
+ self.ws_client = ws_client
163
+ self.workspace_id = workspace_id
164
+ self.jobs_api = JobsAPI(ws_client)
165
+ self.row_urls = {}
166
+ self.data_table = self.query_one(DataTablePlus)
167
+ self.details_panel = self.query_one(DetailsPanel)
168
+ self.details_panel.display = False
169
+ self.filter_panel = self.query_one(FilterPanel)
170
+ self.filter_panel.display = False
171
+ self.state = BodyState.JOBS
172
+ self.load_jobs()
173
+
174
+ def show_filter(self) -> None:
175
+ self.filter_state = FilterState.TYPING
176
+ self.filter_panel.display = True
177
+ self.filter_panel.focus()
178
+ self.filter_panel.focus_input()
179
+
180
+ def hide_filter(self) -> None:
181
+ self.filter_state = FilterState.NONE
182
+ self.job_filter = ""
183
+ self.filter_panel.display = False
184
+ self.filter_panel.clear()
185
+
186
+ def active_filter(self) -> str:
187
+ if self.filter_state == FilterState.TYPING:
188
+ return self.filter_panel.filter_input.value
189
+ return self.job_filter
190
+
191
+ def on_key(self, event: events.Key) -> None:
192
+ if event.key == "escape":
193
+ if self.state == BodyState.JOBS and self.filter_state != FilterState.NONE:
194
+ self.hide_filter()
195
+ self.load_jobs(select_key=self.cursor_row_key())
196
+ self.data_table.focus()
197
+ if self.state == BodyState.JOB_RUNS: # looking at runs for a job
198
+ self.state = BodyState.JOBS
199
+ self.table_loading()
200
+ # the filter is kept while drilled in, so bring it back
201
+ self.filter_panel.display = self.filter_state != FilterState.NONE
202
+ self.load_jobs(filter=self.job_filter, select_key=self.current_job_key)
203
+ elif self.state == BodyState.JOB_RUN: # looking at the tasks in a job run
204
+ self.state = BodyState.JOB_RUNS
205
+ self.table_loading()
206
+ self.load_job_runs(self.current_job_id, select_key=self.current_run_key)
207
+ elif self.state == BodyState.TASK: # looking at specific task status/logs
208
+ self.state = BodyState.JOB_RUN
209
+ self.table_loading()
210
+ self.details_panel.display = False
211
+ self.data_table.display = True
212
+ self.load_job_run(self.current_run_id, select_key=str(self.current_task_run_id))
213
+ elif event.key == "slash":
214
+ if self.state == BodyState.JOBS:
215
+ self.show_filter()
216
+ elif event.key == "enter":
217
+ if self.state == BodyState.JOBS and self.filter_state == FilterState.TYPING:
218
+ event.stop() # prevent row selection in table
219
+ self.data_table.focus()
220
+ self.job_filter = self.filter_panel.filter_input.value
221
+ self.filter_state = FilterState.SELECTING
222
+ self.filter_panel.set_select_message(f"Matches for '{self.job_filter}'")
223
+
224
+ def cursor_row_key(self) -> str | None:
225
+ if self.data_table.row_count == 0:
226
+ return None
227
+ return self.data_table.ordered_rows[self.data_table.cursor_row].key.value
228
+
229
+ def select_row(self, key: str | None) -> None:
230
+ if key is None:
231
+ return
232
+ try:
233
+ self.data_table.move_cursor(row=self.data_table.get_row_index(key))
234
+ except RowDoesNotExist:
235
+ pass # row filtered out or gone since the last load
236
+
237
+ def refresh_view(self) -> None:
238
+ """Reload whatever is on screen, keeping the cursor on the same row."""
239
+ if not self.jobs_initialised:
240
+ return
241
+ if self.state == BodyState.TASK:
242
+ self.details_panel.loading = True
243
+ self.load_task_logs(self.current_task_run_id)
244
+ return
245
+
246
+ select_key = self.cursor_row_key()
247
+ self.table_loading()
248
+ if self.state == BodyState.JOBS:
249
+ self.load_jobs(
250
+ filter=self.active_filter(),
251
+ focus=self.filter_state != FilterState.TYPING,
252
+ clear_cache=True,
253
+ select_key=select_key,
254
+ )
255
+ elif self.state == BodyState.JOB_RUNS:
256
+ self.load_job_runs(self.current_job_id, select_key=select_key)
257
+ elif self.state == BodyState.JOB_RUN:
258
+ self.load_job_run(self.current_run_id, select_key=select_key)
259
+
260
+ def job_url(self, job_id: int | str | None, run_id: int | str | None = None) -> str:
261
+ url = f"{self.ws_client.config.host.rstrip('/')}/jobs/{job_id}"
262
+ if run_id is not None:
263
+ url += f"/runs/{run_id}"
264
+ if self.workspace_id:
265
+ url += f"?o={self.workspace_id}"
266
+ return url
267
+
268
+ def open_in_browser(self) -> None:
269
+ if not self.jobs_initialised:
270
+ return
271
+ if self.state == BodyState.TASK:
272
+ url = self.current_task_url
273
+ else:
274
+ key = self.cursor_row_key()
275
+ if key is None:
276
+ return
277
+ if self.state == BodyState.JOBS:
278
+ url = self.job_url(key)
279
+ else:
280
+ url = self.row_urls.get(key) or self.job_url(self.current_job_id, key)
281
+ if url:
282
+ self.app.open_url(url)
283
+
284
+ def trigger_job(self) -> None:
285
+ if self.state != BodyState.JOBS or not self.jobs_initialised:
286
+ return
287
+ key = self.cursor_row_key()
288
+ if key is None:
289
+ return
290
+ job_id, job_name = self.data_table.get_row(key)[:2]
291
+
292
+ def confirmed(run: bool | None) -> None:
293
+ if run:
294
+ self.run_job(job_id, job_name)
295
+
296
+ self.app.push_screen(ConfirmScreen("Trigger job", f"Run [b]{job_name}[/] now?"), confirmed)
297
+
298
+ @work(thread=True, group="trigger")
299
+ def run_job(self, job_id: int, job_name: str) -> None:
300
+ try:
301
+ # don't wait for the run to finish, only for it to be created
302
+ run_id = self.ws_client.jobs.run_now(job_id=job_id).bind()["run_id"]
303
+ except Exception as e:
304
+ self.app.call_from_thread(
305
+ self.notify, f"Failed to trigger {job_name}: {e}", severity="error"
306
+ )
307
+ return
308
+ self.app.call_from_thread(self.notify, f"Triggered {job_name} (run {run_id})")
309
+
310
+ self.jobs_api.recent_runs.pop(job_id, None)
311
+ try:
312
+ cell = render_recent_runs(self.jobs_api.get_recent_runs(job_id, RECENT_RUNS))
313
+ except Exception:
314
+ cell = render_recent_runs(None)
315
+ self.app.call_from_thread(self.set_recent_runs_cell, job_id, cell)
316
+
317
+ @on(Input.Changed)
318
+ def on_input_changed(self, event: Input.Changed) -> None:
319
+ if event.input.id != "filter_input":
320
+ self.log.warning(f"Unexpected input changed from {event.input.id}")
321
+ return
322
+
323
+ # This is also triggered when the filter is cleared
324
+ if self.filter_state == FilterState.TYPING:
325
+ self.load_jobs(filter=event.input.value, focus=False)
326
+
327
+ @work(exclusive=True, thread=True)
328
+ async def load_jobs(
329
+ self,
330
+ filter: str = "",
331
+ focus: bool = True,
332
+ clear_cache: bool = False,
333
+ select_key: str | None = None,
334
+ ) -> None:
335
+ self.data_table.clear(columns=True)
336
+ self.data_table.add_column("Job ID", key="job_id")
337
+ self.data_table.add_column("Job Name", key="job_name")
338
+ # fixed width: update_cell doesn't grow the column as the "…" placeholders fill in
339
+ self.data_table.add_column(
340
+ f"Last {RECENT_RUNS}", key="recent_runs", width=2 * RECENT_RUNS - 1
341
+ )
342
+
343
+ if not self.jobs_initialised: # TODO: Replace this clunkiness
344
+ clear_cache = True
345
+
346
+ to_fetch = []
347
+ for job in self.jobs_api.get_jobs(clear_cache=clear_cache):
348
+ if filter and filter.lower() not in job.job_name.lower():
349
+ continue
350
+ if job.job_id in self.jobs_api.recent_runs:
351
+ cell = render_recent_runs(self.jobs_api.recent_runs[job.job_id])
352
+ else:
353
+ cell = Text("…", style="dim")
354
+ to_fetch.append(job.job_id)
355
+ self.data_table.add_row(job.job_id, job.job_name, cell, key=str(job.job_id))
356
+
357
+ self.data_table.sort("job_name")
358
+ self.app.call_from_thread(self.select_row, select_key)
359
+ self.app.call_from_thread(self.load_recent_runs, to_fetch)
360
+
361
+ self.data_table.loading = False
362
+ if focus:
363
+ self.data_table.focus()
364
+
365
+ # signal that jobs have been loaded if not already done
366
+ if not self.jobs_initialised:
367
+ self.post_message(self.JobsInitialised())
368
+ self.jobs_initialised = True
369
+
370
+ @work(exclusive=True, thread=True, group="recent_runs")
371
+ def load_recent_runs(self, job_ids: List[int]) -> None:
372
+ # One list_runs call per job (there's no batched public API); results fill in as they arrive
373
+ worker = get_current_worker()
374
+ with ThreadPoolExecutor(max_workers=8) as pool:
375
+ futures = {
376
+ pool.submit(self.jobs_api.get_recent_runs, j, RECENT_RUNS): j for j in job_ids
377
+ }
378
+ for future in as_completed(futures):
379
+ if worker.is_cancelled or self.state != BodyState.JOBS:
380
+ pool.shutdown(cancel_futures=True)
381
+ return
382
+ try:
383
+ cell = render_recent_runs(future.result())
384
+ except Exception as e:
385
+ self.log.warning(f"Recent runs for {futures[future]} failed: {e}")
386
+ cell = render_recent_runs(None)
387
+ self.app.call_from_thread(self.set_recent_runs_cell, futures[future], cell)
388
+
389
+ def set_recent_runs_cell(self, job_id: int, cell: Text) -> None:
390
+ if self.state != BodyState.JOBS:
391
+ return
392
+ try:
393
+ self.data_table.update_cell(str(job_id), "recent_runs", cell)
394
+ except (CellDoesNotExist, RowDoesNotExist):
395
+ pass # row filtered out or table reloaded
396
+
397
+ @work(exclusive=True, thread=True)
398
+ async def load_job_runs(self, job_id: int, select_key: str | None = None) -> None:
399
+ self.data_table.clear(columns=True)
400
+ self.data_table.add_columns(*("Run ID", "Status", "Start Time", "Duration"))
401
+ self.row_urls = {}
402
+ limit = 25
403
+ count = 0
404
+ for r in self.ws_client.jobs.list_runs(job_id=job_id, limit=limit) or []:
405
+ if count >= limit: # TODO: Replace when limit actually works
406
+ break
407
+ status = "Unknown"
408
+ if r.status is None:
409
+ status = "Unknown"
410
+ elif r.status.state != RunLifecycleStateV2State.TERMINATED:
411
+ status = RunLifecycleStateV2State(r.status.state).name
412
+ elif r.status.termination_details:
413
+ status = TerminationCodeCode(r.status.termination_details.code).name
414
+ else:
415
+ status = "Unknown"
416
+ key = str(r.job_run_id)
417
+ self.data_table.add_row(
418
+ r.job_run_id,
419
+ status,
420
+ format_start_time(r.start_time),
421
+ format_duration(r.run_duration),
422
+ key=key,
423
+ )
424
+ if url := getattr(r, "run_page_url", None):
425
+ self.row_urls[key] = url
426
+ count += 1
427
+
428
+ self.app.call_from_thread(self.select_row, select_key)
429
+ self.data_table.loading = False
430
+ self.data_table.focus()
431
+
432
+ # load job run
433
+ @work(exclusive=True, thread=True)
434
+ async def load_job_run(self, run_id: int, select_key: str | None = None) -> None:
435
+ self.data_table.clear(columns=True)
436
+ self.data_table.add_columns(
437
+ *("Run ID", "Task Name", "Status", "Run Duration", "Cluster ID", "Attempts")
438
+ )
439
+ self.row_urls = {}
440
+ run = self.ws_client.jobs.get_run(run_id)
441
+ if run is None:
442
+ return
443
+
444
+ if run.tasks is None:
445
+ return
446
+
447
+ for task in run.tasks:
448
+ status = "Unknown"
449
+ if task.status is None:
450
+ status = "Unknown"
451
+ elif task.status.state != RunLifecycleStateV2State.TERMINATED:
452
+ status = RunLifecycleStateV2State(task.status.state).name
453
+ elif task.status.termination_details:
454
+ status = TerminationCodeCode(task.status.termination_details.code).name
455
+ else:
456
+ status = "Unknown"
457
+
458
+ if task.cluster_instance:
459
+ cluster_instance = task.cluster_instance.cluster_id
460
+ else:
461
+ cluster_instance = "Unknown"
462
+
463
+ key = str(task.run_id)
464
+ self.data_table.add_row(
465
+ task.run_id,
466
+ task.task_key,
467
+ status,
468
+ format_duration(task.run_duration),
469
+ cluster_instance,
470
+ task.attempt_number,
471
+ key=key,
472
+ )
473
+ if url := getattr(task, "run_page_url", None):
474
+ self.row_urls[key] = url
475
+
476
+ self.app.call_from_thread(self.select_row, select_key)
477
+ self.data_table.loading = False
478
+ self.data_table.focus()
479
+
480
+ @work(exclusive=True, thread=True)
481
+ async def load_task_logs(self, run_id: int) -> None:
482
+ task = self.ws_client.jobs.get_run_output(run_id)
483
+ error = task.error
484
+
485
+ if error:
486
+ error_trace = task.error_trace
487
+ job_text = error + "\n" + str(error_trace)
488
+ text_obj = Text(job_text) # because of ANSI colors
489
+ self.query_one("#details_panel_label", Static).update(text_obj)
490
+ else:
491
+ text_obj = Text(task.logs) if task.logs is not None else Text("")
492
+ self.query_one("#details_panel_label", Static).update(text_obj)
493
+ self.details_panel.loading = False
494
+ self.query_one("#details_panel_scroll", VerticalScrollPlus).focus()
495
+
496
+ # This needs to happen before calling threaded functions otherwise errors occur
497
+ def table_loading(self) -> None:
498
+ self.data_table.clear(columns=True)
499
+ self.data_table.loading = True
500
+
501
+ def on_data_table_row_selected(self, message: DataTablePlus.RowSelected) -> None:
502
+ if self.state == BodyState.JOBS:
503
+ # keep the filter so it's still applied when coming back with esc
504
+ self.filter_panel.display = False
505
+ self.state = BodyState.JOB_RUNS
506
+ job_id = message.data_table.get_row(message.row_key)[0]
507
+ self.current_job_id = job_id
508
+ self.current_job_key = message.row_key.value
509
+ self.log.info(f"Row selected: {job_id}")
510
+ self.table_loading()
511
+ self.load_job_runs(job_id)
512
+ elif self.state == BodyState.JOB_RUNS:
513
+ self.state = BodyState.JOB_RUN
514
+ run_id = message.data_table.get_row(message.row_key)[0]
515
+ self.current_run_id = run_id
516
+ self.current_run_key = message.row_key.value
517
+ self.log.info(f"Row selected: {run_id}")
518
+ self.table_loading()
519
+ self.load_job_run(run_id)
520
+ elif self.state == BodyState.JOB_RUN:
521
+ self.state = BodyState.TASK
522
+ run_id = message.data_table.get_row(message.row_key)[0]
523
+ self.current_task_run_id = run_id
524
+ key = str(message.row_key.value)
525
+ self.current_task_url = self.row_urls.get(key) or self.job_url(self.current_job_id, key)
526
+ self.log.info(f"Row selected: {run_id}")
527
+ self.details_panel.loading = True
528
+ self.data_table.display = False
529
+ self.details_panel.display = True
530
+ self.load_task_logs(run_id)