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.
- dbxj/__init__.py +0 -0
- dbxj/auth.py +131 -0
- dbxj/body.py +530 -0
- dbxj/dialogs.py +100 -0
- dbxj/jobs.py +89 -0
- dbxj/main.py +206 -0
- dbxj/main.tcss +104 -0
- dbxj/mock_workspace_client.py +203 -0
- dbxj/sample_data/job_100000000000000.json +112 -0
- dbxj/sample_data/job_100007919000000.json +112 -0
- dbxj/sample_data/job_100015838000000.json +64 -0
- dbxj/sample_data/job_100023757000000.json +90 -0
- dbxj/sample_data/job_100031676000000.json +222 -0
- dbxj/sample_data/job_100039595000000.json +24 -0
- dbxj/sample_data/jobs.json +44 -0
- dbxj/sample_data/run_500000000000000.json +55 -0
- dbxj/sample_data/run_500000006363900.json +55 -0
- dbxj/sample_data/run_500000007613857.json +55 -0
- dbxj/sample_data/run_500000010463046.json +55 -0
- dbxj/sample_data/run_500000012320447.json +55 -0
- dbxj/sample_data/run_500000021057221.json +55 -0
- dbxj/sample_data/run_500000022726564.json +55 -0
- dbxj/sample_data/run_500000029348400.json +55 -0
- dbxj/sample_data/run_500000033531502.json +55 -0
- dbxj/sample_data/run_500000035086732.json +55 -0
- dbxj/sample_data/run_500000042336152.json +55 -0
- dbxj/sample_data/run_500000044098755.json +55 -0
- dbxj/sample_data/run_500000048300009.json +55 -0
- dbxj/sample_data/run_500000050235776.json +55 -0
- dbxj/sample_data/run_500000058538215.json +55 -0
- dbxj/sample_data/run_500000065934619.json +55 -0
- dbxj/sample_data/run_500000074305872.json +55 -0
- dbxj/sample_data/run_500000081730156.json +55 -0
- dbxj/sample_data/run_500000090522447.json +55 -0
- dbxj/sample_data/run_500000093602030.json +55 -0
- dbxj/sample_data/run_500000099538223.json +52 -0
- dbxj/sample_data/run_500000102534750.json +55 -0
- dbxj/sample_data/run_500000110308525.json +55 -0
- dbxj/sample_data/run_500000111816980.json +55 -0
- dbxj/sample_data/run_500000114356189.json +55 -0
- dbxj/sample_data/run_500000116138533.json +55 -0
- dbxj/sample_data/run_500000120413628.json +21 -0
- dbxj/sample_data/run_500000121904221.json +21 -0
- dbxj/sample_data/run_500000127804657.json +21 -0
- dbxj/sample_data/run_500000135946235.json +21 -0
- dbxj/sample_data/run_500000140333349.json +21 -0
- dbxj/sample_data/run_500000142021075.json +21 -0
- dbxj/sample_data/run_500000145023317.json +21 -0
- dbxj/sample_data/run_500000148675603.json +21 -0
- dbxj/sample_data/run_500000150290158.json +21 -0
- dbxj/sample_data/run_500000159118905.json +21 -0
- dbxj/sample_data/run_500000161442181.json +21 -0
- dbxj/sample_data/run_500000168044623.json +21 -0
- dbxj/sample_data/run_500000171263123.json +21 -0
- dbxj/sample_data/run_500000174626665.json +21 -0
- dbxj/sample_data/run_500000179866343.json +21 -0
- dbxj/sample_data/run_500000187823419.json +21 -0
- dbxj/sample_data/run_500000194167913.json +21 -0
- dbxj/sample_data/run_500000199873140.json +21 -0
- dbxj/sample_data/run_500000201175365.json +21 -0
- dbxj/sample_data/run_500000204761223.json +21 -0
- dbxj/sample_data/run_500000211355865.json +21 -0
- dbxj/sample_data/run_500000218642938.json +21 -0
- dbxj/sample_data/run_500000219850861.json +21 -0
- dbxj/sample_data/run_500000222186309.json +21 -0
- dbxj/sample_data/run_500000224860447.json +21 -0
- dbxj/sample_data/run_500000226058958.json +21 -0
- dbxj/sample_data/run_500000234309676.json +21 -0
- dbxj/sample_data/run_500000241009887.json +21 -0
- dbxj/sample_data/run_500000243507822.json +55 -0
- dbxj/sample_data/run_500000248153207.json +55 -0
- dbxj/sample_data/run_output_500000000000001.json +3 -0
- dbxj/sample_data/run_output_500000000000002.json +3 -0
- dbxj/sample_data/run_output_500000000000003.json +3 -0
- dbxj/sample_data/run_output_500000006363901.json +3 -0
- dbxj/sample_data/run_output_500000006363902.json +3 -0
- dbxj/sample_data/run_output_500000006363903.json +3 -0
- dbxj/sample_data/run_output_500000007613858.json +3 -0
- dbxj/sample_data/run_output_500000007613859.json +3 -0
- dbxj/sample_data/run_output_500000007613860.json +3 -0
- dbxj/sample_data/run_output_500000010463047.json +3 -0
- dbxj/sample_data/run_output_500000010463048.json +3 -0
- dbxj/sample_data/run_output_500000010463049.json +3 -0
- dbxj/sample_data/run_output_500000012320448.json +3 -0
- dbxj/sample_data/run_output_500000012320449.json +3 -0
- dbxj/sample_data/run_output_500000012320450.json +3 -0
- dbxj/sample_data/run_output_500000021057222.json +3 -0
- dbxj/sample_data/run_output_500000021057223.json +3 -0
- dbxj/sample_data/run_output_500000021057224.json +3 -0
- dbxj/sample_data/run_output_500000022726565.json +3 -0
- dbxj/sample_data/run_output_500000022726566.json +3 -0
- dbxj/sample_data/run_output_500000022726567.json +3 -0
- dbxj/sample_data/run_output_500000029348401.json +3 -0
- dbxj/sample_data/run_output_500000029348402.json +3 -0
- dbxj/sample_data/run_output_500000029348403.json +3 -0
- dbxj/sample_data/run_output_500000033531503.json +3 -0
- dbxj/sample_data/run_output_500000033531504.json +3 -0
- dbxj/sample_data/run_output_500000033531505.json +3 -0
- dbxj/sample_data/run_output_500000035086733.json +3 -0
- dbxj/sample_data/run_output_500000035086734.json +3 -0
- dbxj/sample_data/run_output_500000035086735.json +3 -0
- dbxj/sample_data/run_output_500000042336153.json +3 -0
- dbxj/sample_data/run_output_500000042336154.json +4 -0
- dbxj/sample_data/run_output_500000044098756.json +3 -0
- dbxj/sample_data/run_output_500000044098757.json +3 -0
- dbxj/sample_data/run_output_500000044098758.json +3 -0
- dbxj/sample_data/run_output_500000048300010.json +3 -0
- dbxj/sample_data/run_output_500000048300011.json +3 -0
- dbxj/sample_data/run_output_500000048300012.json +3 -0
- dbxj/sample_data/run_output_500000050235777.json +3 -0
- dbxj/sample_data/run_output_500000050235778.json +4 -0
- dbxj/sample_data/run_output_500000058538216.json +3 -0
- dbxj/sample_data/run_output_500000058538217.json +3 -0
- dbxj/sample_data/run_output_500000058538218.json +3 -0
- dbxj/sample_data/run_output_500000065934620.json +3 -0
- dbxj/sample_data/run_output_500000065934621.json +3 -0
- dbxj/sample_data/run_output_500000065934622.json +3 -0
- dbxj/sample_data/run_output_500000074305873.json +3 -0
- dbxj/sample_data/run_output_500000074305874.json +3 -0
- dbxj/sample_data/run_output_500000074305875.json +3 -0
- dbxj/sample_data/run_output_500000081730157.json +3 -0
- dbxj/sample_data/run_output_500000081730158.json +3 -0
- dbxj/sample_data/run_output_500000081730159.json +3 -0
- dbxj/sample_data/run_output_500000090522448.json +3 -0
- dbxj/sample_data/run_output_500000090522449.json +4 -0
- dbxj/sample_data/run_output_500000093602031.json +3 -0
- dbxj/sample_data/run_output_500000093602032.json +3 -0
- dbxj/sample_data/run_output_500000093602033.json +3 -0
- dbxj/sample_data/run_output_500000099538224.json +3 -0
- dbxj/sample_data/run_output_500000099538225.json +3 -0
- dbxj/sample_data/run_output_500000102534751.json +3 -0
- dbxj/sample_data/run_output_500000102534752.json +3 -0
- dbxj/sample_data/run_output_500000102534753.json +3 -0
- dbxj/sample_data/run_output_500000110308526.json +3 -0
- dbxj/sample_data/run_output_500000110308527.json +3 -0
- dbxj/sample_data/run_output_500000110308528.json +3 -0
- dbxj/sample_data/run_output_500000111816981.json +3 -0
- dbxj/sample_data/run_output_500000111816982.json +3 -0
- dbxj/sample_data/run_output_500000111816983.json +3 -0
- dbxj/sample_data/run_output_500000114356190.json +3 -0
- dbxj/sample_data/run_output_500000114356191.json +3 -0
- dbxj/sample_data/run_output_500000114356192.json +3 -0
- dbxj/sample_data/run_output_500000116138534.json +3 -0
- dbxj/sample_data/run_output_500000116138535.json +3 -0
- dbxj/sample_data/run_output_500000116138536.json +3 -0
- dbxj/sample_data/run_output_500000121904222.json +3 -0
- dbxj/sample_data/run_output_500000127804658.json +3 -0
- dbxj/sample_data/run_output_500000140333350.json +3 -0
- dbxj/sample_data/run_output_500000142021076.json +3 -0
- dbxj/sample_data/run_output_500000145023318.json +3 -0
- dbxj/sample_data/run_output_500000148675604.json +3 -0
- dbxj/sample_data/run_output_500000150290159.json +3 -0
- dbxj/sample_data/run_output_500000159118906.json +3 -0
- dbxj/sample_data/run_output_500000161442182.json +3 -0
- dbxj/sample_data/run_output_500000168044624.json +4 -0
- dbxj/sample_data/run_output_500000171263124.json +3 -0
- dbxj/sample_data/run_output_500000174626666.json +3 -0
- dbxj/sample_data/run_output_500000179866344.json +3 -0
- dbxj/sample_data/run_output_500000187823420.json +3 -0
- dbxj/sample_data/run_output_500000194167914.json +3 -0
- dbxj/sample_data/run_output_500000199873141.json +3 -0
- dbxj/sample_data/run_output_500000201175366.json +3 -0
- dbxj/sample_data/run_output_500000204761224.json +3 -0
- dbxj/sample_data/run_output_500000211355866.json +3 -0
- dbxj/sample_data/run_output_500000218642939.json +3 -0
- dbxj/sample_data/run_output_500000219850862.json +3 -0
- dbxj/sample_data/run_output_500000222186310.json +3 -0
- dbxj/sample_data/run_output_500000224860448.json +3 -0
- dbxj/sample_data/run_output_500000226058959.json +3 -0
- dbxj/sample_data/run_output_500000234309677.json +3 -0
- dbxj/sample_data/run_output_500000241009888.json +3 -0
- dbxj/sample_data/run_output_500000243507823.json +3 -0
- dbxj/sample_data/run_output_500000243507824.json +3 -0
- dbxj/sample_data/run_output_500000243507825.json +3 -0
- dbxj/sample_data/run_output_500000248153208.json +3 -0
- dbxj/sample_data/run_output_500000248153209.json +3 -0
- dbxj/sample_data/run_output_500000248153210.json +3 -0
- dbxj-0.1.0.dist-info/METADATA +101 -0
- dbxj-0.1.0.dist-info/RECORD +182 -0
- dbxj-0.1.0.dist-info/WHEEL +4 -0
- dbxj-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|