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
clue2app_cli/tui/data.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Reusable data fetching layer for TUI.
|
|
2
|
+
|
|
3
|
+
Provides async-compatible data fetching functions that can be used
|
|
4
|
+
by both Textual and ncurses TUI implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional, Generator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AppStatus(Enum):
|
|
13
|
+
"""Application runtime status."""
|
|
14
|
+
RUNNING = "Running"
|
|
15
|
+
SCALED_TO_ZERO = "Scaled to Zero"
|
|
16
|
+
STOPPED = "Stopped"
|
|
17
|
+
BUILDING = "Building"
|
|
18
|
+
FAILED = "Failed"
|
|
19
|
+
UNKNOWN = "Unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BuildStatus(Enum):
|
|
23
|
+
"""Application build status."""
|
|
24
|
+
READY = "Ready"
|
|
25
|
+
BUILDING = "Building"
|
|
26
|
+
FAILED = "Failed"
|
|
27
|
+
UNKNOWN = "Unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AppInfo:
|
|
32
|
+
"""Unified app information combining build and runtime data."""
|
|
33
|
+
name: str
|
|
34
|
+
display_name: str
|
|
35
|
+
namespace: str
|
|
36
|
+
project_id: Optional[str]
|
|
37
|
+
project_name: Optional[str]
|
|
38
|
+
|
|
39
|
+
# Build info
|
|
40
|
+
build_status: BuildStatus
|
|
41
|
+
build_count: int
|
|
42
|
+
build_reason: Optional[str]
|
|
43
|
+
stack: Optional[str]
|
|
44
|
+
|
|
45
|
+
# Source info
|
|
46
|
+
git_url: Optional[str]
|
|
47
|
+
git_revision: Optional[str]
|
|
48
|
+
|
|
49
|
+
# Runtime info
|
|
50
|
+
runtime_status: AppStatus
|
|
51
|
+
pod_count: int
|
|
52
|
+
url: Optional[str]
|
|
53
|
+
|
|
54
|
+
# Image info
|
|
55
|
+
latest_image: Optional[str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ProjectInfo:
|
|
60
|
+
"""Project information."""
|
|
61
|
+
id: str
|
|
62
|
+
name: str
|
|
63
|
+
namespace: str
|
|
64
|
+
app_count: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DataProvider:
|
|
68
|
+
"""Reusable data fetching for TUI.
|
|
69
|
+
|
|
70
|
+
This class provides methods to fetch and transform data from the SDK
|
|
71
|
+
into formats suitable for TUI display.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
UNLINKED_PROJECT_ID = "00000000-0000-0000-0000-000000000000"
|
|
75
|
+
|
|
76
|
+
def __init__(self):
|
|
77
|
+
from clue2app_cli.sdk import Clue2AppSDK
|
|
78
|
+
self.sdk = Clue2AppSDK()
|
|
79
|
+
self._project_cache: Dict[str, ProjectInfo] = {}
|
|
80
|
+
|
|
81
|
+
def get_projects(self) -> List[ProjectInfo]:
|
|
82
|
+
"""Get all projects."""
|
|
83
|
+
# Use suppress_errors=False so we get exceptions we can catch
|
|
84
|
+
projects = self.sdk.list_projects(suppress_errors=False)
|
|
85
|
+
|
|
86
|
+
result = []
|
|
87
|
+
for p in projects:
|
|
88
|
+
info = ProjectInfo(
|
|
89
|
+
id=p.get('id', ''),
|
|
90
|
+
name=p.get('name', 'Unknown'),
|
|
91
|
+
namespace=p.get('namespace', ''),
|
|
92
|
+
app_count=0 # Will be populated when we fetch apps
|
|
93
|
+
)
|
|
94
|
+
self._project_cache[info.id] = info
|
|
95
|
+
result.append(info)
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
def get_project(self, project_id: str) -> Optional[ProjectInfo]:
|
|
99
|
+
"""Get a single project by ID."""
|
|
100
|
+
if project_id in self._project_cache:
|
|
101
|
+
return self._project_cache[project_id]
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
project = self.sdk.get_project(project_id, quiet=True)
|
|
105
|
+
if project:
|
|
106
|
+
info = ProjectInfo(
|
|
107
|
+
id=project.get('id', ''),
|
|
108
|
+
name=project.get('name', 'Unknown'),
|
|
109
|
+
namespace=project.get('namespace', ''),
|
|
110
|
+
app_count=0
|
|
111
|
+
)
|
|
112
|
+
self._project_cache[info.id] = info
|
|
113
|
+
return info
|
|
114
|
+
except Exception:
|
|
115
|
+
# Silently fail - project lookup is non-critical
|
|
116
|
+
pass
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def get_apps(self, project_id: Optional[str] = None, include_unlinked: bool = False) -> List[AppInfo]:
|
|
120
|
+
"""Get apps with merged build and runtime info.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
project_id: Filter by project ID (None for all projects in context)
|
|
124
|
+
include_unlinked: Include apps not linked to any project
|
|
125
|
+
"""
|
|
126
|
+
# Fetch apps from SDK (suppress_errors=False to get exceptions we can catch)
|
|
127
|
+
if include_unlinked:
|
|
128
|
+
apps = self.sdk.list_apps(use_context=False, unlinked_only=True, suppress_errors=False)
|
|
129
|
+
elif project_id:
|
|
130
|
+
apps = self.sdk.list_apps(project_id=project_id, use_context=True, suppress_errors=False)
|
|
131
|
+
else:
|
|
132
|
+
apps = self.sdk.list_apps(use_context=False, suppress_errors=False)
|
|
133
|
+
|
|
134
|
+
# Fetch runtime metrics
|
|
135
|
+
metrics = self.sdk.get_app_runtime_metrics(project_id=project_id)
|
|
136
|
+
metrics_map = {}
|
|
137
|
+
for m in metrics:
|
|
138
|
+
svc_name = m.get('serviceName', '')
|
|
139
|
+
metrics_map[svc_name] = m
|
|
140
|
+
|
|
141
|
+
# Merge and transform
|
|
142
|
+
result = []
|
|
143
|
+
for app in apps:
|
|
144
|
+
info = self._transform_app(app, metrics_map)
|
|
145
|
+
result.append(info)
|
|
146
|
+
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
def _transform_app(self, app: Dict[str, Any], metrics_map: Dict[str, Any]) -> AppInfo:
|
|
150
|
+
"""Transform raw app data into AppInfo."""
|
|
151
|
+
meta = app.get('metadata', {})
|
|
152
|
+
spec = app.get('spec', {})
|
|
153
|
+
status = app.get('status', {})
|
|
154
|
+
labels = meta.get('labels', {})
|
|
155
|
+
git = spec.get('source', {}).get('git', {})
|
|
156
|
+
|
|
157
|
+
name = meta.get('name', '')
|
|
158
|
+
display_name = meta.get('displayName', name)
|
|
159
|
+
|
|
160
|
+
# Get project info
|
|
161
|
+
app_group_id = labels.get('appGroupId', '')
|
|
162
|
+
project_name = None
|
|
163
|
+
if app_group_id and app_group_id != self.UNLINKED_PROJECT_ID:
|
|
164
|
+
project = self.get_project(app_group_id)
|
|
165
|
+
if project:
|
|
166
|
+
project_name = project.name
|
|
167
|
+
|
|
168
|
+
# Determine build status
|
|
169
|
+
conditions = status.get('conditions', [])
|
|
170
|
+
build_status = BuildStatus.UNKNOWN
|
|
171
|
+
for cond in conditions:
|
|
172
|
+
if cond.get('type') == 'Ready':
|
|
173
|
+
if cond.get('status') == 'True':
|
|
174
|
+
build_status = BuildStatus.READY
|
|
175
|
+
elif cond.get('status') == 'False':
|
|
176
|
+
build_status = BuildStatus.FAILED
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
if status.get('buildCounter', 0) > 0 and build_status == BuildStatus.UNKNOWN:
|
|
180
|
+
# Check if currently building
|
|
181
|
+
latest_build = status.get('latestBuildRef', '')
|
|
182
|
+
if latest_build:
|
|
183
|
+
build_status = BuildStatus.BUILDING
|
|
184
|
+
|
|
185
|
+
# Get runtime info from metrics
|
|
186
|
+
runtime_status = AppStatus.UNKNOWN
|
|
187
|
+
pod_count = 0
|
|
188
|
+
url = None
|
|
189
|
+
|
|
190
|
+
# Try to match by name (strip timestamp prefix if present)
|
|
191
|
+
base_name = name
|
|
192
|
+
if '-' in name:
|
|
193
|
+
parts = name.split('-', 1)
|
|
194
|
+
if parts[0].isdigit():
|
|
195
|
+
base_name = parts[1]
|
|
196
|
+
|
|
197
|
+
metric = metrics_map.get(name) or metrics_map.get(base_name) or metrics_map.get(display_name)
|
|
198
|
+
if metric:
|
|
199
|
+
ready_replicas = metric.get('readyReplicas', 0)
|
|
200
|
+
desired_replicas = metric.get('desiredReplicas', 0)
|
|
201
|
+
|
|
202
|
+
if ready_replicas > 0:
|
|
203
|
+
runtime_status = AppStatus.RUNNING
|
|
204
|
+
elif desired_replicas == 0:
|
|
205
|
+
runtime_status = AppStatus.SCALED_TO_ZERO
|
|
206
|
+
else:
|
|
207
|
+
runtime_status = AppStatus.STOPPED
|
|
208
|
+
|
|
209
|
+
pod_count = ready_replicas
|
|
210
|
+
url = metric.get('url')
|
|
211
|
+
|
|
212
|
+
return AppInfo(
|
|
213
|
+
name=name,
|
|
214
|
+
display_name=display_name,
|
|
215
|
+
namespace=meta.get('namespace', ''),
|
|
216
|
+
project_id=app_group_id if app_group_id != self.UNLINKED_PROJECT_ID else None,
|
|
217
|
+
project_name=project_name,
|
|
218
|
+
build_status=build_status,
|
|
219
|
+
build_count=status.get('buildCounter', 0),
|
|
220
|
+
build_reason=status.get('latestBuildReason', ''),
|
|
221
|
+
stack=status.get('stack', {}).get('id', ''),
|
|
222
|
+
git_url=git.get('url'),
|
|
223
|
+
git_revision=git.get('revision', 'main'),
|
|
224
|
+
runtime_status=runtime_status,
|
|
225
|
+
pod_count=pod_count,
|
|
226
|
+
url=url,
|
|
227
|
+
latest_image=status.get('latestImage', '')
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def get_app_details(self, app_name: str) -> Optional[AppInfo]:
|
|
231
|
+
"""Get detailed info for a single app."""
|
|
232
|
+
app = self.sdk.get_app(app_name)
|
|
233
|
+
if not app:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
# Get runtime metrics for this app
|
|
237
|
+
meta = app.get('metadata', {})
|
|
238
|
+
labels = meta.get('labels', {})
|
|
239
|
+
project_id = labels.get('appGroupId')
|
|
240
|
+
|
|
241
|
+
metrics = self.sdk.get_app_runtime_metrics(project_id=project_id)
|
|
242
|
+
metrics_map = {m.get('serviceName', ''): m for m in metrics}
|
|
243
|
+
|
|
244
|
+
return self._transform_app(app, metrics_map)
|
|
245
|
+
|
|
246
|
+
def stream_logs(self, app_name: str, lines: int = 100) -> Generator[str, None, None]:
|
|
247
|
+
"""Stream instance logs for an app.
|
|
248
|
+
|
|
249
|
+
Yields log lines as they become available.
|
|
250
|
+
"""
|
|
251
|
+
yield from self.sdk.get_instance_logs(app_name, lines=lines, follow=True)
|
|
252
|
+
|
|
253
|
+
def get_instance_logs(self, app_name: str, lines: int = 100) -> str:
|
|
254
|
+
"""Get recent instance (runtime) logs for an app."""
|
|
255
|
+
try:
|
|
256
|
+
return self.sdk.get_instance_logs(app_name, lines=lines, follow=False)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
return f"Error getting instance logs: {e}"
|
|
259
|
+
|
|
260
|
+
def get_build_logs(self, app_name: str) -> str:
|
|
261
|
+
"""Get build logs for the latest build of an app."""
|
|
262
|
+
return self.sdk.get_latest_build_logs(app_name)
|
|
263
|
+
|
|
264
|
+
def get_logs(self, app_name: str, lines: int = 100, log_type: str = "auto") -> str:
|
|
265
|
+
"""Get logs for an app.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
app_name: The application name
|
|
269
|
+
lines: Number of lines for instance logs
|
|
270
|
+
log_type: "instance", "build", or "auto" (tries instance first, falls back to build)
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Log content string
|
|
274
|
+
"""
|
|
275
|
+
if log_type == "build":
|
|
276
|
+
return self.get_build_logs(app_name)
|
|
277
|
+
elif log_type == "instance":
|
|
278
|
+
return self.get_instance_logs(app_name, lines)
|
|
279
|
+
else:
|
|
280
|
+
# Auto mode: try instance logs first, fall back to build logs
|
|
281
|
+
instance_logs = self.get_instance_logs(app_name, lines)
|
|
282
|
+
if instance_logs and not instance_logs.startswith("Error") and len(instance_logs.strip()) > 0:
|
|
283
|
+
return instance_logs
|
|
284
|
+
# No instance logs (probably scaled to zero), try build logs
|
|
285
|
+
build_logs = self.get_build_logs(app_name)
|
|
286
|
+
if build_logs:
|
|
287
|
+
return f"[No running instances - showing build logs]\n\n{build_logs}"
|
|
288
|
+
return "No logs available (no running instances and no build logs found)"
|
|
289
|
+
|
|
290
|
+
def rebuild_app(self, app_name: str) -> Optional[Dict[str, Any]]:
|
|
291
|
+
"""Trigger app rebuild."""
|
|
292
|
+
return self.sdk.rebuild_app(app_name)
|
|
293
|
+
|
|
294
|
+
def link_app(self, app_name: str, project_id: str) -> Optional[Dict[str, Any]]:
|
|
295
|
+
"""Link app to project."""
|
|
296
|
+
return self.sdk.link_app_to_project(app_name, project_id)
|
|
297
|
+
|
|
298
|
+
def delete_app(self, app_name: str) -> bool:
|
|
299
|
+
"""Delete an app."""
|
|
300
|
+
return self.sdk.delete_app(app_name)
|
|
301
|
+
|
|
302
|
+
def restart_service(self, app_name: str, namespace: Optional[str] = None) -> Dict[str, Any]:
|
|
303
|
+
"""Restart/wake up a service (useful for scaled-to-zero apps).
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Dict with 'success' bool and 'error' message if failed
|
|
307
|
+
"""
|
|
308
|
+
return self.sdk.restart_service(app_name, namespace=namespace)
|
|
309
|
+
|
|
310
|
+
def get_current_project(self) -> Optional[ProjectInfo]:
|
|
311
|
+
"""Get the currently selected project from context."""
|
|
312
|
+
from clue2app_cli.config import get_config_value
|
|
313
|
+
project_id = get_config_value("context.project.id")
|
|
314
|
+
if project_id:
|
|
315
|
+
return self.get_project(project_id)
|
|
316
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TUI screens."""
|