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,1021 @@
|
|
|
1
|
+
"""Textual-based TUI for Clue2App CLI.
|
|
2
|
+
|
|
3
|
+
Provides a k9s-like interactive terminal interface using the Textual framework.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import DataTable, Footer, Header, Static, Label, Button
|
|
11
|
+
from textual.reactive import reactive
|
|
12
|
+
from textual import work
|
|
13
|
+
from textual.worker import Worker, get_current_worker
|
|
14
|
+
|
|
15
|
+
from typing import Optional, List
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from .data import DataProvider, AppInfo, ProjectInfo, AppStatus, BuildStatus
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AppRow:
|
|
22
|
+
"""Helper to format app data for table display."""
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def build_status_text(status: BuildStatus) -> Text:
|
|
26
|
+
"""Format build status with color."""
|
|
27
|
+
colors = {
|
|
28
|
+
BuildStatus.READY: "green",
|
|
29
|
+
BuildStatus.BUILDING: "yellow",
|
|
30
|
+
BuildStatus.FAILED: "red",
|
|
31
|
+
BuildStatus.UNKNOWN: "dim",
|
|
32
|
+
}
|
|
33
|
+
symbol = {
|
|
34
|
+
BuildStatus.READY: "✓",
|
|
35
|
+
BuildStatus.BUILDING: "⟳",
|
|
36
|
+
BuildStatus.FAILED: "✗",
|
|
37
|
+
BuildStatus.UNKNOWN: "?",
|
|
38
|
+
}
|
|
39
|
+
return Text(f"{symbol[status]} {status.value}", style=colors[status])
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def runtime_status_text(status: AppStatus, pods: int) -> Text:
|
|
43
|
+
"""Format runtime status with color."""
|
|
44
|
+
colors = {
|
|
45
|
+
AppStatus.RUNNING: "green",
|
|
46
|
+
AppStatus.SCALED_TO_ZERO: "yellow",
|
|
47
|
+
AppStatus.STOPPED: "red",
|
|
48
|
+
AppStatus.BUILDING: "cyan",
|
|
49
|
+
AppStatus.FAILED: "red",
|
|
50
|
+
AppStatus.UNKNOWN: "dim",
|
|
51
|
+
}
|
|
52
|
+
return Text(f"{status.value} ({pods})", style=colors[status])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class StatusBar(Static):
|
|
56
|
+
"""Bottom status bar showing context and help."""
|
|
57
|
+
|
|
58
|
+
project_name: reactive[str] = reactive("")
|
|
59
|
+
|
|
60
|
+
def render(self) -> Text:
|
|
61
|
+
project = f"Project: {self.project_name}" if self.project_name else "No project selected"
|
|
62
|
+
return Text.from_markup(f"[bold cyan]{project}[/] | Press [bold]?[/] for help")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CommandBar(Static):
|
|
66
|
+
"""k9s-style command bar showing available shortcuts."""
|
|
67
|
+
|
|
68
|
+
DEFAULT_CSS = """
|
|
69
|
+
CommandBar {
|
|
70
|
+
dock: top;
|
|
71
|
+
height: 1;
|
|
72
|
+
background: $surface-darken-1;
|
|
73
|
+
padding: 0 1;
|
|
74
|
+
}
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
commands: reactive[str] = reactive("")
|
|
78
|
+
|
|
79
|
+
def render(self) -> Text:
|
|
80
|
+
return Text.from_markup(self.commands)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def format_commands(commands: list[tuple[str, str]]) -> str:
|
|
84
|
+
"""Format commands as k9s-style bar.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
commands: List of (key, description) tuples
|
|
88
|
+
"""
|
|
89
|
+
parts = []
|
|
90
|
+
for key, desc in commands:
|
|
91
|
+
parts.append(f"[bold cyan]<{key}>[/] {desc}")
|
|
92
|
+
return " | ".join(parts)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AppsScreen(Screen):
|
|
96
|
+
"""Main apps list screen."""
|
|
97
|
+
|
|
98
|
+
BINDINGS = [
|
|
99
|
+
Binding("q", "quit", "Quit"),
|
|
100
|
+
Binding("escape", "noop", "Home", priority=True, show=False), # Prevent escape from closing app
|
|
101
|
+
Binding("?", "help", "Help"),
|
|
102
|
+
Binding("enter", "select", "Details"),
|
|
103
|
+
Binding("l", "logs", "Logs"),
|
|
104
|
+
Binding("r", "rebuild", "Rebuild"),
|
|
105
|
+
Binding("d", "delete", "Delete"),
|
|
106
|
+
Binding("p", "projects", "Projects"),
|
|
107
|
+
Binding("R", "refresh", "Refresh"),
|
|
108
|
+
Binding("u", "toggle_unlinked", "Unlinked"),
|
|
109
|
+
Binding("a", "toggle_all", "All Projects"),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
show_unlinked: reactive[bool] = reactive(False)
|
|
113
|
+
show_all: reactive[bool] = reactive(False) # False = show only current project
|
|
114
|
+
apps: reactive[List[AppInfo]] = reactive([])
|
|
115
|
+
|
|
116
|
+
def __init__(self, data_provider: DataProvider, *args, **kwargs):
|
|
117
|
+
super().__init__(*args, **kwargs)
|
|
118
|
+
self.data_provider = data_provider
|
|
119
|
+
self._selected_app: Optional[AppInfo] = None
|
|
120
|
+
self._current_project: Optional[ProjectInfo] = None
|
|
121
|
+
|
|
122
|
+
def compose(self) -> ComposeResult:
|
|
123
|
+
yield Header()
|
|
124
|
+
yield CommandBar(id="command-bar")
|
|
125
|
+
yield Container(
|
|
126
|
+
Static("Loading apps...", id="loading"),
|
|
127
|
+
DataTable(id="apps-table"),
|
|
128
|
+
id="main-container"
|
|
129
|
+
)
|
|
130
|
+
yield StatusBar(id="status-bar")
|
|
131
|
+
yield Footer()
|
|
132
|
+
|
|
133
|
+
def on_mount(self) -> None:
|
|
134
|
+
"""Set up the table and load data."""
|
|
135
|
+
# Get current project context
|
|
136
|
+
self._current_project = self.data_provider.get_current_project()
|
|
137
|
+
|
|
138
|
+
# Set up command bar
|
|
139
|
+
self._update_command_bar()
|
|
140
|
+
|
|
141
|
+
table = self.query_one("#apps-table", DataTable)
|
|
142
|
+
table.cursor_type = "row"
|
|
143
|
+
table.zebra_stripes = True
|
|
144
|
+
|
|
145
|
+
# Add columns
|
|
146
|
+
table.add_column("Name", key="name", width=30)
|
|
147
|
+
table.add_column("Project", key="project", width=20)
|
|
148
|
+
table.add_column("Build", key="build", width=15)
|
|
149
|
+
table.add_column("Runtime", key="runtime", width=20)
|
|
150
|
+
table.add_column("URL", key="url", width=40)
|
|
151
|
+
|
|
152
|
+
# Update status bar with current project
|
|
153
|
+
status_bar = self.query_one("#status-bar", StatusBar)
|
|
154
|
+
status_bar.project_name = self._current_project.name if self._current_project else ""
|
|
155
|
+
|
|
156
|
+
# Load apps
|
|
157
|
+
self.load_apps()
|
|
158
|
+
|
|
159
|
+
def _update_command_bar(self) -> None:
|
|
160
|
+
"""Update command bar based on current state."""
|
|
161
|
+
cmd_bar = self.query_one("#command-bar", CommandBar)
|
|
162
|
+
commands = [
|
|
163
|
+
("Enter", "Details"),
|
|
164
|
+
("l", "Logs"),
|
|
165
|
+
("r", "Rebuild"),
|
|
166
|
+
("d", "Delete"),
|
|
167
|
+
("p", "Projects"),
|
|
168
|
+
]
|
|
169
|
+
# Show "a" toggle only if a project is selected
|
|
170
|
+
if self._current_project:
|
|
171
|
+
scope = "All" if self.show_all else self._current_project.name
|
|
172
|
+
commands.append(("a", f"Scope:{scope}"))
|
|
173
|
+
commands.extend([
|
|
174
|
+
("u", "Unlinked"),
|
|
175
|
+
("?", "Help"),
|
|
176
|
+
("q", "Quit"),
|
|
177
|
+
])
|
|
178
|
+
cmd_bar.commands = CommandBar.format_commands(commands)
|
|
179
|
+
|
|
180
|
+
@work(exclusive=True)
|
|
181
|
+
async def load_apps(self) -> None:
|
|
182
|
+
"""Load apps in background."""
|
|
183
|
+
worker = get_current_worker()
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
# Determine project filter
|
|
187
|
+
project_id = None
|
|
188
|
+
if self._current_project and not self.show_all:
|
|
189
|
+
project_id = self._current_project.id
|
|
190
|
+
|
|
191
|
+
apps = self.data_provider.get_apps(
|
|
192
|
+
project_id=project_id,
|
|
193
|
+
include_unlinked=self.show_unlinked
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if not worker.is_cancelled:
|
|
197
|
+
self.call_from_thread(self._update_table, apps)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
if not worker.is_cancelled:
|
|
200
|
+
self.call_from_thread(self._show_error, str(e))
|
|
201
|
+
|
|
202
|
+
def _update_table(self, apps: List[AppInfo]) -> None:
|
|
203
|
+
"""Update the table with app data."""
|
|
204
|
+
self.apps = apps
|
|
205
|
+
|
|
206
|
+
# Hide loading indicator
|
|
207
|
+
loading = self.query_one("#loading", Static)
|
|
208
|
+
|
|
209
|
+
# Update table
|
|
210
|
+
table = self.query_one("#apps-table", DataTable)
|
|
211
|
+
table.clear()
|
|
212
|
+
|
|
213
|
+
# Show helpful message if no apps found with project filter
|
|
214
|
+
if not apps and self._current_project and not self.show_all:
|
|
215
|
+
loading.update(
|
|
216
|
+
f"[yellow]No apps linked to project '{self._current_project.name}'[/yellow]\n\n"
|
|
217
|
+
"[dim]Press [bold]a[/bold] to show all apps, or [bold]u[/bold] to show unlinked apps.\n"
|
|
218
|
+
"Use [bold]c2a apps link <app> --project-id <id>[/bold] to link apps to this project.[/dim]"
|
|
219
|
+
)
|
|
220
|
+
loading.display = True
|
|
221
|
+
table.display = False
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
loading.display = False
|
|
225
|
+
table.display = True
|
|
226
|
+
|
|
227
|
+
for app in apps:
|
|
228
|
+
project_text = Text(app.project_name or "(unlinked)", style="yellow" if not app.project_name else "green")
|
|
229
|
+
url_text = Text(app.url or "-", style="cyan" if app.url else "dim")
|
|
230
|
+
|
|
231
|
+
table.add_row(
|
|
232
|
+
app.display_name,
|
|
233
|
+
project_text,
|
|
234
|
+
AppRow.build_status_text(app.build_status),
|
|
235
|
+
AppRow.runtime_status_text(app.runtime_status, app.pod_count),
|
|
236
|
+
url_text,
|
|
237
|
+
key=app.name
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _show_error(self, message: str) -> None:
|
|
241
|
+
"""Show error message."""
|
|
242
|
+
loading = self.query_one("#loading", Static)
|
|
243
|
+
loading.update(f"[red]Error: {message}[/red]")
|
|
244
|
+
|
|
245
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
246
|
+
"""Handle row selection."""
|
|
247
|
+
if event.row_key:
|
|
248
|
+
app_name = str(event.row_key.value)
|
|
249
|
+
for app in self.apps:
|
|
250
|
+
if app.name == app_name:
|
|
251
|
+
self._selected_app = app
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
def action_refresh(self) -> None:
|
|
255
|
+
"""Refresh the apps list."""
|
|
256
|
+
loading = self.query_one("#loading", Static)
|
|
257
|
+
loading.update("Refreshing...")
|
|
258
|
+
loading.display = True
|
|
259
|
+
self.load_apps()
|
|
260
|
+
|
|
261
|
+
def action_toggle_unlinked(self) -> None:
|
|
262
|
+
"""Toggle showing unlinked apps."""
|
|
263
|
+
self.show_unlinked = not self.show_unlinked
|
|
264
|
+
self.action_refresh()
|
|
265
|
+
|
|
266
|
+
def action_toggle_all(self) -> None:
|
|
267
|
+
"""Toggle showing all projects vs current project only."""
|
|
268
|
+
if not self._current_project:
|
|
269
|
+
self.notify("No project selected - showing all apps", severity="warning")
|
|
270
|
+
return
|
|
271
|
+
self.show_all = not self.show_all
|
|
272
|
+
self._update_command_bar()
|
|
273
|
+
self.action_refresh()
|
|
274
|
+
|
|
275
|
+
def action_noop(self) -> None:
|
|
276
|
+
"""Do nothing - prevents escape from closing app on home screen."""
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
def action_logs(self) -> None:
|
|
280
|
+
"""View logs for selected app."""
|
|
281
|
+
table = self.query_one("#apps-table", DataTable)
|
|
282
|
+
if table.cursor_row is not None and self.apps:
|
|
283
|
+
row_key = table.get_row_at(table.cursor_row)
|
|
284
|
+
# Find the app
|
|
285
|
+
for app in self.apps:
|
|
286
|
+
if app.name == str(table.get_row_key_at(table.cursor_row).value):
|
|
287
|
+
self.app.push_screen(LogsScreen(self.data_provider, app))
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
def action_rebuild(self) -> None:
|
|
291
|
+
"""Rebuild selected app."""
|
|
292
|
+
table = self.query_one("#apps-table", DataTable)
|
|
293
|
+
if table.cursor_row is not None and self.apps:
|
|
294
|
+
for app in self.apps:
|
|
295
|
+
if app.name == str(table.get_row_key_at(table.cursor_row).value):
|
|
296
|
+
self.app.push_screen(ConfirmScreen(
|
|
297
|
+
f"Rebuild '{app.display_name}'?",
|
|
298
|
+
lambda: self._do_rebuild(app)
|
|
299
|
+
))
|
|
300
|
+
break
|
|
301
|
+
|
|
302
|
+
def _do_rebuild(self, app: AppInfo) -> None:
|
|
303
|
+
"""Execute rebuild."""
|
|
304
|
+
result = self.data_provider.rebuild_app(app.name)
|
|
305
|
+
if result:
|
|
306
|
+
self.notify(f"Rebuild triggered for {app.display_name}")
|
|
307
|
+
else:
|
|
308
|
+
self.notify(f"Failed to rebuild {app.display_name}", severity="error")
|
|
309
|
+
self.action_refresh()
|
|
310
|
+
|
|
311
|
+
def action_delete(self) -> None:
|
|
312
|
+
"""Delete selected app."""
|
|
313
|
+
table = self.query_one("#apps-table", DataTable)
|
|
314
|
+
if table.cursor_row is not None and self.apps:
|
|
315
|
+
for app in self.apps:
|
|
316
|
+
if app.name == str(table.get_row_key_at(table.cursor_row).value):
|
|
317
|
+
self.app.push_screen(ConfirmScreen(
|
|
318
|
+
f"Delete '{app.display_name}'? This cannot be undone!",
|
|
319
|
+
lambda: self._do_delete(app)
|
|
320
|
+
))
|
|
321
|
+
break
|
|
322
|
+
|
|
323
|
+
def _do_delete(self, app: AppInfo) -> None:
|
|
324
|
+
"""Execute delete."""
|
|
325
|
+
if self.data_provider.delete_app(app.name):
|
|
326
|
+
self.notify(f"Deleted {app.display_name}")
|
|
327
|
+
else:
|
|
328
|
+
self.notify(f"Failed to delete {app.display_name}", severity="error")
|
|
329
|
+
self.action_refresh()
|
|
330
|
+
|
|
331
|
+
def action_select(self) -> None:
|
|
332
|
+
"""Show details for selected app."""
|
|
333
|
+
table = self.query_one("#apps-table", DataTable)
|
|
334
|
+
if table.cursor_row is not None and self.apps:
|
|
335
|
+
for app in self.apps:
|
|
336
|
+
if app.name == str(table.get_row_key_at(table.cursor_row).value):
|
|
337
|
+
self.app.push_screen(DetailsScreen(self.data_provider, app))
|
|
338
|
+
break
|
|
339
|
+
|
|
340
|
+
def action_projects(self) -> None:
|
|
341
|
+
"""Switch to projects screen."""
|
|
342
|
+
self.app.push_screen(ProjectsScreen(self.data_provider))
|
|
343
|
+
|
|
344
|
+
def action_help(self) -> None:
|
|
345
|
+
"""Show help screen."""
|
|
346
|
+
self.app.push_screen(HelpScreen())
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class LogsScreen(Screen):
|
|
350
|
+
"""Log viewer screen."""
|
|
351
|
+
|
|
352
|
+
BINDINGS = [
|
|
353
|
+
Binding("q", "back", "Back"),
|
|
354
|
+
Binding("escape", "back", "Back", priority=True),
|
|
355
|
+
Binding("f", "toggle_follow", "Follow"),
|
|
356
|
+
Binding("g", "scroll_top", "Top"),
|
|
357
|
+
Binding("G", "scroll_bottom", "Bottom"),
|
|
358
|
+
Binding("b", "toggle_log_type", "Build/Instance"),
|
|
359
|
+
Binding("R", "refresh_logs", "Refresh"),
|
|
360
|
+
# vi-style navigation
|
|
361
|
+
Binding("j", "scroll_down", "Down", show=False),
|
|
362
|
+
Binding("k", "scroll_up", "Up", show=False),
|
|
363
|
+
Binding("ctrl+d", "page_down", "Page Down", show=False),
|
|
364
|
+
Binding("ctrl+u", "page_up", "Page Up", show=False),
|
|
365
|
+
Binding("ctrl+f", "page_down", "Page Down", show=False),
|
|
366
|
+
Binding("ctrl+b", "page_up", "Page Up", show=False),
|
|
367
|
+
Binding("/", "search", "Search"),
|
|
368
|
+
Binding("n", "search_next", "Next", show=False),
|
|
369
|
+
Binding("N", "search_prev", "Prev", show=False),
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
following: reactive[bool] = reactive(False)
|
|
373
|
+
log_type: reactive[str] = reactive("auto") # "auto", "instance", "build"
|
|
374
|
+
search_term: reactive[str] = reactive("")
|
|
375
|
+
search_matches: reactive[List[int]] = reactive([])
|
|
376
|
+
current_match: reactive[int] = reactive(-1)
|
|
377
|
+
|
|
378
|
+
def __init__(self, data_provider: DataProvider, app: AppInfo, *args, **kwargs):
|
|
379
|
+
super().__init__(*args, **kwargs)
|
|
380
|
+
self.data_provider = data_provider
|
|
381
|
+
self.app_info = app
|
|
382
|
+
self._log_worker: Optional[Worker] = None
|
|
383
|
+
self._log_lines: List[str] = []
|
|
384
|
+
|
|
385
|
+
def compose(self) -> ComposeResult:
|
|
386
|
+
yield Header()
|
|
387
|
+
yield CommandBar(id="command-bar")
|
|
388
|
+
yield Container(
|
|
389
|
+
Static(f"[bold]Logs: {self.app_info.display_name}[/bold]", id="logs-header"),
|
|
390
|
+
Static("Loading logs...", id="logs-content"),
|
|
391
|
+
id="logs-container"
|
|
392
|
+
)
|
|
393
|
+
yield Footer()
|
|
394
|
+
|
|
395
|
+
def on_mount(self) -> None:
|
|
396
|
+
"""Load initial logs."""
|
|
397
|
+
self._update_command_bar()
|
|
398
|
+
self.load_logs()
|
|
399
|
+
|
|
400
|
+
def _update_command_bar(self) -> None:
|
|
401
|
+
"""Update command bar based on current state."""
|
|
402
|
+
cmd_bar = self.query_one("#command-bar", CommandBar)
|
|
403
|
+
log_type_label = {"auto": "Auto", "instance": "Instance", "build": "Build"}[self.log_type]
|
|
404
|
+
cmd_bar.commands = CommandBar.format_commands([
|
|
405
|
+
("b", f"Type:{log_type_label}"),
|
|
406
|
+
("j/k", "Up/Down"),
|
|
407
|
+
("/", "Search"),
|
|
408
|
+
("R", "Refresh"),
|
|
409
|
+
("g/G", "Top/Bottom"),
|
|
410
|
+
("Esc", "Back"),
|
|
411
|
+
])
|
|
412
|
+
|
|
413
|
+
def _update_header(self) -> None:
|
|
414
|
+
"""Update header with current log type."""
|
|
415
|
+
header = self.query_one("#logs-header", Static)
|
|
416
|
+
log_type_label = {"auto": "", "instance": " [cyan][Instance][/cyan]", "build": " [yellow][Build][/yellow]"}[self.log_type]
|
|
417
|
+
follow_label = " [green][Following][/green]" if self.following else ""
|
|
418
|
+
header.update(f"[bold]Logs: {self.app_info.display_name}[/bold]{log_type_label}{follow_label}")
|
|
419
|
+
|
|
420
|
+
@work(exclusive=True)
|
|
421
|
+
async def load_logs(self) -> None:
|
|
422
|
+
"""Load logs in background."""
|
|
423
|
+
worker = get_current_worker()
|
|
424
|
+
try:
|
|
425
|
+
logs = self.data_provider.get_logs(self.app_info.name, lines=200, log_type=self.log_type)
|
|
426
|
+
if not worker.is_cancelled:
|
|
427
|
+
self.call_from_thread(self._update_logs, logs)
|
|
428
|
+
except Exception as e:
|
|
429
|
+
if not worker.is_cancelled:
|
|
430
|
+
self.call_from_thread(self._show_error, str(e))
|
|
431
|
+
|
|
432
|
+
def _update_logs(self, logs: str) -> None:
|
|
433
|
+
"""Update logs display."""
|
|
434
|
+
self._log_lines = logs.split('\n') if logs else []
|
|
435
|
+
content = self.query_one("#logs-content", Static)
|
|
436
|
+
content.update(logs if logs else "[dim]No logs available[/dim]")
|
|
437
|
+
|
|
438
|
+
def _show_error(self, message: str) -> None:
|
|
439
|
+
"""Show error message."""
|
|
440
|
+
content = self.query_one("#logs-content", Static)
|
|
441
|
+
content.update(f"[red]Error: {message}[/red]")
|
|
442
|
+
|
|
443
|
+
def action_back(self) -> None:
|
|
444
|
+
"""Go back to apps screen."""
|
|
445
|
+
self.app.pop_screen()
|
|
446
|
+
|
|
447
|
+
def action_toggle_follow(self) -> None:
|
|
448
|
+
"""Toggle log following."""
|
|
449
|
+
self.following = not self.following
|
|
450
|
+
self._update_header()
|
|
451
|
+
|
|
452
|
+
def action_toggle_log_type(self) -> None:
|
|
453
|
+
"""Toggle between log types (auto -> instance -> build -> auto)."""
|
|
454
|
+
if self.log_type == "auto":
|
|
455
|
+
self.log_type = "instance"
|
|
456
|
+
elif self.log_type == "instance":
|
|
457
|
+
self.log_type = "build"
|
|
458
|
+
else:
|
|
459
|
+
self.log_type = "auto"
|
|
460
|
+
self._update_command_bar()
|
|
461
|
+
self._update_header()
|
|
462
|
+
self.load_logs()
|
|
463
|
+
|
|
464
|
+
def action_refresh_logs(self) -> None:
|
|
465
|
+
"""Refresh logs."""
|
|
466
|
+
content = self.query_one("#logs-content", Static)
|
|
467
|
+
content.update("Refreshing logs...")
|
|
468
|
+
self.load_logs()
|
|
469
|
+
|
|
470
|
+
def action_scroll_top(self) -> None:
|
|
471
|
+
"""Scroll to top."""
|
|
472
|
+
container = self.query_one("#logs-container", Container)
|
|
473
|
+
container.scroll_home()
|
|
474
|
+
|
|
475
|
+
def action_scroll_bottom(self) -> None:
|
|
476
|
+
"""Scroll to bottom."""
|
|
477
|
+
container = self.query_one("#logs-container", Container)
|
|
478
|
+
container.scroll_end()
|
|
479
|
+
|
|
480
|
+
def action_scroll_down(self) -> None:
|
|
481
|
+
"""Scroll down one line (vi j)."""
|
|
482
|
+
container = self.query_one("#logs-container", Container)
|
|
483
|
+
container.scroll_relative(y=1)
|
|
484
|
+
|
|
485
|
+
def action_scroll_up(self) -> None:
|
|
486
|
+
"""Scroll up one line (vi k)."""
|
|
487
|
+
container = self.query_one("#logs-container", Container)
|
|
488
|
+
container.scroll_relative(y=-1)
|
|
489
|
+
|
|
490
|
+
def action_page_down(self) -> None:
|
|
491
|
+
"""Scroll down half page (vi Ctrl-d)."""
|
|
492
|
+
container = self.query_one("#logs-container", Container)
|
|
493
|
+
container.scroll_relative(y=container.size.height // 2)
|
|
494
|
+
|
|
495
|
+
def action_page_up(self) -> None:
|
|
496
|
+
"""Scroll up half page (vi Ctrl-u)."""
|
|
497
|
+
container = self.query_one("#logs-container", Container)
|
|
498
|
+
container.scroll_relative(y=-(container.size.height // 2))
|
|
499
|
+
|
|
500
|
+
def action_search(self) -> None:
|
|
501
|
+
"""Start search (vi /)."""
|
|
502
|
+
self.app.push_screen(SearchScreen(self._do_search))
|
|
503
|
+
|
|
504
|
+
def _do_search(self, term: str) -> None:
|
|
505
|
+
"""Execute search."""
|
|
506
|
+
if not term:
|
|
507
|
+
return
|
|
508
|
+
self.search_term = term
|
|
509
|
+
self.search_matches = []
|
|
510
|
+
self.current_match = -1
|
|
511
|
+
|
|
512
|
+
# Find all matching lines
|
|
513
|
+
for i, line in enumerate(self._log_lines):
|
|
514
|
+
if term.lower() in line.lower():
|
|
515
|
+
self.search_matches.append(i)
|
|
516
|
+
|
|
517
|
+
if self.search_matches:
|
|
518
|
+
self.current_match = 0
|
|
519
|
+
self._highlight_match()
|
|
520
|
+
self.notify(f"Found {len(self.search_matches)} matches")
|
|
521
|
+
else:
|
|
522
|
+
self.notify(f"No matches for '{term}'", severity="warning")
|
|
523
|
+
|
|
524
|
+
def _highlight_match(self) -> None:
|
|
525
|
+
"""Highlight current match and scroll to it."""
|
|
526
|
+
if self.current_match < 0 or self.current_match >= len(self.search_matches):
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
# Re-render logs with highlighting
|
|
530
|
+
content = self.query_one("#logs-content", Static)
|
|
531
|
+
highlighted_lines = []
|
|
532
|
+
for i, line in enumerate(self._log_lines):
|
|
533
|
+
if i == self.search_matches[self.current_match]:
|
|
534
|
+
highlighted_lines.append(f"[reverse]{line}[/reverse]")
|
|
535
|
+
elif self.search_term.lower() in line.lower():
|
|
536
|
+
# Highlight search term in other matches
|
|
537
|
+
highlighted_lines.append(line.replace(
|
|
538
|
+
self.search_term,
|
|
539
|
+
f"[bold yellow]{self.search_term}[/bold yellow]"
|
|
540
|
+
))
|
|
541
|
+
else:
|
|
542
|
+
highlighted_lines.append(line)
|
|
543
|
+
content.update('\n'.join(highlighted_lines))
|
|
544
|
+
|
|
545
|
+
# Scroll to match
|
|
546
|
+
container = self.query_one("#logs-container", Container)
|
|
547
|
+
match_line = self.search_matches[self.current_match]
|
|
548
|
+
container.scroll_to(y=match_line)
|
|
549
|
+
|
|
550
|
+
def action_search_next(self) -> None:
|
|
551
|
+
"""Go to next search match (vi n)."""
|
|
552
|
+
if not self.search_matches:
|
|
553
|
+
return
|
|
554
|
+
self.current_match = (self.current_match + 1) % len(self.search_matches)
|
|
555
|
+
self._highlight_match()
|
|
556
|
+
|
|
557
|
+
def action_search_prev(self) -> None:
|
|
558
|
+
"""Go to previous search match (vi N)."""
|
|
559
|
+
if not self.search_matches:
|
|
560
|
+
return
|
|
561
|
+
self.current_match = (self.current_match - 1) % len(self.search_matches)
|
|
562
|
+
self._highlight_match()
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class SearchScreen(Screen):
|
|
566
|
+
"""Search input dialog."""
|
|
567
|
+
|
|
568
|
+
BINDINGS = [
|
|
569
|
+
Binding("escape", "cancel", "Cancel", priority=True),
|
|
570
|
+
]
|
|
571
|
+
|
|
572
|
+
def __init__(self, on_search: callable, *args, **kwargs):
|
|
573
|
+
super().__init__(*args, **kwargs)
|
|
574
|
+
self.on_search = on_search
|
|
575
|
+
|
|
576
|
+
def compose(self) -> ComposeResult:
|
|
577
|
+
from textual.widgets import Input
|
|
578
|
+
yield Container(
|
|
579
|
+
Static("[bold]Search:[/bold]", id="search-label"),
|
|
580
|
+
Input(placeholder="Enter search term...", id="search-input"),
|
|
581
|
+
id="search-dialog"
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def on_mount(self) -> None:
|
|
585
|
+
"""Focus the input."""
|
|
586
|
+
self.query_one("#search-input").focus()
|
|
587
|
+
|
|
588
|
+
def on_input_submitted(self, event) -> None:
|
|
589
|
+
"""Handle search submission."""
|
|
590
|
+
term = event.value
|
|
591
|
+
self.app.pop_screen()
|
|
592
|
+
self.on_search(term)
|
|
593
|
+
|
|
594
|
+
def action_cancel(self) -> None:
|
|
595
|
+
"""Cancel search."""
|
|
596
|
+
self.app.pop_screen()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class DetailsScreen(Screen):
|
|
600
|
+
"""App details screen."""
|
|
601
|
+
|
|
602
|
+
BINDINGS = [
|
|
603
|
+
Binding("q", "back", "Back"),
|
|
604
|
+
Binding("escape", "back", "Back", priority=True),
|
|
605
|
+
Binding("l", "logs", "Logs"),
|
|
606
|
+
Binding("r", "rebuild", "Rebuild"),
|
|
607
|
+
Binding("o", "open_url", "Open URL"),
|
|
608
|
+
]
|
|
609
|
+
|
|
610
|
+
def __init__(self, data_provider: DataProvider, app: AppInfo, *args, **kwargs):
|
|
611
|
+
super().__init__(*args, **kwargs)
|
|
612
|
+
self.data_provider = data_provider
|
|
613
|
+
self.app_info = app
|
|
614
|
+
|
|
615
|
+
def compose(self) -> ComposeResult:
|
|
616
|
+
yield Header()
|
|
617
|
+
yield CommandBar(id="command-bar")
|
|
618
|
+
yield Container(
|
|
619
|
+
Static(self._render_details(), id="details-content"),
|
|
620
|
+
id="details-container"
|
|
621
|
+
)
|
|
622
|
+
yield Footer()
|
|
623
|
+
|
|
624
|
+
def on_mount(self) -> None:
|
|
625
|
+
"""Set up command bar."""
|
|
626
|
+
cmd_bar = self.query_one("#command-bar", CommandBar)
|
|
627
|
+
cmd_bar.commands = CommandBar.format_commands([
|
|
628
|
+
("l", "Logs"),
|
|
629
|
+
("r", "Rebuild"),
|
|
630
|
+
("o", "Open URL"),
|
|
631
|
+
("Esc", "Back"),
|
|
632
|
+
])
|
|
633
|
+
|
|
634
|
+
def _render_details(self) -> str:
|
|
635
|
+
"""Render app details."""
|
|
636
|
+
app = self.app_info
|
|
637
|
+
lines = [
|
|
638
|
+
f"[bold cyan]{'='*60}[/bold cyan]",
|
|
639
|
+
f"[bold]{app.display_name}[/bold]",
|
|
640
|
+
f"[bold cyan]{'='*60}[/bold cyan]",
|
|
641
|
+
"",
|
|
642
|
+
"[bold]General[/bold]",
|
|
643
|
+
f" Name: {app.name}",
|
|
644
|
+
f" Namespace: {app.namespace}",
|
|
645
|
+
f" Project: {app.project_name or '(unlinked)'}",
|
|
646
|
+
"",
|
|
647
|
+
"[bold]Build[/bold]",
|
|
648
|
+
f" Status: {app.build_status.value}",
|
|
649
|
+
f" Builds: {app.build_count}",
|
|
650
|
+
f" Reason: {app.build_reason or 'N/A'}",
|
|
651
|
+
f" Stack: {app.stack or 'N/A'}",
|
|
652
|
+
"",
|
|
653
|
+
"[bold]Source[/bold]",
|
|
654
|
+
f" Git URL: {app.git_url or 'N/A'}",
|
|
655
|
+
f" Revision: {app.git_revision or 'N/A'}",
|
|
656
|
+
"",
|
|
657
|
+
"[bold]Runtime[/bold]",
|
|
658
|
+
f" Status: {app.runtime_status.value}",
|
|
659
|
+
f" Pods: {app.pod_count}",
|
|
660
|
+
f" URL: {app.url or 'N/A'}",
|
|
661
|
+
"",
|
|
662
|
+
"[bold]Image[/bold]",
|
|
663
|
+
f" Latest: {app.latest_image[:60] + '...' if app.latest_image and len(app.latest_image) > 60 else app.latest_image or 'N/A'}",
|
|
664
|
+
]
|
|
665
|
+
return "\n".join(lines)
|
|
666
|
+
|
|
667
|
+
def action_back(self) -> None:
|
|
668
|
+
"""Go back."""
|
|
669
|
+
self.app.pop_screen()
|
|
670
|
+
|
|
671
|
+
def action_logs(self) -> None:
|
|
672
|
+
"""View logs."""
|
|
673
|
+
self.app.switch_screen(LogsScreen(self.data_provider, self.app_info))
|
|
674
|
+
|
|
675
|
+
def action_rebuild(self) -> None:
|
|
676
|
+
"""Rebuild app."""
|
|
677
|
+
self.app.push_screen(ConfirmScreen(
|
|
678
|
+
f"Rebuild '{self.app_info.display_name}'?",
|
|
679
|
+
self._do_rebuild
|
|
680
|
+
))
|
|
681
|
+
|
|
682
|
+
def _do_rebuild(self) -> None:
|
|
683
|
+
"""Execute rebuild."""
|
|
684
|
+
result = self.data_provider.rebuild_app(self.app_info.name)
|
|
685
|
+
if result:
|
|
686
|
+
self.notify(f"Rebuild triggered for {self.app_info.display_name}")
|
|
687
|
+
else:
|
|
688
|
+
self.notify(f"Failed to rebuild", severity="error")
|
|
689
|
+
|
|
690
|
+
def action_open_url(self) -> None:
|
|
691
|
+
"""Open app URL in browser."""
|
|
692
|
+
if self.app_info.url:
|
|
693
|
+
import webbrowser
|
|
694
|
+
webbrowser.open(self.app_info.url)
|
|
695
|
+
self.notify(f"Opening {self.app_info.url}")
|
|
696
|
+
else:
|
|
697
|
+
self.notify("No URL available", severity="warning")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class ProjectsScreen(Screen):
|
|
701
|
+
"""Projects list screen."""
|
|
702
|
+
|
|
703
|
+
BINDINGS = [
|
|
704
|
+
Binding("q", "back", "Back"),
|
|
705
|
+
Binding("escape", "back", "Back", priority=True),
|
|
706
|
+
Binding("enter", "select", "Select"),
|
|
707
|
+
Binding("R", "refresh", "Refresh"),
|
|
708
|
+
]
|
|
709
|
+
|
|
710
|
+
def __init__(self, data_provider: DataProvider, *args, **kwargs):
|
|
711
|
+
super().__init__(*args, **kwargs)
|
|
712
|
+
self.data_provider = data_provider
|
|
713
|
+
self.projects: List[ProjectInfo] = []
|
|
714
|
+
|
|
715
|
+
def compose(self) -> ComposeResult:
|
|
716
|
+
yield Header()
|
|
717
|
+
yield CommandBar(id="command-bar")
|
|
718
|
+
yield Container(
|
|
719
|
+
Static("Loading projects...", id="loading"),
|
|
720
|
+
DataTable(id="projects-table"),
|
|
721
|
+
id="main-container"
|
|
722
|
+
)
|
|
723
|
+
yield Footer()
|
|
724
|
+
|
|
725
|
+
def on_mount(self) -> None:
|
|
726
|
+
"""Set up table and load data."""
|
|
727
|
+
cmd_bar = self.query_one("#command-bar", CommandBar)
|
|
728
|
+
cmd_bar.commands = CommandBar.format_commands([
|
|
729
|
+
("Enter", "Select"),
|
|
730
|
+
("R", "Refresh"),
|
|
731
|
+
("Esc", "Back"),
|
|
732
|
+
])
|
|
733
|
+
|
|
734
|
+
table = self.query_one("#projects-table", DataTable)
|
|
735
|
+
table.cursor_type = "row"
|
|
736
|
+
table.zebra_stripes = True
|
|
737
|
+
|
|
738
|
+
table.add_column("Name", key="name", width=30)
|
|
739
|
+
table.add_column("ID", key="id", width=40)
|
|
740
|
+
table.add_column("Namespace", key="namespace", width=20)
|
|
741
|
+
|
|
742
|
+
self.load_projects()
|
|
743
|
+
|
|
744
|
+
@work(exclusive=True)
|
|
745
|
+
async def load_projects(self) -> None:
|
|
746
|
+
"""Load projects in background."""
|
|
747
|
+
worker = get_current_worker()
|
|
748
|
+
try:
|
|
749
|
+
projects = self.data_provider.get_projects()
|
|
750
|
+
if not worker.is_cancelled:
|
|
751
|
+
self.call_from_thread(self._update_table, projects)
|
|
752
|
+
except Exception as e:
|
|
753
|
+
if not worker.is_cancelled:
|
|
754
|
+
self.call_from_thread(self._show_error, str(e))
|
|
755
|
+
|
|
756
|
+
def _update_table(self, projects: List[ProjectInfo]) -> None:
|
|
757
|
+
"""Update table with projects."""
|
|
758
|
+
self.projects = projects
|
|
759
|
+
|
|
760
|
+
loading = self.query_one("#loading", Static)
|
|
761
|
+
loading.display = False
|
|
762
|
+
|
|
763
|
+
table = self.query_one("#projects-table", DataTable)
|
|
764
|
+
table.display = True
|
|
765
|
+
table.clear()
|
|
766
|
+
|
|
767
|
+
for p in projects:
|
|
768
|
+
table.add_row(p.name, p.id, p.namespace, key=p.id)
|
|
769
|
+
|
|
770
|
+
def _show_error(self, message: str) -> None:
|
|
771
|
+
"""Show error message."""
|
|
772
|
+
loading = self.query_one("#loading", Static)
|
|
773
|
+
loading.update(f"[red]Error: {message}[/red]")
|
|
774
|
+
|
|
775
|
+
def action_back(self) -> None:
|
|
776
|
+
"""Go back."""
|
|
777
|
+
self.app.pop_screen()
|
|
778
|
+
|
|
779
|
+
def action_select(self) -> None:
|
|
780
|
+
"""Select project and go to apps."""
|
|
781
|
+
table = self.query_one("#projects-table", DataTable)
|
|
782
|
+
if table.cursor_row is not None and self.projects:
|
|
783
|
+
row_key = table.get_row_key_at(table.cursor_row)
|
|
784
|
+
for p in self.projects:
|
|
785
|
+
if p.id == str(row_key.value):
|
|
786
|
+
# TODO: Set project context
|
|
787
|
+
self.notify(f"Selected project: {p.name}")
|
|
788
|
+
self.app.pop_screen()
|
|
789
|
+
break
|
|
790
|
+
|
|
791
|
+
def action_refresh(self) -> None:
|
|
792
|
+
"""Refresh projects."""
|
|
793
|
+
loading = self.query_one("#loading", Static)
|
|
794
|
+
loading.update("Refreshing...")
|
|
795
|
+
loading.display = True
|
|
796
|
+
self.load_projects()
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
class ConfirmScreen(Screen):
|
|
800
|
+
"""Confirmation dialog screen."""
|
|
801
|
+
|
|
802
|
+
BINDINGS = [
|
|
803
|
+
Binding("y", "confirm", "Yes"),
|
|
804
|
+
Binding("n", "cancel", "No"),
|
|
805
|
+
Binding("escape", "cancel", "Cancel", priority=True),
|
|
806
|
+
]
|
|
807
|
+
|
|
808
|
+
def __init__(self, message: str, on_confirm: callable, *args, **kwargs):
|
|
809
|
+
super().__init__(*args, **kwargs)
|
|
810
|
+
self.message = message
|
|
811
|
+
self.on_confirm = on_confirm
|
|
812
|
+
|
|
813
|
+
def compose(self) -> ComposeResult:
|
|
814
|
+
yield Container(
|
|
815
|
+
Static(f"[bold yellow]{self.message}[/bold yellow]", id="confirm-message"),
|
|
816
|
+
Horizontal(
|
|
817
|
+
Button("Yes (y)", id="yes-btn", variant="warning"),
|
|
818
|
+
Button("No (n)", id="no-btn", variant="primary"),
|
|
819
|
+
id="confirm-buttons"
|
|
820
|
+
),
|
|
821
|
+
id="confirm-dialog"
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
def action_confirm(self) -> None:
|
|
825
|
+
"""Confirm action."""
|
|
826
|
+
self.app.pop_screen()
|
|
827
|
+
self.on_confirm()
|
|
828
|
+
|
|
829
|
+
def action_cancel(self) -> None:
|
|
830
|
+
"""Cancel action."""
|
|
831
|
+
self.app.pop_screen()
|
|
832
|
+
|
|
833
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
834
|
+
"""Handle button press."""
|
|
835
|
+
if event.button.id == "yes-btn":
|
|
836
|
+
self.action_confirm()
|
|
837
|
+
else:
|
|
838
|
+
self.action_cancel()
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
class HelpScreen(Screen):
|
|
842
|
+
"""Help screen showing keyboard shortcuts."""
|
|
843
|
+
|
|
844
|
+
BINDINGS = [
|
|
845
|
+
Binding("q", "back", "Back"),
|
|
846
|
+
Binding("escape", "back", "Back", priority=True),
|
|
847
|
+
]
|
|
848
|
+
|
|
849
|
+
def compose(self) -> ComposeResult:
|
|
850
|
+
yield Container(
|
|
851
|
+
Static(self._render_help(), id="help-content"),
|
|
852
|
+
id="help-container"
|
|
853
|
+
)
|
|
854
|
+
yield Footer()
|
|
855
|
+
|
|
856
|
+
def _render_help(self) -> str:
|
|
857
|
+
"""Render help content."""
|
|
858
|
+
return """
|
|
859
|
+
[bold cyan]╔══════════════════════════════════════════════════════════════╗[/bold cyan]
|
|
860
|
+
[bold cyan]║[/bold cyan] [bold white]Clue2App TUI - Quick Reference[/bold white] [bold cyan]║[/bold cyan]
|
|
861
|
+
[bold cyan]╚══════════════════════════════════════════════════════════════╝[/bold cyan]
|
|
862
|
+
|
|
863
|
+
[bold green]┌─ NAVIGATION ─────────────────────────────────────────────────┐[/bold green]
|
|
864
|
+
│ │
|
|
865
|
+
│ [bold cyan]↑[/bold cyan] or [bold cyan]k[/bold cyan] Move selection up │
|
|
866
|
+
│ [bold cyan]↓[/bold cyan] or [bold cyan]j[/bold cyan] Move selection down │
|
|
867
|
+
│ [bold cyan]Enter[/bold cyan] Open / Select item │
|
|
868
|
+
│ [bold cyan]Escape[/bold cyan] Go back to previous screen │
|
|
869
|
+
│ [bold cyan]q[/bold cyan] Quit (from main screen) / Back │
|
|
870
|
+
│ │
|
|
871
|
+
[bold green]└───────────────────────────────────────────────────────────────┘[/bold green]
|
|
872
|
+
|
|
873
|
+
[bold yellow]┌─ APPS SCREEN ─────────────────────────────────────────────────┐[/bold yellow]
|
|
874
|
+
│ │
|
|
875
|
+
│ [bold cyan]Enter[/bold cyan] View app details │
|
|
876
|
+
│ [bold cyan]l[/bold cyan] View app logs │
|
|
877
|
+
│ [bold cyan]r[/bold cyan] Rebuild app (triggers new build) │
|
|
878
|
+
│ [bold cyan]d[/bold cyan] Delete app (asks for confirmation) │
|
|
879
|
+
│ [bold cyan]p[/bold cyan] Switch to projects view │
|
|
880
|
+
│ [bold cyan]a[/bold cyan] Toggle all projects / current project only │
|
|
881
|
+
│ [bold cyan]u[/bold cyan] Toggle unlinked apps visibility │
|
|
882
|
+
│ [bold cyan]R[/bold cyan] Refresh the list │
|
|
883
|
+
│ │
|
|
884
|
+
[bold yellow]└───────────────────────────────────────────────────────────────┘[/bold yellow]
|
|
885
|
+
|
|
886
|
+
[bold magenta]┌─ LOGS SCREEN ─────────────────────────────────────────────────┐[/bold magenta]
|
|
887
|
+
│ │
|
|
888
|
+
│ [bold cyan]j[/bold cyan] / [bold cyan]k[/bold cyan] Scroll down / up (one line) │
|
|
889
|
+
│ [bold cyan]Ctrl+D[/bold cyan] Scroll down half page │
|
|
890
|
+
│ [bold cyan]Ctrl+U[/bold cyan] Scroll up half page │
|
|
891
|
+
│ [bold cyan]g[/bold cyan] Jump to top of logs │
|
|
892
|
+
│ [bold cyan]G[/bold cyan] Jump to bottom of logs │
|
|
893
|
+
│ [bold cyan]f[/bold cyan] Toggle follow mode (live updates) │
|
|
894
|
+
│ [bold cyan]/[/bold cyan] Search in logs │
|
|
895
|
+
│ [bold cyan]n[/bold cyan] Go to next search match │
|
|
896
|
+
│ [bold cyan]N[/bold cyan] Go to previous search match │
|
|
897
|
+
│ │
|
|
898
|
+
[bold magenta]└───────────────────────────────────────────────────────────────┘[/bold magenta]
|
|
899
|
+
|
|
900
|
+
[bold blue]┌─ DETAILS SCREEN ───────────────────────────────────────────────┐[/bold blue]
|
|
901
|
+
│ │
|
|
902
|
+
│ [bold cyan]l[/bold cyan] View logs │
|
|
903
|
+
│ [bold cyan]r[/bold cyan] Rebuild app │
|
|
904
|
+
│ [bold cyan]o[/bold cyan] Open app URL in browser │
|
|
905
|
+
│ │
|
|
906
|
+
[bold blue]└───────────────────────────────────────────────────────────────┘[/bold blue]
|
|
907
|
+
|
|
908
|
+
[dim]Press [bold]q[/bold] or [bold]Escape[/bold] to close this help and return.[/dim]
|
|
909
|
+
"""
|
|
910
|
+
|
|
911
|
+
def action_back(self) -> None:
|
|
912
|
+
"""Close help."""
|
|
913
|
+
self.app.pop_screen()
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
class Clue2AppTUI(App):
|
|
917
|
+
"""Main Clue2App TUI application."""
|
|
918
|
+
|
|
919
|
+
CSS = """
|
|
920
|
+
#main-container {
|
|
921
|
+
height: 100%;
|
|
922
|
+
padding: 1;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
#loading {
|
|
926
|
+
text-align: center;
|
|
927
|
+
padding: 2;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
#apps-table {
|
|
931
|
+
height: 100%;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
#status-bar {
|
|
935
|
+
dock: bottom;
|
|
936
|
+
height: 1;
|
|
937
|
+
background: $surface;
|
|
938
|
+
padding: 0 1;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
#logs-container {
|
|
942
|
+
height: 100%;
|
|
943
|
+
padding: 1;
|
|
944
|
+
overflow-y: auto;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
#logs-header {
|
|
948
|
+
padding: 0 0 1 0;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
#logs-content {
|
|
952
|
+
height: auto;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
#details-container {
|
|
956
|
+
height: 100%;
|
|
957
|
+
padding: 1;
|
|
958
|
+
overflow-y: auto;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
#confirm-dialog {
|
|
962
|
+
align: center middle;
|
|
963
|
+
width: 60;
|
|
964
|
+
height: 10;
|
|
965
|
+
border: solid $primary;
|
|
966
|
+
padding: 1 2;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
#confirm-message {
|
|
970
|
+
text-align: center;
|
|
971
|
+
padding: 1;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
#confirm-buttons {
|
|
975
|
+
align: center middle;
|
|
976
|
+
height: 3;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
#confirm-buttons Button {
|
|
980
|
+
margin: 0 1;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
#help-container {
|
|
984
|
+
padding: 2;
|
|
985
|
+
overflow-y: auto;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
#search-dialog {
|
|
989
|
+
align: center middle;
|
|
990
|
+
width: 60;
|
|
991
|
+
height: 5;
|
|
992
|
+
border: solid $primary;
|
|
993
|
+
padding: 1 2;
|
|
994
|
+
background: $surface;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
#search-label {
|
|
998
|
+
padding: 0 0 1 0;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
#search-input {
|
|
1002
|
+
width: 100%;
|
|
1003
|
+
}
|
|
1004
|
+
"""
|
|
1005
|
+
|
|
1006
|
+
TITLE = "Clue2App TUI"
|
|
1007
|
+
BINDINGS = [
|
|
1008
|
+
Binding("q", "quit", "Quit", show=True),
|
|
1009
|
+
]
|
|
1010
|
+
|
|
1011
|
+
def __init__(self, initial_screen: str = 'apps', *args, **kwargs):
|
|
1012
|
+
super().__init__(*args, **kwargs)
|
|
1013
|
+
self.initial_screen = initial_screen
|
|
1014
|
+
self.data_provider = DataProvider()
|
|
1015
|
+
|
|
1016
|
+
def on_mount(self) -> None:
|
|
1017
|
+
"""Set up initial screen."""
|
|
1018
|
+
if self.initial_screen == 'projects':
|
|
1019
|
+
self.push_screen(ProjectsScreen(self.data_provider))
|
|
1020
|
+
else:
|
|
1021
|
+
self.push_screen(AppsScreen(self.data_provider))
|