clue2app-cli 0.2.7__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.
- clue2app_cli/__init__.py +10 -0
- clue2app_cli/agents/__init__.py +9 -0
- clue2app_cli/agents/ecr_healer.py +475 -0
- clue2app_cli/auth.py +343 -0
- clue2app_cli/config.py +316 -0
- clue2app_cli/core_client.py +46 -0
- clue2app_cli/creds_module.py +253 -0
- clue2app_cli/curses_wizard.py +839 -0
- clue2app_cli/entrypoint_check.py +215 -0
- clue2app_cli/main.py +6711 -0
- clue2app_cli/sdk.py +2993 -0
- clue2app_cli/tui/__init__.py +33 -0
- clue2app_cli/tui/curses_app.py +733 -0
- clue2app_cli/tui/data.py +316 -0
- clue2app_cli/tui/screens/__init__.py +1 -0
- clue2app_cli/tui/textual_app.py +1021 -0
- clue2app_cli/tui/widgets/__init__.py +1 -0
- clue2app_cli/tui_wizard.py +987 -0
- clue2app_cli/version.py +3 -0
- clue2app_cli/wizard.py +1079 -0
- clue2app_cli-0.2.7.dist-info/METADATA +202 -0
- clue2app_cli-0.2.7.dist-info/RECORD +26 -0
- clue2app_cli-0.2.7.dist-info/WHEEL +5 -0
- clue2app_cli-0.2.7.dist-info/entry_points.txt +3 -0
- clue2app_cli-0.2.7.dist-info/licenses/LICENSE +21 -0
- clue2app_cli-0.2.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""ncurses-based TUI for Clue2App CLI.
|
|
2
|
+
|
|
3
|
+
Provides a traditional terminal interface using ncurses for systems
|
|
4
|
+
without Textual support or minimal terminal environments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import curses
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Optional, List, Callable
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from .data import DataProvider, AppInfo, ProjectInfo, AppStatus, BuildStatus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Screen(Enum):
|
|
16
|
+
"""Available screens."""
|
|
17
|
+
APPS = "apps"
|
|
18
|
+
DETAILS = "details"
|
|
19
|
+
LOGS = "logs"
|
|
20
|
+
PROJECTS = "projects"
|
|
21
|
+
HELP = "help"
|
|
22
|
+
CONFIRM = "confirm"
|
|
23
|
+
STATUS_LOG = "status_log"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Colors:
|
|
27
|
+
"""Color pair indices."""
|
|
28
|
+
NORMAL = 0
|
|
29
|
+
GREEN = 1
|
|
30
|
+
RED = 2
|
|
31
|
+
YELLOW = 3
|
|
32
|
+
CYAN = 4
|
|
33
|
+
HEADER = 5
|
|
34
|
+
SELECTED = 6
|
|
35
|
+
WHITE = 7
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CursesTUI:
|
|
39
|
+
"""ncurses-based interactive TUI."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, stdscr, initial_screen: str = 'apps'):
|
|
42
|
+
self.stdscr = stdscr
|
|
43
|
+
self.data_provider = DataProvider()
|
|
44
|
+
|
|
45
|
+
self.current_screen = Screen.APPS if initial_screen == 'apps' else Screen.PROJECTS
|
|
46
|
+
self.selected_index = 0
|
|
47
|
+
self.scroll_offset = 0
|
|
48
|
+
|
|
49
|
+
self.apps: List[AppInfo] = []
|
|
50
|
+
self.projects: List[ProjectInfo] = []
|
|
51
|
+
self.selected_app: Optional[AppInfo] = None
|
|
52
|
+
self.show_unlinked = False
|
|
53
|
+
self.show_all = False # False = show only current project
|
|
54
|
+
self._current_project: Optional[ProjectInfo] = self.data_provider.get_current_project()
|
|
55
|
+
|
|
56
|
+
self.logs_content = ""
|
|
57
|
+
self.confirm_message = ""
|
|
58
|
+
self.confirm_callback: Optional[Callable] = None
|
|
59
|
+
|
|
60
|
+
# Status log for displaying messages/errors
|
|
61
|
+
self._status_messages: List[tuple] = [] # [(timestamp, level, message), ...]
|
|
62
|
+
self._max_messages = 50
|
|
63
|
+
|
|
64
|
+
self._init_colors()
|
|
65
|
+
|
|
66
|
+
def _init_colors(self) -> None:
|
|
67
|
+
"""Initialize color pairs."""
|
|
68
|
+
curses.start_color()
|
|
69
|
+
curses.use_default_colors()
|
|
70
|
+
|
|
71
|
+
curses.init_pair(Colors.GREEN, curses.COLOR_GREEN, -1)
|
|
72
|
+
curses.init_pair(Colors.RED, curses.COLOR_RED, -1)
|
|
73
|
+
curses.init_pair(Colors.YELLOW, curses.COLOR_YELLOW, -1)
|
|
74
|
+
curses.init_pair(Colors.CYAN, curses.COLOR_CYAN, -1)
|
|
75
|
+
curses.init_pair(Colors.HEADER, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
76
|
+
curses.init_pair(Colors.SELECTED, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
|
77
|
+
curses.init_pair(Colors.WHITE, curses.COLOR_WHITE, -1)
|
|
78
|
+
|
|
79
|
+
def _log(self, message: str, level: str = "info") -> None:
|
|
80
|
+
"""Add a message to the status log.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
message: The message to log
|
|
84
|
+
level: One of "info", "warn", "error"
|
|
85
|
+
"""
|
|
86
|
+
from datetime import datetime
|
|
87
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
88
|
+
full_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
89
|
+
self._status_messages.append((timestamp, level, message))
|
|
90
|
+
# Keep only the last N messages
|
|
91
|
+
if len(self._status_messages) > self._max_messages:
|
|
92
|
+
self._status_messages = self._status_messages[-self._max_messages:]
|
|
93
|
+
|
|
94
|
+
# Also write to file for easy access
|
|
95
|
+
try:
|
|
96
|
+
with open("/tmp/c2a_tui.log", "a") as f:
|
|
97
|
+
f.write(f"[{full_timestamp}] [{level.upper()}] {message}\n")
|
|
98
|
+
except:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def run(self) -> None:
|
|
102
|
+
"""Main event loop."""
|
|
103
|
+
curses.curs_set(0) # Hide cursor
|
|
104
|
+
self.stdscr.keypad(True)
|
|
105
|
+
self.stdscr.timeout(1000) # 1s timeout - only redraw on input or timeout
|
|
106
|
+
|
|
107
|
+
# Initial data load
|
|
108
|
+
self._load_data()
|
|
109
|
+
|
|
110
|
+
running = True
|
|
111
|
+
needs_redraw = True
|
|
112
|
+
while running:
|
|
113
|
+
if needs_redraw:
|
|
114
|
+
self._draw()
|
|
115
|
+
self.stdscr.refresh()
|
|
116
|
+
needs_redraw = False
|
|
117
|
+
|
|
118
|
+
result = self._handle_input()
|
|
119
|
+
if result is False:
|
|
120
|
+
running = False
|
|
121
|
+
elif result is True:
|
|
122
|
+
needs_redraw = True # Input received, redraw
|
|
123
|
+
|
|
124
|
+
def _load_data(self) -> None:
|
|
125
|
+
"""Load data for current screen."""
|
|
126
|
+
try:
|
|
127
|
+
if self.current_screen == Screen.APPS:
|
|
128
|
+
# Determine project filter
|
|
129
|
+
project_id = None
|
|
130
|
+
if self._current_project and not self.show_all:
|
|
131
|
+
project_id = self._current_project.id
|
|
132
|
+
self.apps = self.data_provider.get_apps(
|
|
133
|
+
project_id=project_id,
|
|
134
|
+
include_unlinked=self.show_unlinked
|
|
135
|
+
)
|
|
136
|
+
self._log(f"Loaded {len(self.apps)} apps", "info")
|
|
137
|
+
elif self.current_screen == Screen.PROJECTS:
|
|
138
|
+
self.projects = self.data_provider.get_projects()
|
|
139
|
+
self._log(f"Loaded {len(self.projects)} projects", "info")
|
|
140
|
+
elif self.current_screen == Screen.LOGS and self.selected_app:
|
|
141
|
+
self.logs_content = self.data_provider.get_logs(self.selected_app.name, lines=100)
|
|
142
|
+
self._log(f"Loaded logs for {self.selected_app.display_name}", "info")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
error_msg = str(e)
|
|
145
|
+
# Extract useful info from common errors
|
|
146
|
+
if "500" in error_msg:
|
|
147
|
+
self._log(f"Server Error (500): API request failed", "error")
|
|
148
|
+
elif "401" in error_msg or "403" in error_msg:
|
|
149
|
+
self._log(f"Auth Error: Please re-login", "error")
|
|
150
|
+
elif "Connection" in error_msg or "timeout" in error_msg.lower():
|
|
151
|
+
self._log(f"Network Error: Cannot reach server", "error")
|
|
152
|
+
else:
|
|
153
|
+
self._log(f"Error: {error_msg[:60]}", "error")
|
|
154
|
+
self._show_error(str(e))
|
|
155
|
+
|
|
156
|
+
def _draw(self) -> None:
|
|
157
|
+
"""Draw the current screen."""
|
|
158
|
+
self.stdscr.clear()
|
|
159
|
+
height, width = self.stdscr.getmaxyx()
|
|
160
|
+
|
|
161
|
+
# Draw header
|
|
162
|
+
self._draw_header(width)
|
|
163
|
+
|
|
164
|
+
# Draw main content based on screen
|
|
165
|
+
if self.current_screen == Screen.APPS:
|
|
166
|
+
self._draw_apps_screen(height, width)
|
|
167
|
+
elif self.current_screen == Screen.DETAILS:
|
|
168
|
+
self._draw_details_screen(height, width)
|
|
169
|
+
elif self.current_screen == Screen.LOGS:
|
|
170
|
+
self._draw_logs_screen(height, width)
|
|
171
|
+
elif self.current_screen == Screen.PROJECTS:
|
|
172
|
+
self._draw_projects_screen(height, width)
|
|
173
|
+
elif self.current_screen == Screen.HELP:
|
|
174
|
+
self._draw_help_screen(height, width)
|
|
175
|
+
elif self.current_screen == Screen.CONFIRM:
|
|
176
|
+
self._draw_confirm_screen(height, width)
|
|
177
|
+
elif self.current_screen == Screen.STATUS_LOG:
|
|
178
|
+
self._draw_status_log_screen(height, width)
|
|
179
|
+
|
|
180
|
+
# Draw footer
|
|
181
|
+
self._draw_footer(height, width)
|
|
182
|
+
|
|
183
|
+
self.stdscr.refresh()
|
|
184
|
+
|
|
185
|
+
def _draw_header(self, width: int) -> None:
|
|
186
|
+
"""Draw the header bar."""
|
|
187
|
+
header = " Clue2App TUI "
|
|
188
|
+
project = self.data_provider.get_current_project()
|
|
189
|
+
if project:
|
|
190
|
+
header += f"| Project: {project.name} "
|
|
191
|
+
|
|
192
|
+
header = header.ljust(width)
|
|
193
|
+
self.stdscr.attron(curses.color_pair(Colors.HEADER) | curses.A_BOLD)
|
|
194
|
+
self.stdscr.addstr(0, 0, header[:width-1])
|
|
195
|
+
self.stdscr.attroff(curses.color_pair(Colors.HEADER) | curses.A_BOLD)
|
|
196
|
+
|
|
197
|
+
def _draw_footer(self, height: int, width: int) -> None:
|
|
198
|
+
"""Draw the footer with shortcuts and status log."""
|
|
199
|
+
# Draw status log line (above footer)
|
|
200
|
+
if self._status_messages:
|
|
201
|
+
timestamp, level, message = self._status_messages[-1]
|
|
202
|
+
status_line = f"[{timestamp}] {message}"
|
|
203
|
+
|
|
204
|
+
# Choose color based on level
|
|
205
|
+
if level == "error":
|
|
206
|
+
color = curses.color_pair(Colors.RED)
|
|
207
|
+
elif level == "warn":
|
|
208
|
+
color = curses.color_pair(Colors.YELLOW)
|
|
209
|
+
else:
|
|
210
|
+
color = curses.color_pair(Colors.CYAN)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
self.stdscr.attron(color)
|
|
214
|
+
self.stdscr.addstr(height - 2, 0, status_line[:width-1].ljust(width-1))
|
|
215
|
+
self.stdscr.attroff(color)
|
|
216
|
+
except curses.error:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
# Draw shortcuts footer
|
|
220
|
+
shortcuts = {
|
|
221
|
+
Screen.APPS: "[↵]View [l]Logs [r]Rebuild [s]Start [p]Proj [a]All [L]Log [q]Quit",
|
|
222
|
+
Screen.DETAILS: "[l]Logs [r]Rebuild [o]URL [L]Log [q]Back",
|
|
223
|
+
Screen.LOGS: "[f]Follow [g]Top [G]End [L]Log [q]Back",
|
|
224
|
+
Screen.PROJECTS: "[↵]Select [R]Refresh [L]Log [q]Back",
|
|
225
|
+
Screen.HELP: "[q]Back",
|
|
226
|
+
Screen.CONFIRM: "[y]Yes [n]No",
|
|
227
|
+
Screen.STATUS_LOG: "[c]Clear [q]Back",
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
footer = shortcuts.get(self.current_screen, "[q]Back")
|
|
231
|
+
footer = footer.center(width)
|
|
232
|
+
|
|
233
|
+
self.stdscr.attron(curses.color_pair(Colors.HEADER))
|
|
234
|
+
try:
|
|
235
|
+
self.stdscr.addstr(height - 1, 0, footer[:width-1])
|
|
236
|
+
except curses.error:
|
|
237
|
+
pass
|
|
238
|
+
self.stdscr.attroff(curses.color_pair(Colors.HEADER))
|
|
239
|
+
|
|
240
|
+
def _draw_apps_screen(self, height: int, width: int) -> None:
|
|
241
|
+
"""Draw the apps list screen."""
|
|
242
|
+
# Column headers
|
|
243
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
244
|
+
header = f"{'NAME':<25} {'PROJECT':<15} {'BUILD':<12} {'RUNTIME':<15} {'PODS':<5}"
|
|
245
|
+
self.stdscr.addstr(2, 2, header[:width-4])
|
|
246
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
247
|
+
|
|
248
|
+
# Separator
|
|
249
|
+
self.stdscr.addstr(3, 2, "─" * min(width - 4, 80))
|
|
250
|
+
|
|
251
|
+
# Apps list
|
|
252
|
+
visible_rows = height - 6
|
|
253
|
+
start_idx = self.scroll_offset
|
|
254
|
+
end_idx = min(start_idx + visible_rows, len(self.apps))
|
|
255
|
+
|
|
256
|
+
for i, app in enumerate(self.apps[start_idx:end_idx]):
|
|
257
|
+
row = 4 + i
|
|
258
|
+
is_selected = (start_idx + i) == self.selected_index
|
|
259
|
+
|
|
260
|
+
# Format columns
|
|
261
|
+
project_name = app.project_name or "(unlinked)"
|
|
262
|
+
build_symbol = self._status_symbol(app.build_status)
|
|
263
|
+
build_text = app.build_status.value[:8]
|
|
264
|
+
runtime_text = app.runtime_status.value[:12]
|
|
265
|
+
|
|
266
|
+
# Determine runtime status color
|
|
267
|
+
if app.runtime_status == AppStatus.RUNNING:
|
|
268
|
+
runtime_color = Colors.GREEN
|
|
269
|
+
elif app.runtime_status == AppStatus.SCALED_TO_ZERO:
|
|
270
|
+
runtime_color = Colors.YELLOW
|
|
271
|
+
elif app.runtime_status in (AppStatus.STOPPED, AppStatus.FAILED):
|
|
272
|
+
runtime_color = Colors.RED
|
|
273
|
+
else:
|
|
274
|
+
runtime_color = Colors.WHITE
|
|
275
|
+
|
|
276
|
+
if is_selected:
|
|
277
|
+
# Selected row - use selection color for everything
|
|
278
|
+
self.stdscr.attron(curses.color_pair(Colors.SELECTED))
|
|
279
|
+
line = f"{app.display_name:<25} {project_name:<15} {build_symbol} {build_text:<10} {runtime_text:<15} {app.pod_count:<5}"
|
|
280
|
+
self.stdscr.addstr(row, 2, line[:width-4])
|
|
281
|
+
self.stdscr.attroff(curses.color_pair(Colors.SELECTED))
|
|
282
|
+
else:
|
|
283
|
+
# Render columns separately with colors
|
|
284
|
+
col = 2
|
|
285
|
+
# Name
|
|
286
|
+
self.stdscr.addstr(row, col, f"{app.display_name:<25}")
|
|
287
|
+
col += 25
|
|
288
|
+
# Project
|
|
289
|
+
self.stdscr.addstr(row, col, f"{project_name:<15}")
|
|
290
|
+
col += 15 + 1
|
|
291
|
+
# Build status with symbol
|
|
292
|
+
self.stdscr.addstr(row, col, f"{build_symbol} {build_text:<10}")
|
|
293
|
+
col += 12
|
|
294
|
+
# Runtime status with color
|
|
295
|
+
self.stdscr.attron(curses.color_pair(runtime_color))
|
|
296
|
+
self.stdscr.addstr(row, col, f"{runtime_text:<15}")
|
|
297
|
+
self.stdscr.attroff(curses.color_pair(runtime_color))
|
|
298
|
+
col += 15
|
|
299
|
+
# Pod count
|
|
300
|
+
self.stdscr.addstr(row, col, f"{app.pod_count:<5}")
|
|
301
|
+
|
|
302
|
+
# Show scroll indicator if needed
|
|
303
|
+
if len(self.apps) > visible_rows:
|
|
304
|
+
scroll_info = f"[{self.selected_index + 1}/{len(self.apps)}]"
|
|
305
|
+
self.stdscr.addstr(2, width - len(scroll_info) - 2, scroll_info)
|
|
306
|
+
|
|
307
|
+
def _draw_details_screen(self, height: int, width: int) -> None:
|
|
308
|
+
"""Draw app details screen."""
|
|
309
|
+
if not self.selected_app:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
app = self.selected_app
|
|
313
|
+
row = 2
|
|
314
|
+
|
|
315
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
316
|
+
self.stdscr.addstr(row, 2, f"App Details: {app.display_name}")
|
|
317
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
318
|
+
row += 2
|
|
319
|
+
|
|
320
|
+
details = [
|
|
321
|
+
("Name", app.name),
|
|
322
|
+
("Namespace", app.namespace),
|
|
323
|
+
("Project", app.project_name or "(unlinked)"),
|
|
324
|
+
("", ""),
|
|
325
|
+
("Build Status", app.build_status.value),
|
|
326
|
+
("Build Count", str(app.build_count)),
|
|
327
|
+
("Build Reason", app.build_reason or "N/A"),
|
|
328
|
+
("Stack", app.stack or "N/A"),
|
|
329
|
+
("", ""),
|
|
330
|
+
("Git URL", app.git_url or "N/A"),
|
|
331
|
+
("Git Revision", app.git_revision or "N/A"),
|
|
332
|
+
("", ""),
|
|
333
|
+
("Runtime Status", app.runtime_status.value),
|
|
334
|
+
("Pods", str(app.pod_count)),
|
|
335
|
+
("URL", app.url or "N/A"),
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
for label, value in details:
|
|
339
|
+
if label:
|
|
340
|
+
self.stdscr.attron(curses.color_pair(Colors.CYAN))
|
|
341
|
+
self.stdscr.addstr(row, 2, f"{label}:")
|
|
342
|
+
self.stdscr.attroff(curses.color_pair(Colors.CYAN))
|
|
343
|
+
self.stdscr.addstr(row, 18, str(value)[:width-20])
|
|
344
|
+
row += 1
|
|
345
|
+
if row >= height - 2:
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
def _draw_logs_screen(self, height: int, width: int) -> None:
|
|
349
|
+
"""Draw logs screen."""
|
|
350
|
+
if not self.selected_app:
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
354
|
+
self.stdscr.addstr(2, 2, f"Logs: {self.selected_app.display_name}")
|
|
355
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
356
|
+
|
|
357
|
+
# Display logs
|
|
358
|
+
lines = self.logs_content.split('\n') if self.logs_content else ["No logs available"]
|
|
359
|
+
visible_rows = height - 5
|
|
360
|
+
|
|
361
|
+
start_line = max(0, len(lines) - visible_rows - self.scroll_offset)
|
|
362
|
+
end_line = start_line + visible_rows
|
|
363
|
+
|
|
364
|
+
for i, line in enumerate(lines[start_line:end_line]):
|
|
365
|
+
row = 4 + i
|
|
366
|
+
if row < height - 1:
|
|
367
|
+
self.stdscr.addstr(row, 2, line[:width-4])
|
|
368
|
+
|
|
369
|
+
def _draw_projects_screen(self, height: int, width: int) -> None:
|
|
370
|
+
"""Draw projects list screen."""
|
|
371
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
372
|
+
header = f"{'NAME':<30} {'ID':<40}"
|
|
373
|
+
self.stdscr.addstr(2, 2, header[:width-4])
|
|
374
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
375
|
+
|
|
376
|
+
self.stdscr.addstr(3, 2, "─" * min(width - 4, 80))
|
|
377
|
+
|
|
378
|
+
visible_rows = height - 6
|
|
379
|
+
|
|
380
|
+
for i, project in enumerate(self.projects[:visible_rows]):
|
|
381
|
+
row = 4 + i
|
|
382
|
+
is_selected = i == self.selected_index
|
|
383
|
+
|
|
384
|
+
if is_selected:
|
|
385
|
+
self.stdscr.attron(curses.color_pair(Colors.SELECTED))
|
|
386
|
+
|
|
387
|
+
line = f"{project.name:<30} {project.id:<40}"
|
|
388
|
+
self.stdscr.addstr(row, 2, line[:width-4])
|
|
389
|
+
|
|
390
|
+
if is_selected:
|
|
391
|
+
self.stdscr.attroff(curses.color_pair(Colors.SELECTED))
|
|
392
|
+
|
|
393
|
+
def _draw_help_screen(self, height: int, width: int) -> None:
|
|
394
|
+
"""Draw help screen."""
|
|
395
|
+
help_text = [
|
|
396
|
+
"Clue2App TUI - Keyboard Shortcuts",
|
|
397
|
+
"",
|
|
398
|
+
"Navigation:",
|
|
399
|
+
" j / ↓ Move down",
|
|
400
|
+
" k / ↑ Move up",
|
|
401
|
+
" Enter Select / View details",
|
|
402
|
+
" q / Escape Back / Quit",
|
|
403
|
+
"",
|
|
404
|
+
"Apps Screen:",
|
|
405
|
+
" l View app logs",
|
|
406
|
+
" r Rebuild app",
|
|
407
|
+
" d Delete app",
|
|
408
|
+
" p Switch to projects",
|
|
409
|
+
" a Toggle all/current project",
|
|
410
|
+
" u Toggle unlinked apps",
|
|
411
|
+
" R Refresh",
|
|
412
|
+
" L View status log",
|
|
413
|
+
"",
|
|
414
|
+
"Logs Screen:",
|
|
415
|
+
" f Toggle follow mode",
|
|
416
|
+
" g Scroll to top",
|
|
417
|
+
" G Scroll to bottom",
|
|
418
|
+
"",
|
|
419
|
+
"Status Log:",
|
|
420
|
+
" c Clear log messages",
|
|
421
|
+
"",
|
|
422
|
+
"Press q to close this help.",
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
for i, line in enumerate(help_text):
|
|
426
|
+
if i + 2 < height - 1:
|
|
427
|
+
self.stdscr.addstr(i + 2, 2, line[:width-4])
|
|
428
|
+
|
|
429
|
+
def _draw_status_log_screen(self, height: int, width: int) -> None:
|
|
430
|
+
"""Draw status log screen showing all messages."""
|
|
431
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
432
|
+
self.stdscr.addstr(2, 2, "Status Log (most recent at bottom)")
|
|
433
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
434
|
+
self.stdscr.addstr(3, 2, "─" * min(width - 4, 80))
|
|
435
|
+
|
|
436
|
+
visible_rows = height - 7 # Leave room for header, title, and footer
|
|
437
|
+
messages = self._status_messages[-visible_rows:] if self._status_messages else []
|
|
438
|
+
|
|
439
|
+
for i, (timestamp, level, message) in enumerate(messages):
|
|
440
|
+
row = 4 + i
|
|
441
|
+
if row >= height - 3:
|
|
442
|
+
break
|
|
443
|
+
|
|
444
|
+
# Choose color based on level
|
|
445
|
+
if level == "error":
|
|
446
|
+
color = curses.color_pair(Colors.RED)
|
|
447
|
+
elif level == "warn":
|
|
448
|
+
color = curses.color_pair(Colors.YELLOW)
|
|
449
|
+
else:
|
|
450
|
+
color = curses.color_pair(Colors.CYAN)
|
|
451
|
+
|
|
452
|
+
line = f"[{timestamp}] {message}"
|
|
453
|
+
try:
|
|
454
|
+
self.stdscr.attron(color)
|
|
455
|
+
self.stdscr.addstr(row, 2, line[:width-4])
|
|
456
|
+
self.stdscr.attroff(color)
|
|
457
|
+
except curses.error:
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
if not messages:
|
|
461
|
+
self.stdscr.addstr(5, 2, "(No messages yet)", curses.A_DIM)
|
|
462
|
+
|
|
463
|
+
def _draw_confirm_screen(self, height: int, width: int) -> None:
|
|
464
|
+
"""Draw confirmation dialog."""
|
|
465
|
+
# Center the dialog
|
|
466
|
+
dialog_width = 50
|
|
467
|
+
dialog_height = 5
|
|
468
|
+
start_y = (height - dialog_height) // 2
|
|
469
|
+
start_x = (width - dialog_width) // 2
|
|
470
|
+
|
|
471
|
+
# Draw border
|
|
472
|
+
for i in range(dialog_height):
|
|
473
|
+
self.stdscr.addstr(start_y + i, start_x, " " * dialog_width, curses.color_pair(Colors.HEADER))
|
|
474
|
+
|
|
475
|
+
# Draw message
|
|
476
|
+
self.stdscr.attron(curses.A_BOLD)
|
|
477
|
+
msg = self.confirm_message[:dialog_width - 4]
|
|
478
|
+
self.stdscr.addstr(start_y + 1, start_x + 2, msg.center(dialog_width - 4))
|
|
479
|
+
self.stdscr.attroff(curses.A_BOLD)
|
|
480
|
+
|
|
481
|
+
# Draw options
|
|
482
|
+
self.stdscr.addstr(start_y + 3, start_x + 2, "[y] Yes [n] No".center(dialog_width - 4))
|
|
483
|
+
|
|
484
|
+
def _handle_input(self):
|
|
485
|
+
"""Handle keyboard input. Returns False to exit, None for no input, True for redraw."""
|
|
486
|
+
try:
|
|
487
|
+
key = self.stdscr.getch()
|
|
488
|
+
except:
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
if key == -1: # No input (timeout)
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
# Global keys
|
|
495
|
+
if key == ord('q') or key == 27: # q or Escape
|
|
496
|
+
if self.current_screen in (Screen.APPS,):
|
|
497
|
+
return False # Exit app
|
|
498
|
+
else:
|
|
499
|
+
self._go_back()
|
|
500
|
+
return True
|
|
501
|
+
|
|
502
|
+
# Screen-specific handlers
|
|
503
|
+
if self.current_screen == Screen.APPS:
|
|
504
|
+
return self._handle_apps_input(key)
|
|
505
|
+
elif self.current_screen == Screen.DETAILS:
|
|
506
|
+
return self._handle_details_input(key)
|
|
507
|
+
elif self.current_screen == Screen.LOGS:
|
|
508
|
+
return self._handle_logs_input(key)
|
|
509
|
+
elif self.current_screen == Screen.PROJECTS:
|
|
510
|
+
return self._handle_projects_input(key)
|
|
511
|
+
elif self.current_screen == Screen.HELP:
|
|
512
|
+
return self._handle_help_input(key)
|
|
513
|
+
elif self.current_screen == Screen.CONFIRM:
|
|
514
|
+
return self._handle_confirm_input(key)
|
|
515
|
+
elif self.current_screen == Screen.STATUS_LOG:
|
|
516
|
+
return self._handle_status_log_input(key)
|
|
517
|
+
|
|
518
|
+
return True
|
|
519
|
+
|
|
520
|
+
def _handle_apps_input(self, key: int) -> bool:
|
|
521
|
+
"""Handle input on apps screen."""
|
|
522
|
+
if key == ord('j') or key == curses.KEY_DOWN:
|
|
523
|
+
self.selected_index = min(self.selected_index + 1, len(self.apps) - 1)
|
|
524
|
+
self._adjust_scroll()
|
|
525
|
+
elif key == ord('k') or key == curses.KEY_UP:
|
|
526
|
+
self.selected_index = max(self.selected_index - 1, 0)
|
|
527
|
+
self._adjust_scroll()
|
|
528
|
+
elif key == ord('\n') or key == curses.KEY_ENTER or key == 10:
|
|
529
|
+
if self.apps:
|
|
530
|
+
self.selected_app = self.apps[self.selected_index]
|
|
531
|
+
self.current_screen = Screen.DETAILS
|
|
532
|
+
elif key == ord('l'):
|
|
533
|
+
if self.apps:
|
|
534
|
+
self.selected_app = self.apps[self.selected_index]
|
|
535
|
+
self.scroll_offset = 0
|
|
536
|
+
self._load_data()
|
|
537
|
+
self.current_screen = Screen.LOGS
|
|
538
|
+
elif key == ord('r'):
|
|
539
|
+
if self.apps:
|
|
540
|
+
self.selected_app = self.apps[self.selected_index]
|
|
541
|
+
self.confirm_message = f"Rebuild {self.selected_app.display_name}?"
|
|
542
|
+
self.confirm_callback = self._do_rebuild
|
|
543
|
+
self.current_screen = Screen.CONFIRM
|
|
544
|
+
elif key == ord('s'):
|
|
545
|
+
if self.apps:
|
|
546
|
+
self.selected_app = self.apps[self.selected_index]
|
|
547
|
+
self.confirm_message = f"Start/Restart {self.selected_app.display_name}?"
|
|
548
|
+
self.confirm_callback = self._do_restart
|
|
549
|
+
self.current_screen = Screen.CONFIRM
|
|
550
|
+
elif key == ord('d'):
|
|
551
|
+
if self.apps:
|
|
552
|
+
self.selected_app = self.apps[self.selected_index]
|
|
553
|
+
self.confirm_message = f"Delete {self.selected_app.display_name}?"
|
|
554
|
+
self.confirm_callback = self._do_delete
|
|
555
|
+
self.current_screen = Screen.CONFIRM
|
|
556
|
+
elif key == ord('p'):
|
|
557
|
+
self.selected_index = 0
|
|
558
|
+
self.current_screen = Screen.PROJECTS
|
|
559
|
+
self._load_data()
|
|
560
|
+
elif key == ord('u'):
|
|
561
|
+
self.show_unlinked = not self.show_unlinked
|
|
562
|
+
self.selected_index = 0
|
|
563
|
+
self._load_data()
|
|
564
|
+
elif key == ord('a'):
|
|
565
|
+
if self._current_project:
|
|
566
|
+
self.show_all = not self.show_all
|
|
567
|
+
self.selected_index = 0
|
|
568
|
+
self._load_data()
|
|
569
|
+
elif key == ord('R'):
|
|
570
|
+
self._load_data()
|
|
571
|
+
elif key == ord('?'):
|
|
572
|
+
self.current_screen = Screen.HELP
|
|
573
|
+
elif key == ord('L'):
|
|
574
|
+
self.current_screen = Screen.STATUS_LOG
|
|
575
|
+
|
|
576
|
+
return True
|
|
577
|
+
|
|
578
|
+
def _handle_details_input(self, key: int) -> bool:
|
|
579
|
+
"""Handle input on details screen."""
|
|
580
|
+
if key == ord('l'):
|
|
581
|
+
self.scroll_offset = 0
|
|
582
|
+
self.current_screen = Screen.LOGS
|
|
583
|
+
self._load_data()
|
|
584
|
+
elif key == ord('r'):
|
|
585
|
+
self.confirm_message = f"Rebuild {self.selected_app.display_name}?"
|
|
586
|
+
self.confirm_callback = self._do_rebuild
|
|
587
|
+
self.current_screen = Screen.CONFIRM
|
|
588
|
+
elif key == ord('o'):
|
|
589
|
+
if self.selected_app and self.selected_app.url:
|
|
590
|
+
import webbrowser
|
|
591
|
+
webbrowser.open(self.selected_app.url)
|
|
592
|
+
elif key == ord('L'):
|
|
593
|
+
self.current_screen = Screen.STATUS_LOG
|
|
594
|
+
|
|
595
|
+
return True
|
|
596
|
+
|
|
597
|
+
def _handle_logs_input(self, key: int) -> bool:
|
|
598
|
+
"""Handle input on logs screen."""
|
|
599
|
+
if key == ord('g'):
|
|
600
|
+
self.scroll_offset = 0
|
|
601
|
+
elif key == ord('G'):
|
|
602
|
+
lines = self.logs_content.split('\n') if self.logs_content else []
|
|
603
|
+
height, _ = self.stdscr.getmaxyx()
|
|
604
|
+
visible = height - 5
|
|
605
|
+
self.scroll_offset = max(0, len(lines) - visible)
|
|
606
|
+
elif key == ord('R'):
|
|
607
|
+
self._load_data()
|
|
608
|
+
elif key == ord('L'):
|
|
609
|
+
self.current_screen = Screen.STATUS_LOG
|
|
610
|
+
|
|
611
|
+
return True
|
|
612
|
+
|
|
613
|
+
def _handle_projects_input(self, key: int) -> bool:
|
|
614
|
+
"""Handle input on projects screen."""
|
|
615
|
+
if key == ord('j') or key == curses.KEY_DOWN:
|
|
616
|
+
self.selected_index = min(self.selected_index + 1, len(self.projects) - 1)
|
|
617
|
+
elif key == ord('k') or key == curses.KEY_UP:
|
|
618
|
+
self.selected_index = max(self.selected_index - 1, 0)
|
|
619
|
+
elif key == ord('\n') or key == curses.KEY_ENTER or key == 10:
|
|
620
|
+
if self.projects and self.selected_index < len(self.projects):
|
|
621
|
+
# Set the selected project as current
|
|
622
|
+
selected_project = self.projects[self.selected_index]
|
|
623
|
+
self._current_project = selected_project
|
|
624
|
+
self.show_all = False # Reset to show only this project's apps
|
|
625
|
+
self._log(f"Switched to project: {selected_project.name}", "info")
|
|
626
|
+
# Go back to apps screen and reload
|
|
627
|
+
self.current_screen = Screen.APPS
|
|
628
|
+
self.selected_index = 0
|
|
629
|
+
self.scroll_offset = 0
|
|
630
|
+
self._load_data()
|
|
631
|
+
elif key == ord('R'):
|
|
632
|
+
self._load_data()
|
|
633
|
+
elif key == ord('L'):
|
|
634
|
+
self.current_screen = Screen.STATUS_LOG
|
|
635
|
+
|
|
636
|
+
return True
|
|
637
|
+
|
|
638
|
+
def _handle_help_input(self, key: int) -> bool:
|
|
639
|
+
"""Handle input on help screen."""
|
|
640
|
+
# Any key returns to previous screen
|
|
641
|
+
return True
|
|
642
|
+
|
|
643
|
+
def _handle_confirm_input(self, key: int) -> bool:
|
|
644
|
+
"""Handle input on confirm screen."""
|
|
645
|
+
if key == ord('y') or key == ord('Y'):
|
|
646
|
+
if self.confirm_callback:
|
|
647
|
+
self.confirm_callback()
|
|
648
|
+
self._go_back()
|
|
649
|
+
elif key == ord('n') or key == ord('N'):
|
|
650
|
+
self._go_back()
|
|
651
|
+
|
|
652
|
+
return True
|
|
653
|
+
|
|
654
|
+
def _handle_status_log_input(self, key: int) -> bool:
|
|
655
|
+
"""Handle input on status log screen."""
|
|
656
|
+
if key == ord('c') or key == ord('C'):
|
|
657
|
+
# Clear log messages
|
|
658
|
+
self._status_messages = []
|
|
659
|
+
self._log("Log cleared", "info")
|
|
660
|
+
# Any other key (including q/Escape handled globally) does nothing special
|
|
661
|
+
return True
|
|
662
|
+
|
|
663
|
+
def _go_back(self) -> None:
|
|
664
|
+
"""Go back to previous screen."""
|
|
665
|
+
self.current_screen = Screen.APPS
|
|
666
|
+
self.selected_index = 0
|
|
667
|
+
self.scroll_offset = 0
|
|
668
|
+
self._load_data()
|
|
669
|
+
|
|
670
|
+
def _adjust_scroll(self) -> None:
|
|
671
|
+
"""Adjust scroll offset to keep selection visible."""
|
|
672
|
+
height, _ = self.stdscr.getmaxyx()
|
|
673
|
+
visible_rows = height - 6
|
|
674
|
+
|
|
675
|
+
if self.selected_index < self.scroll_offset:
|
|
676
|
+
self.scroll_offset = self.selected_index
|
|
677
|
+
elif self.selected_index >= self.scroll_offset + visible_rows:
|
|
678
|
+
self.scroll_offset = self.selected_index - visible_rows + 1
|
|
679
|
+
|
|
680
|
+
def _status_symbol(self, status: BuildStatus) -> str:
|
|
681
|
+
"""Get symbol for build status."""
|
|
682
|
+
symbols = {
|
|
683
|
+
BuildStatus.READY: "✓",
|
|
684
|
+
BuildStatus.BUILDING: "⟳",
|
|
685
|
+
BuildStatus.FAILED: "✗",
|
|
686
|
+
BuildStatus.UNKNOWN: "?",
|
|
687
|
+
}
|
|
688
|
+
return symbols.get(status, "?")
|
|
689
|
+
|
|
690
|
+
def _do_rebuild(self) -> None:
|
|
691
|
+
"""Execute rebuild."""
|
|
692
|
+
if self.selected_app:
|
|
693
|
+
self.data_provider.rebuild_app(self.selected_app.name)
|
|
694
|
+
self._load_data()
|
|
695
|
+
|
|
696
|
+
def _do_delete(self) -> None:
|
|
697
|
+
"""Execute delete."""
|
|
698
|
+
if self.selected_app:
|
|
699
|
+
self.data_provider.delete_app(self.selected_app.name)
|
|
700
|
+
self._load_data()
|
|
701
|
+
|
|
702
|
+
def _do_restart(self) -> None:
|
|
703
|
+
"""Execute restart/start service."""
|
|
704
|
+
if self.selected_app:
|
|
705
|
+
result = self.data_provider.restart_service(
|
|
706
|
+
self.selected_app.name,
|
|
707
|
+
namespace=self.selected_app.namespace
|
|
708
|
+
)
|
|
709
|
+
if result.get("success"):
|
|
710
|
+
self._log(f"Started {self.selected_app.display_name}", "info")
|
|
711
|
+
else:
|
|
712
|
+
error = result.get("error", "Unknown error")
|
|
713
|
+
self._log(f"Failed to start {self.selected_app.display_name}: {error}", "error")
|
|
714
|
+
self._load_data()
|
|
715
|
+
|
|
716
|
+
def _show_error(self, message: str) -> None:
|
|
717
|
+
"""Show error message and add to status log."""
|
|
718
|
+
# Add to status log
|
|
719
|
+
self._log(f"Error: {message[:100]}", "error")
|
|
720
|
+
# Also display on screen
|
|
721
|
+
try:
|
|
722
|
+
self.stdscr.addstr(1, 2, f"Error: {message[:60]}", curses.color_pair(Colors.RED))
|
|
723
|
+
except curses.error:
|
|
724
|
+
pass
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def run_curses_tui(initial_screen: str = 'apps') -> None:
|
|
728
|
+
"""Run the ncurses TUI."""
|
|
729
|
+
def main(stdscr):
|
|
730
|
+
tui = CursesTUI(stdscr, initial_screen=initial_screen)
|
|
731
|
+
tui.run()
|
|
732
|
+
|
|
733
|
+
curses.wrapper(main)
|