kctl-api 0.2.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.
- kctl_api/__init__.py +3 -0
- kctl_api/__main__.py +5 -0
- kctl_api/cli.py +238 -0
- kctl_api/commands/__init__.py +1 -0
- kctl_api/commands/ai.py +250 -0
- kctl_api/commands/aliases.py +84 -0
- kctl_api/commands/apps.py +172 -0
- kctl_api/commands/auth.py +313 -0
- kctl_api/commands/automation.py +242 -0
- kctl_api/commands/build_cmd.py +87 -0
- kctl_api/commands/clean.py +182 -0
- kctl_api/commands/config_cmd.py +443 -0
- kctl_api/commands/dashboard.py +139 -0
- kctl_api/commands/db.py +599 -0
- kctl_api/commands/deploy.py +84 -0
- kctl_api/commands/deps.py +289 -0
- kctl_api/commands/dev.py +136 -0
- kctl_api/commands/docker_cmd.py +252 -0
- kctl_api/commands/doctor_cmd.py +286 -0
- kctl_api/commands/env.py +289 -0
- kctl_api/commands/files.py +250 -0
- kctl_api/commands/fmt_cmd.py +58 -0
- kctl_api/commands/health.py +479 -0
- kctl_api/commands/jobs.py +169 -0
- kctl_api/commands/lint_cmd.py +81 -0
- kctl_api/commands/logs.py +258 -0
- kctl_api/commands/marketplace.py +316 -0
- kctl_api/commands/monitor_cmd.py +243 -0
- kctl_api/commands/notifications.py +132 -0
- kctl_api/commands/odoo_proxy.py +182 -0
- kctl_api/commands/openapi.py +299 -0
- kctl_api/commands/perf.py +307 -0
- kctl_api/commands/rate_limit.py +223 -0
- kctl_api/commands/realtime.py +100 -0
- kctl_api/commands/redis_cmd.py +609 -0
- kctl_api/commands/routes_cmd.py +277 -0
- kctl_api/commands/saas.py +145 -0
- kctl_api/commands/scaffold.py +362 -0
- kctl_api/commands/security_cmd.py +350 -0
- kctl_api/commands/services.py +191 -0
- kctl_api/commands/shell.py +197 -0
- kctl_api/commands/skill_cmd.py +58 -0
- kctl_api/commands/streams.py +309 -0
- kctl_api/commands/stripe_cmd.py +105 -0
- kctl_api/commands/tenant_ai.py +169 -0
- kctl_api/commands/test_cmd.py +95 -0
- kctl_api/commands/users.py +302 -0
- kctl_api/commands/webhooks.py +56 -0
- kctl_api/commands/workflows.py +127 -0
- kctl_api/commands/ws.py +323 -0
- kctl_api/core/__init__.py +1 -0
- kctl_api/core/async_client.py +120 -0
- kctl_api/core/callbacks.py +88 -0
- kctl_api/core/client.py +190 -0
- kctl_api/core/config.py +260 -0
- kctl_api/core/db.py +65 -0
- kctl_api/core/exceptions.py +43 -0
- kctl_api/core/output.py +5 -0
- kctl_api/core/plugins.py +26 -0
- kctl_api/core/redis.py +35 -0
- kctl_api/core/resolve.py +47 -0
- kctl_api/core/utils.py +109 -0
- kctl_api-0.2.0.dist-info/METADATA +34 -0
- kctl_api-0.2.0.dist-info/RECORD +66 -0
- kctl_api-0.2.0.dist-info/WHEEL +4 -0
- kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Config management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Manage connection profiles, switch environments, and test connectivity.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_api.core.callbacks import AppContext
|
|
13
|
+
from kctl_api.core.config import (
|
|
14
|
+
CONFIG_FILE,
|
|
15
|
+
SERVICE_KEY,
|
|
16
|
+
ServiceConfig,
|
|
17
|
+
_is_service_scoped,
|
|
18
|
+
get_all_services_in_profile,
|
|
19
|
+
get_default_profile,
|
|
20
|
+
get_profile_names,
|
|
21
|
+
get_service_config,
|
|
22
|
+
load_raw_config,
|
|
23
|
+
remove_profile,
|
|
24
|
+
resolve_active_profile_name,
|
|
25
|
+
save_raw_config,
|
|
26
|
+
set_default_profile,
|
|
27
|
+
set_service_config,
|
|
28
|
+
)
|
|
29
|
+
from kctl_api.core.utils import mask_secret
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(name="config", help="Manage connection profiles and configuration.", no_args_is_help=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# init
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
@app.command()
|
|
38
|
+
def init(ctx: typer.Context) -> None:
|
|
39
|
+
"""Interactive setup wizard: prompt for URL, email, password; login to get JWT; save profile."""
|
|
40
|
+
actx: AppContext = ctx.obj
|
|
41
|
+
out = actx.output
|
|
42
|
+
|
|
43
|
+
out.header("kctl-api Setup Wizard")
|
|
44
|
+
|
|
45
|
+
profile_name = typer.prompt("Profile name", default="default")
|
|
46
|
+
url = typer.prompt("API URL (api-main)", default="http://localhost:8000")
|
|
47
|
+
ai_url = typer.prompt("AI URL (ai-main, optional)", default="")
|
|
48
|
+
email = typer.prompt("Email")
|
|
49
|
+
password = typer.prompt("Password", hide_input=True)
|
|
50
|
+
|
|
51
|
+
out.info(f"Authenticating against {url} ...")
|
|
52
|
+
|
|
53
|
+
from kctl_api.core.client import ApiClient
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
client = ApiClient(base_url=url)
|
|
57
|
+
tokens = client.login(email, password)
|
|
58
|
+
client.close()
|
|
59
|
+
except Exception as e:
|
|
60
|
+
out.error(f"Login failed: {e}")
|
|
61
|
+
raise typer.Exit(1) from None
|
|
62
|
+
|
|
63
|
+
api_key = tokens.get("access_token", "")
|
|
64
|
+
svc = ServiceConfig(url=url, ai_url=ai_url, api_key=api_key)
|
|
65
|
+
set_service_config(profile_name, svc)
|
|
66
|
+
set_default_profile(profile_name)
|
|
67
|
+
|
|
68
|
+
out.success(f"Profile '{profile_name}' created and set as default.")
|
|
69
|
+
out.info(f"Config saved to {CONFIG_FILE}")
|
|
70
|
+
|
|
71
|
+
if actx.json_mode:
|
|
72
|
+
out.raw_json(
|
|
73
|
+
{
|
|
74
|
+
"profile": profile_name,
|
|
75
|
+
"url": url,
|
|
76
|
+
"ai_url": ai_url,
|
|
77
|
+
"authenticated": True,
|
|
78
|
+
"config_file": str(CONFIG_FILE),
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# add
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
@app.command()
|
|
87
|
+
def add(
|
|
88
|
+
ctx: typer.Context,
|
|
89
|
+
name: Annotated[str, typer.Argument(help="Profile name to create.")],
|
|
90
|
+
url: Annotated[str, typer.Option("--url", help="API base URL.")] = "",
|
|
91
|
+
ai_url: Annotated[str, typer.Option("--ai-url", help="AI API base URL.")] = "",
|
|
92
|
+
api_key: Annotated[str, typer.Option("--api-key", help="API key or JWT token.")] = "",
|
|
93
|
+
database_url: Annotated[str, typer.Option("--database-url", help="PostgreSQL async URL.")] = "",
|
|
94
|
+
redis_url: Annotated[str, typer.Option("--redis-url", help="Redis URL.")] = "",
|
|
95
|
+
default: Annotated[bool, typer.Option("--default", help="Set as default profile.")] = False,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Add a new connection profile."""
|
|
98
|
+
actx: AppContext = ctx.obj
|
|
99
|
+
out = actx.output
|
|
100
|
+
|
|
101
|
+
existing = get_profile_names()
|
|
102
|
+
if name in existing:
|
|
103
|
+
out.warn(f"Profile '{name}' already exists and will be overwritten.")
|
|
104
|
+
|
|
105
|
+
svc = ServiceConfig(
|
|
106
|
+
url=url,
|
|
107
|
+
ai_url=ai_url,
|
|
108
|
+
api_key=api_key,
|
|
109
|
+
database_url=database_url,
|
|
110
|
+
redis_url=redis_url,
|
|
111
|
+
)
|
|
112
|
+
set_service_config(name, svc)
|
|
113
|
+
|
|
114
|
+
if default:
|
|
115
|
+
set_default_profile(name)
|
|
116
|
+
out.success(f"Profile '{name}' added and set as default.")
|
|
117
|
+
else:
|
|
118
|
+
out.success(f"Profile '{name}' added.")
|
|
119
|
+
|
|
120
|
+
if actx.json_mode:
|
|
121
|
+
out.raw_json({"profile": name, "default": default})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# use
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
@app.command()
|
|
128
|
+
def use(
|
|
129
|
+
ctx: typer.Context,
|
|
130
|
+
name: Annotated[str, typer.Argument(help="Profile name to activate.")],
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Switch the default profile."""
|
|
133
|
+
actx: AppContext = ctx.obj
|
|
134
|
+
out = actx.output
|
|
135
|
+
|
|
136
|
+
existing = get_profile_names()
|
|
137
|
+
if name not in existing:
|
|
138
|
+
out.error(f"Profile '{name}' does not exist. Available: {', '.join(existing) or '(none)'}")
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
|
|
141
|
+
set_default_profile(name)
|
|
142
|
+
out.success(f"Default profile set to '{name}'.")
|
|
143
|
+
|
|
144
|
+
if actx.json_mode:
|
|
145
|
+
out.raw_json({"default_profile": name})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# remove
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
@app.command(name="remove")
|
|
152
|
+
def remove_cmd(
|
|
153
|
+
ctx: typer.Context,
|
|
154
|
+
name: Annotated[str, typer.Argument(help="Profile name to delete.")],
|
|
155
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Delete a connection profile."""
|
|
158
|
+
actx: AppContext = ctx.obj
|
|
159
|
+
out = actx.output
|
|
160
|
+
|
|
161
|
+
existing = get_profile_names()
|
|
162
|
+
if name not in existing:
|
|
163
|
+
out.error(f"Profile '{name}' does not exist.")
|
|
164
|
+
raise typer.Exit(1)
|
|
165
|
+
|
|
166
|
+
if not force:
|
|
167
|
+
confirm = typer.confirm(f"Delete profile '{name}'?", default=False)
|
|
168
|
+
if not confirm:
|
|
169
|
+
out.info("Cancelled.")
|
|
170
|
+
raise typer.Exit(0)
|
|
171
|
+
|
|
172
|
+
remove_profile(name)
|
|
173
|
+
out.success(f"Profile '{name}' removed.")
|
|
174
|
+
|
|
175
|
+
if actx.json_mode:
|
|
176
|
+
out.raw_json({"removed": name})
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# test
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
@app.command()
|
|
183
|
+
def test(ctx: typer.Context) -> None:
|
|
184
|
+
"""Test current profile connectivity (GET /api/v1/health)."""
|
|
185
|
+
actx: AppContext = ctx.obj
|
|
186
|
+
out = actx.output
|
|
187
|
+
|
|
188
|
+
profile_name = resolve_active_profile_name(actx.profile)
|
|
189
|
+
svc = get_service_config(profile_name)
|
|
190
|
+
|
|
191
|
+
if not svc.url:
|
|
192
|
+
out.error(f"Profile '{profile_name}' has no URL configured. Run: kctl-api config add {profile_name} --url ...")
|
|
193
|
+
raise typer.Exit(1)
|
|
194
|
+
|
|
195
|
+
out.info(f"Testing connectivity to {svc.url} (profile: {profile_name}) ...")
|
|
196
|
+
|
|
197
|
+
from kctl_api.core.client import ApiClient
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
client = ApiClient(base_url=svc.url, api_key=svc.api_key)
|
|
201
|
+
healthy, details = client.health_check()
|
|
202
|
+
client.close()
|
|
203
|
+
except Exception as e:
|
|
204
|
+
out.error(f"Connection failed: {e}")
|
|
205
|
+
if actx.json_mode:
|
|
206
|
+
out.raw_json({"profile": profile_name, "url": svc.url, "healthy": False, "error": str(e)})
|
|
207
|
+
raise typer.Exit(1) from None
|
|
208
|
+
|
|
209
|
+
status = details.get("status", "unknown")
|
|
210
|
+
if healthy:
|
|
211
|
+
out.success(f"API is healthy (status: {status})")
|
|
212
|
+
else:
|
|
213
|
+
out.error(f"API returned unhealthy status: {status}")
|
|
214
|
+
|
|
215
|
+
if actx.json_mode:
|
|
216
|
+
out.raw_json({"profile": profile_name, "url": svc.url, "healthy": healthy, **details})
|
|
217
|
+
elif not healthy:
|
|
218
|
+
raise typer.Exit(1)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# current
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
@app.command()
|
|
225
|
+
def current(ctx: typer.Context) -> None:
|
|
226
|
+
"""Show active profile name and connection status."""
|
|
227
|
+
actx: AppContext = ctx.obj
|
|
228
|
+
out = actx.output
|
|
229
|
+
|
|
230
|
+
profile_name = resolve_active_profile_name(actx.profile)
|
|
231
|
+
svc = get_service_config(profile_name)
|
|
232
|
+
|
|
233
|
+
connected = False
|
|
234
|
+
status_text = "not tested"
|
|
235
|
+
if svc.url:
|
|
236
|
+
from kctl_api.core.client import ApiClient
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
client = ApiClient(base_url=svc.url, api_key=svc.api_key)
|
|
240
|
+
healthy, _details = client.health_check()
|
|
241
|
+
client.close()
|
|
242
|
+
connected = healthy
|
|
243
|
+
status_text = "[green]connected[/green]" if healthy else "[red]unreachable[/red]"
|
|
244
|
+
except Exception:
|
|
245
|
+
status_text = "[red]unreachable[/red]"
|
|
246
|
+
|
|
247
|
+
data = {
|
|
248
|
+
"profile": profile_name,
|
|
249
|
+
"url": svc.url or "(not set)",
|
|
250
|
+
"connected": connected,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
out.detail(
|
|
254
|
+
title="Active Profile",
|
|
255
|
+
sections=[
|
|
256
|
+
(
|
|
257
|
+
"Connection",
|
|
258
|
+
[
|
|
259
|
+
("Profile", profile_name),
|
|
260
|
+
("URL", svc.url or "(not set)"),
|
|
261
|
+
("Status", status_text),
|
|
262
|
+
("Default", str(profile_name == get_default_profile())),
|
|
263
|
+
],
|
|
264
|
+
),
|
|
265
|
+
],
|
|
266
|
+
data_for_json=data,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
# show
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
@app.command()
|
|
274
|
+
def show(ctx: typer.Context) -> None:
|
|
275
|
+
"""Show full configuration with masked secrets."""
|
|
276
|
+
actx: AppContext = ctx.obj
|
|
277
|
+
out = actx.output
|
|
278
|
+
|
|
279
|
+
profile_name = resolve_active_profile_name(actx.profile)
|
|
280
|
+
all_services = get_all_services_in_profile(profile_name)
|
|
281
|
+
|
|
282
|
+
sections: list[tuple[str, list[tuple[str, str]]]] = []
|
|
283
|
+
json_data: dict[str, object] = {"profile": profile_name, "config_file": str(CONFIG_FILE), "services": {}}
|
|
284
|
+
|
|
285
|
+
sections.append(
|
|
286
|
+
(
|
|
287
|
+
"General",
|
|
288
|
+
[
|
|
289
|
+
("Profile", profile_name),
|
|
290
|
+
("Config File", str(CONFIG_FILE)),
|
|
291
|
+
("Default Profile", get_default_profile()),
|
|
292
|
+
],
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
for svc_name, svc_data in all_services.items():
|
|
297
|
+
kvs: list[tuple[str, str]] = []
|
|
298
|
+
svc_json: dict[str, str] = {}
|
|
299
|
+
for key, value in svc_data.items():
|
|
300
|
+
display_val = str(value) if value else "(not set)"
|
|
301
|
+
if key in ("api_key", "database_url", "redis_url") and value:
|
|
302
|
+
display_val = mask_secret(str(value))
|
|
303
|
+
kvs.append((key, display_val))
|
|
304
|
+
svc_json[key] = (
|
|
305
|
+
mask_secret(str(value)) if key in ("api_key", "database_url", "redis_url") and value else str(value)
|
|
306
|
+
)
|
|
307
|
+
sections.append((f"Service: {svc_name}", kvs))
|
|
308
|
+
if isinstance(json_data["services"], dict):
|
|
309
|
+
json_data["services"][svc_name] = svc_json
|
|
310
|
+
|
|
311
|
+
out.detail(title=f"Config — {profile_name}", sections=sections, data_for_json=json_data)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
# set
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
@app.command(name="set")
|
|
318
|
+
def set_cmd(
|
|
319
|
+
ctx: typer.Context,
|
|
320
|
+
key: Annotated[str, typer.Argument(help="Config key: url, ai_url, api_key, database_url, redis_url.")],
|
|
321
|
+
value: Annotated[str, typer.Argument(help="Config value.")],
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Set a config value on the active profile."""
|
|
324
|
+
actx: AppContext = ctx.obj
|
|
325
|
+
out = actx.output
|
|
326
|
+
|
|
327
|
+
valid_keys = set(ServiceConfig.model_fields.keys())
|
|
328
|
+
if key not in valid_keys:
|
|
329
|
+
out.error(f"Invalid key '{key}'. Valid keys: {', '.join(sorted(valid_keys))}")
|
|
330
|
+
raise typer.Exit(1)
|
|
331
|
+
|
|
332
|
+
profile_name = resolve_active_profile_name(actx.profile)
|
|
333
|
+
svc = get_service_config(profile_name)
|
|
334
|
+
svc_dict = svc.model_dump()
|
|
335
|
+
svc_dict[key] = value
|
|
336
|
+
updated = ServiceConfig(**svc_dict)
|
|
337
|
+
set_service_config(profile_name, updated)
|
|
338
|
+
|
|
339
|
+
display_val = mask_secret(value) if key in ("api_key", "database_url", "redis_url") else value
|
|
340
|
+
out.success(f"Set {key}={display_val} on profile '{profile_name}'.")
|
|
341
|
+
|
|
342
|
+
if actx.json_mode:
|
|
343
|
+
out.raw_json({"profile": profile_name, "key": key, "value": display_val})
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
# profiles
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
@app.command()
|
|
350
|
+
def profiles(ctx: typer.Context) -> None:
|
|
351
|
+
"""List all connection profiles."""
|
|
352
|
+
actx: AppContext = ctx.obj
|
|
353
|
+
out = actx.output
|
|
354
|
+
|
|
355
|
+
names = get_profile_names()
|
|
356
|
+
default = get_default_profile()
|
|
357
|
+
|
|
358
|
+
if not names:
|
|
359
|
+
out.info("No profiles configured. Run: kctl-api config init")
|
|
360
|
+
if actx.json_mode:
|
|
361
|
+
out.raw_json([])
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
rows: list[list[str]] = []
|
|
365
|
+
json_data: list[dict[str, object]] = []
|
|
366
|
+
|
|
367
|
+
for name in names:
|
|
368
|
+
svc = get_service_config(name)
|
|
369
|
+
is_default = name == default
|
|
370
|
+
marker = "[green]*[/green]" if is_default else ""
|
|
371
|
+
services = get_all_services_in_profile(name)
|
|
372
|
+
svc_names = ", ".join(sorted(services.keys())) if services else "(empty)"
|
|
373
|
+
|
|
374
|
+
rows.append(
|
|
375
|
+
[
|
|
376
|
+
f"{marker} {name}" if marker else name,
|
|
377
|
+
svc.url or "(not set)",
|
|
378
|
+
svc_names,
|
|
379
|
+
"yes" if is_default else "",
|
|
380
|
+
]
|
|
381
|
+
)
|
|
382
|
+
json_data.append(
|
|
383
|
+
{
|
|
384
|
+
"name": name,
|
|
385
|
+
"url": svc.url,
|
|
386
|
+
"services": list(services.keys()),
|
|
387
|
+
"default": is_default,
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
out.table(
|
|
392
|
+
title="Profiles",
|
|
393
|
+
columns=[
|
|
394
|
+
("Name", "bold"),
|
|
395
|
+
("URL", ""),
|
|
396
|
+
("Services", "dim"),
|
|
397
|
+
("Default", "green"),
|
|
398
|
+
],
|
|
399
|
+
rows=rows,
|
|
400
|
+
data_for_json=json_data,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
# migrate
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
@app.command()
|
|
408
|
+
def migrate(ctx: typer.Context) -> None:
|
|
409
|
+
"""Migrate from old flat format to service-scoped YAML."""
|
|
410
|
+
actx: AppContext = ctx.obj
|
|
411
|
+
out = actx.output
|
|
412
|
+
|
|
413
|
+
raw = load_raw_config()
|
|
414
|
+
profiles_data = raw.get("profiles", {})
|
|
415
|
+
|
|
416
|
+
if not profiles_data:
|
|
417
|
+
out.info("No profiles found. Nothing to migrate.")
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
migrated_count = 0
|
|
421
|
+
for _name, profile_data in profiles_data.items():
|
|
422
|
+
if not isinstance(profile_data, dict):
|
|
423
|
+
continue
|
|
424
|
+
if _is_service_scoped(profile_data):
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
# Flat format detected — wrap under SERVICE_KEY
|
|
428
|
+
old_data = dict(profile_data)
|
|
429
|
+
profile_data.clear()
|
|
430
|
+
profile_data[SERVICE_KEY] = old_data
|
|
431
|
+
migrated_count += 1
|
|
432
|
+
|
|
433
|
+
if migrated_count == 0:
|
|
434
|
+
out.info("All profiles already use service-scoped format. Nothing to migrate.")
|
|
435
|
+
if actx.json_mode:
|
|
436
|
+
out.raw_json({"migrated": 0})
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
save_raw_config(raw)
|
|
440
|
+
out.success(f"Migrated {migrated_count} profile(s) to service-scoped format.")
|
|
441
|
+
|
|
442
|
+
if actx.json_mode:
|
|
443
|
+
out.raw_json({"migrated": migrated_count, "profiles": list(profiles_data.keys())})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Dashboard and overview commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Aggregate health, job, and service information.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextlib
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from kctl_api.core.callbacks import AppContext
|
|
14
|
+
from kctl_api.core.utils import service_status_color
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="dashboard", help="Dashboard — overview, live monitoring.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# overview
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
@app.command()
|
|
23
|
+
def overview(ctx: typer.Context) -> None:
|
|
24
|
+
"""Show a combined overview: health + jobs + recent activity."""
|
|
25
|
+
actx: AppContext = ctx.obj
|
|
26
|
+
out = actx.output
|
|
27
|
+
|
|
28
|
+
# Gather health checks
|
|
29
|
+
health_results: list[dict] = []
|
|
30
|
+
|
|
31
|
+
# API health
|
|
32
|
+
try:
|
|
33
|
+
healthy, details = actx.client.health_check()
|
|
34
|
+
health_results.append({"service": "api-main", "healthy": healthy, "status": details.get("status", "unknown")})
|
|
35
|
+
except Exception as e:
|
|
36
|
+
health_results.append({"service": "api-main", "healthy": False, "status": "error", "error": str(e)})
|
|
37
|
+
|
|
38
|
+
# AI health
|
|
39
|
+
try:
|
|
40
|
+
ai_data = actx.ai_client.get("/api/v1/ai/health")
|
|
41
|
+
ai_ok = ai_data.get("status") == "ok" if ai_data else False
|
|
42
|
+
health_results.append({"service": "ai-main", "healthy": ai_ok, "status": ai_data.get("status", "unknown")})
|
|
43
|
+
except Exception as e:
|
|
44
|
+
health_results.append({"service": "ai-main", "healthy": False, "status": "error", "error": str(e)})
|
|
45
|
+
|
|
46
|
+
# DB health
|
|
47
|
+
db_url = actx.database_url
|
|
48
|
+
if db_url:
|
|
49
|
+
|
|
50
|
+
async def _check_db() -> dict:
|
|
51
|
+
from kctl_api.core.db import dispose_engine, execute_query
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
rows = await execute_query(db_url, "SELECT 1 AS ping")
|
|
55
|
+
await dispose_engine()
|
|
56
|
+
ok = len(rows) > 0 and rows[0].get("ping") == 1
|
|
57
|
+
return {"service": "database", "healthy": ok, "status": "ok" if ok else "error"}
|
|
58
|
+
except Exception as e:
|
|
59
|
+
with contextlib.suppress(Exception):
|
|
60
|
+
await dispose_engine()
|
|
61
|
+
return {"service": "database", "healthy": False, "status": "error", "error": str(e)}
|
|
62
|
+
|
|
63
|
+
health_results.append(asyncio.run(_check_db()))
|
|
64
|
+
|
|
65
|
+
# Redis health
|
|
66
|
+
redis_url = actx.redis_url
|
|
67
|
+
if redis_url:
|
|
68
|
+
|
|
69
|
+
async def _check_redis() -> dict:
|
|
70
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
client = get_redis(redis_url)
|
|
74
|
+
pong = await client.ping()
|
|
75
|
+
await close_redis()
|
|
76
|
+
return {"service": "redis", "healthy": bool(pong), "status": "ok" if pong else "error"}
|
|
77
|
+
except Exception as e:
|
|
78
|
+
with contextlib.suppress(Exception):
|
|
79
|
+
await close_redis()
|
|
80
|
+
return {"service": "redis", "healthy": False, "status": "error", "error": str(e)}
|
|
81
|
+
|
|
82
|
+
health_results.append(asyncio.run(_check_redis()))
|
|
83
|
+
|
|
84
|
+
# Display health table
|
|
85
|
+
health_rows: list[list[str]] = []
|
|
86
|
+
for r in health_results:
|
|
87
|
+
health_rows.append(
|
|
88
|
+
[
|
|
89
|
+
r["service"],
|
|
90
|
+
service_status_color(r.get("status", "unknown")),
|
|
91
|
+
"[green]yes[/green]" if r["healthy"] else "[red]no[/red]",
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
out.table(
|
|
96
|
+
title="Service Health",
|
|
97
|
+
columns=[
|
|
98
|
+
("Service", "bold"),
|
|
99
|
+
("Status", ""),
|
|
100
|
+
("Healthy", ""),
|
|
101
|
+
],
|
|
102
|
+
rows=health_rows,
|
|
103
|
+
data_for_json=health_results,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Try to show job overview
|
|
107
|
+
try:
|
|
108
|
+
jobs_data = actx.client.get("/api/v1/jobs/queues/overview")
|
|
109
|
+
if jobs_data:
|
|
110
|
+
out.header("Job Queues")
|
|
111
|
+
if isinstance(jobs_data, list):
|
|
112
|
+
for q in jobs_data:
|
|
113
|
+
out.kv(q.get("name", "queue"), f"queued={q.get('queued', 0)} active={q.get('active', 0)}")
|
|
114
|
+
elif isinstance(jobs_data, dict):
|
|
115
|
+
for k, v in jobs_data.items():
|
|
116
|
+
out.kv(k, str(v))
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# Summary
|
|
121
|
+
total = len(health_results)
|
|
122
|
+
healthy_count = sum(1 for r in health_results if r["healthy"])
|
|
123
|
+
if healthy_count == total:
|
|
124
|
+
out.success(f"All {total} services healthy.")
|
|
125
|
+
else:
|
|
126
|
+
out.warn(f"{healthy_count}/{total} services healthy.")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# live
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
@app.command()
|
|
133
|
+
def live(
|
|
134
|
+
ctx: typer.Context,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Live dashboard with auto-refresh (not yet implemented)."""
|
|
137
|
+
actx: AppContext = ctx.obj
|
|
138
|
+
out = actx.output
|
|
139
|
+
out.info("Not yet implemented. Will show a live Rich dashboard with auto-refresh.")
|