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.
Files changed (66) hide show
  1. kctl_api/__init__.py +3 -0
  2. kctl_api/__main__.py +5 -0
  3. kctl_api/cli.py +238 -0
  4. kctl_api/commands/__init__.py +1 -0
  5. kctl_api/commands/ai.py +250 -0
  6. kctl_api/commands/aliases.py +84 -0
  7. kctl_api/commands/apps.py +172 -0
  8. kctl_api/commands/auth.py +313 -0
  9. kctl_api/commands/automation.py +242 -0
  10. kctl_api/commands/build_cmd.py +87 -0
  11. kctl_api/commands/clean.py +182 -0
  12. kctl_api/commands/config_cmd.py +443 -0
  13. kctl_api/commands/dashboard.py +139 -0
  14. kctl_api/commands/db.py +599 -0
  15. kctl_api/commands/deploy.py +84 -0
  16. kctl_api/commands/deps.py +289 -0
  17. kctl_api/commands/dev.py +136 -0
  18. kctl_api/commands/docker_cmd.py +252 -0
  19. kctl_api/commands/doctor_cmd.py +286 -0
  20. kctl_api/commands/env.py +289 -0
  21. kctl_api/commands/files.py +250 -0
  22. kctl_api/commands/fmt_cmd.py +58 -0
  23. kctl_api/commands/health.py +479 -0
  24. kctl_api/commands/jobs.py +169 -0
  25. kctl_api/commands/lint_cmd.py +81 -0
  26. kctl_api/commands/logs.py +258 -0
  27. kctl_api/commands/marketplace.py +316 -0
  28. kctl_api/commands/monitor_cmd.py +243 -0
  29. kctl_api/commands/notifications.py +132 -0
  30. kctl_api/commands/odoo_proxy.py +182 -0
  31. kctl_api/commands/openapi.py +299 -0
  32. kctl_api/commands/perf.py +307 -0
  33. kctl_api/commands/rate_limit.py +223 -0
  34. kctl_api/commands/realtime.py +100 -0
  35. kctl_api/commands/redis_cmd.py +609 -0
  36. kctl_api/commands/routes_cmd.py +277 -0
  37. kctl_api/commands/saas.py +145 -0
  38. kctl_api/commands/scaffold.py +362 -0
  39. kctl_api/commands/security_cmd.py +350 -0
  40. kctl_api/commands/services.py +191 -0
  41. kctl_api/commands/shell.py +197 -0
  42. kctl_api/commands/skill_cmd.py +58 -0
  43. kctl_api/commands/streams.py +309 -0
  44. kctl_api/commands/stripe_cmd.py +105 -0
  45. kctl_api/commands/tenant_ai.py +169 -0
  46. kctl_api/commands/test_cmd.py +95 -0
  47. kctl_api/commands/users.py +302 -0
  48. kctl_api/commands/webhooks.py +56 -0
  49. kctl_api/commands/workflows.py +127 -0
  50. kctl_api/commands/ws.py +323 -0
  51. kctl_api/core/__init__.py +1 -0
  52. kctl_api/core/async_client.py +120 -0
  53. kctl_api/core/callbacks.py +88 -0
  54. kctl_api/core/client.py +190 -0
  55. kctl_api/core/config.py +260 -0
  56. kctl_api/core/db.py +65 -0
  57. kctl_api/core/exceptions.py +43 -0
  58. kctl_api/core/output.py +5 -0
  59. kctl_api/core/plugins.py +26 -0
  60. kctl_api/core/redis.py +35 -0
  61. kctl_api/core/resolve.py +47 -0
  62. kctl_api/core/utils.py +109 -0
  63. kctl_api-0.2.0.dist-info/METADATA +34 -0
  64. kctl_api-0.2.0.dist-info/RECORD +66 -0
  65. kctl_api-0.2.0.dist-info/WHEEL +4 -0
  66. kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,599 @@
1
+ """Database management commands for kctl-api.
2
+
3
+ Direct DB queries, Alembic migrations, psql shell, seeding, and analysis.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import subprocess
10
+ import sys
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from kctl_api.core.callbacks import AppContext
16
+ from kctl_api.core.utils import find_project_root
17
+
18
+ app = typer.Typer(name="db", help="Database management — tables, migrations, queries, shell.", no_args_is_help=True)
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # tables
23
+ # ---------------------------------------------------------------------------
24
+ @app.command()
25
+ def tables(ctx: typer.Context) -> None:
26
+ """List database tables via direct async query."""
27
+ actx: AppContext = ctx.obj
28
+ out = actx.output
29
+
30
+ db_url = actx.database_url
31
+ if not db_url:
32
+ out.error("No database_url configured.")
33
+ raise typer.Exit(1)
34
+
35
+ async def _run() -> list[dict]:
36
+ from kctl_api.core.db import dispose_engine, execute_query
37
+
38
+ try:
39
+ rows = await execute_query(
40
+ db_url,
41
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name",
42
+ )
43
+ return rows
44
+ finally:
45
+ await dispose_engine()
46
+
47
+ try:
48
+ rows = asyncio.run(_run())
49
+ except ImportError as e:
50
+ out.error(str(e))
51
+ raise typer.Exit(1) from None
52
+ except Exception as e:
53
+ out.error(f"Query failed: {e}")
54
+ raise typer.Exit(1) from None
55
+
56
+ table_rows: list[list[str]] = [[r.get("table_name", "")] for r in rows]
57
+
58
+ out.table(
59
+ title=f"Database Tables ({len(rows)})",
60
+ columns=[("Table", "bold")],
61
+ rows=table_rows,
62
+ data_for_json=rows,
63
+ )
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # count
68
+ # ---------------------------------------------------------------------------
69
+ @app.command()
70
+ def count(
71
+ ctx: typer.Context,
72
+ table: Annotated[str, typer.Argument(help="Table name.")],
73
+ ) -> None:
74
+ """Count rows in a table via direct async query."""
75
+ actx: AppContext = ctx.obj
76
+ out = actx.output
77
+
78
+ db_url = actx.database_url
79
+ if not db_url:
80
+ out.error("No database_url configured.")
81
+ raise typer.Exit(1)
82
+
83
+ async def _run() -> list[dict]:
84
+ from kctl_api.core.db import dispose_engine, execute_query, validate_identifier
85
+
86
+ try:
87
+ safe_table = validate_identifier(table)
88
+ return await execute_query(db_url, f"SELECT COUNT(*) AS cnt FROM {safe_table}")
89
+ finally:
90
+ await dispose_engine()
91
+
92
+ try:
93
+ rows = asyncio.run(_run())
94
+ except Exception as e:
95
+ out.error(f"Query failed: {e}")
96
+ raise typer.Exit(1) from None
97
+
98
+ cnt = rows[0].get("cnt", 0) if rows else 0
99
+ out.success(f"{table}: {cnt} rows")
100
+ if actx.json_mode:
101
+ out.raw_json({"table": table, "count": cnt})
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # migrate
106
+ # ---------------------------------------------------------------------------
107
+ @app.command()
108
+ def migrate(ctx: typer.Context) -> None:
109
+ """Run Alembic upgrade head via scripts/db migrate."""
110
+ actx: AppContext = ctx.obj
111
+ out = actx.output
112
+
113
+ root = find_project_root()
114
+ script = root / "scripts" / "db"
115
+
116
+ out.info("Running database migration ...")
117
+ result = subprocess.run([str(script), "migrate"], cwd=str(root), capture_output=False)
118
+ if result.returncode != 0:
119
+ out.error("Migration failed.")
120
+ raise typer.Exit(result.returncode)
121
+ out.success("Migration complete.")
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # generate
126
+ # ---------------------------------------------------------------------------
127
+ @app.command()
128
+ def generate(
129
+ ctx: typer.Context,
130
+ message: Annotated[str, typer.Argument(help="Migration revision message.")],
131
+ ) -> None:
132
+ """Generate a new Alembic migration via scripts/db generate."""
133
+ actx: AppContext = ctx.obj
134
+ out = actx.output
135
+
136
+ root = find_project_root()
137
+ script = root / "scripts" / "db"
138
+
139
+ out.info(f"Generating migration: {message}")
140
+ result = subprocess.run([str(script), "generate", message], cwd=str(root), capture_output=False)
141
+ if result.returncode != 0:
142
+ out.error("Generation failed.")
143
+ raise typer.Exit(result.returncode)
144
+ out.success("Migration generated.")
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # history
149
+ # ---------------------------------------------------------------------------
150
+ @app.command()
151
+ def history(ctx: typer.Context) -> None:
152
+ """Show Alembic migration history via scripts/db history."""
153
+ root = find_project_root()
154
+ script = root / "scripts" / "db"
155
+ subprocess.run([str(script), "history"], cwd=str(root), capture_output=False)
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # downgrade
160
+ # ---------------------------------------------------------------------------
161
+ @app.command()
162
+ def downgrade(
163
+ ctx: typer.Context,
164
+ revision: Annotated[str, typer.Argument(help="Target revision (or '-1' for one step back).")] = "-1",
165
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
166
+ ) -> None:
167
+ """Downgrade database to a previous migration revision."""
168
+ actx: AppContext = ctx.obj
169
+ out = actx.output
170
+
171
+ if not force:
172
+ confirm = typer.confirm(f"Downgrade to revision '{revision}'?", default=False)
173
+ if not confirm:
174
+ out.info("Cancelled.")
175
+ raise typer.Exit(0)
176
+
177
+ root = find_project_root()
178
+ script = root / "scripts" / "db"
179
+
180
+ out.info(f"Downgrading to {revision} ...")
181
+ result = subprocess.run([str(script), "downgrade", revision], cwd=str(root), capture_output=False)
182
+ if result.returncode != 0:
183
+ out.error("Downgrade failed.")
184
+ raise typer.Exit(result.returncode)
185
+ out.success("Downgrade complete.")
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # shell
190
+ # ---------------------------------------------------------------------------
191
+ @app.command()
192
+ def shell(ctx: typer.Context) -> None:
193
+ """Open an interactive psql shell to the configured database."""
194
+ actx: AppContext = ctx.obj
195
+ out = actx.output
196
+
197
+ db_url = actx.database_url
198
+ if not db_url:
199
+ out.error("No database_url configured.")
200
+ raise typer.Exit(1)
201
+
202
+ # Convert async URL to sync for psql
203
+ psql_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
204
+
205
+ out.info("Opening psql shell ...")
206
+ result = subprocess.run(["psql", psql_url], capture_output=False)
207
+ sys.exit(result.returncode)
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # query
212
+ # ---------------------------------------------------------------------------
213
+ @app.command()
214
+ def query(
215
+ ctx: typer.Context,
216
+ sql: Annotated[str, typer.Argument(help="SQL query to execute.")],
217
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation for write queries.")] = False,
218
+ ) -> None:
219
+ """Execute a raw SQL query via async engine."""
220
+ actx: AppContext = ctx.obj
221
+ out = actx.output
222
+
223
+ db_url = actx.database_url
224
+ if not db_url:
225
+ out.error("No database_url configured.")
226
+ raise typer.Exit(1)
227
+
228
+ # Warn on write queries
229
+ sql_upper = sql.strip().upper()
230
+ is_write = any(sql_upper.startswith(kw) for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"))
231
+ if is_write and not force:
232
+ confirm = typer.confirm("This looks like a write query. Proceed?", default=False)
233
+ if not confirm:
234
+ out.info("Cancelled.")
235
+ raise typer.Exit(0)
236
+
237
+ async def _run() -> list[dict]:
238
+ from kctl_api.core.db import dispose_engine, execute_query
239
+
240
+ try:
241
+ return await execute_query(db_url, sql)
242
+ finally:
243
+ await dispose_engine()
244
+
245
+ try:
246
+ rows = asyncio.run(_run())
247
+ except Exception as e:
248
+ out.error(f"Query failed: {e}")
249
+ raise typer.Exit(1) from None
250
+
251
+ if not rows:
252
+ out.info("Query returned no rows.")
253
+ if actx.json_mode:
254
+ out.raw_json([])
255
+ return
256
+
257
+ # Auto-detect columns from first row
258
+ cols = list(rows[0].keys())
259
+ table_rows = [[str(r.get(c, "")) for c in cols] for r in rows]
260
+
261
+ out.table(
262
+ title=f"Query Results ({len(rows)} rows)",
263
+ columns=[(c, "") for c in cols],
264
+ rows=table_rows,
265
+ data_for_json=rows,
266
+ )
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # seed
271
+ # ---------------------------------------------------------------------------
272
+ @app.command()
273
+ def seed(
274
+ ctx: typer.Context,
275
+ app_name: Annotated[str | None, typer.Argument(help="App name to seed (default: api-main).")] = None,
276
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
277
+ ) -> None:
278
+ """Seed the database with test/dev data via scripts/db seed."""
279
+ actx: AppContext = ctx.obj
280
+ out = actx.output
281
+
282
+ if not force:
283
+ confirm = typer.confirm("Seed the database with test data?", default=False)
284
+ if not confirm:
285
+ out.info("Cancelled.")
286
+ raise typer.Exit(0)
287
+
288
+ root = find_project_root()
289
+ script = root / "scripts" / "db"
290
+ cmd = [str(script), "seed"]
291
+ if app_name:
292
+ cmd.append(app_name)
293
+
294
+ out.info(f"Seeding database{f' ({app_name})' if app_name else ''} ...")
295
+ result = subprocess.run(cmd, cwd=str(root), capture_output=False)
296
+ if result.returncode != 0:
297
+ out.error("Seeding failed.")
298
+ raise typer.Exit(result.returncode)
299
+ out.success("Database seeded.")
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # reset
304
+ # ---------------------------------------------------------------------------
305
+ @app.command()
306
+ def reset(
307
+ ctx: typer.Context,
308
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
309
+ skip_seed: Annotated[bool, typer.Option("--no-seed", help="Skip seeding after reset.")] = False,
310
+ ) -> None:
311
+ """Drop + recreate + migrate + seed the database."""
312
+ actx: AppContext = ctx.obj
313
+ out = actx.output
314
+
315
+ if not force:
316
+ confirm = typer.confirm(
317
+ "[red]WARNING[/red]: This will DROP and recreate the database. Proceed?",
318
+ default=False,
319
+ )
320
+ if not confirm:
321
+ out.info("Cancelled.")
322
+ raise typer.Exit(0)
323
+
324
+ root = find_project_root()
325
+ script = root / "scripts" / "db"
326
+
327
+ # Drop and recreate
328
+ out.info("Dropping and recreating database ...")
329
+ result = subprocess.run([str(script), "reset"], cwd=str(root), capture_output=False)
330
+ if result.returncode != 0:
331
+ # Try migrate as fallback
332
+ out.warn("Reset script not found, running migrate ...")
333
+ result = subprocess.run([str(script), "migrate"], cwd=str(root), capture_output=False)
334
+
335
+ if result.returncode != 0:
336
+ out.error("Reset failed.")
337
+ raise typer.Exit(result.returncode)
338
+
339
+ if not skip_seed:
340
+ out.info("Seeding database ...")
341
+ subprocess.run([str(script), "seed"], cwd=str(root), capture_output=False)
342
+
343
+ out.success("Database reset complete.")
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # check-safety
348
+ # ---------------------------------------------------------------------------
349
+ @app.command(name="check-safety")
350
+ def check_safety(ctx: typer.Context) -> None:
351
+ """Analyze pending Alembic migrations for destructive operations."""
352
+ actx: AppContext = ctx.obj
353
+ out = actx.output
354
+
355
+ root = find_project_root()
356
+
357
+ # Find migration files
358
+
359
+ migration_dirs = list(root.rglob("versions"))
360
+ if not migration_dirs:
361
+ out.warn("No Alembic versions directory found.")
362
+ raise typer.Exit(1)
363
+
364
+ destructive_patterns = [
365
+ ("drop_table", "Table deletion"),
366
+ ("drop_column", "Column deletion"),
367
+ ("drop_index", "Index deletion"),
368
+ ("drop_constraint", "Constraint deletion"),
369
+ ("execute.*DROP", "Raw DROP statement"),
370
+ ("execute.*TRUNCATE", "TRUNCATE statement"),
371
+ ("execute.*DELETE", "DELETE without WHERE"),
372
+ ]
373
+
374
+ import re
375
+
376
+ issues: list[dict] = []
377
+ for versions_dir in migration_dirs:
378
+ for migration_file in sorted(versions_dir.glob("*.py")):
379
+ content = migration_file.read_text(encoding="utf-8", errors="ignore")
380
+ # Only check upgrade() function
381
+ upgrade_match = re.search(r"def upgrade\(\):(.*?)(?=def downgrade|\Z)", content, re.DOTALL)
382
+ if not upgrade_match:
383
+ continue
384
+ upgrade_body = upgrade_match.group(1)
385
+
386
+ for pattern, label in destructive_patterns:
387
+ if re.search(pattern, upgrade_body, re.IGNORECASE):
388
+ issues.append(
389
+ {
390
+ "file": migration_file.name,
391
+ "operation": label,
392
+ "severity": "WARNING",
393
+ }
394
+ )
395
+
396
+ if not issues:
397
+ out.success("No destructive operations found in pending migrations.")
398
+ return
399
+
400
+ rows = [[i["file"], i["operation"], i["severity"]] for i in issues]
401
+ out.table(
402
+ title=f"Destructive Migration Operations ({len(issues)})",
403
+ columns=[("File", "bold"), ("Operation", "red"), ("Severity", "yellow")],
404
+ rows=rows,
405
+ data_for_json=issues,
406
+ )
407
+ out.warn("Review these migrations carefully before applying to production.")
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # size
412
+ # ---------------------------------------------------------------------------
413
+ @app.command()
414
+ def size(ctx: typer.Context) -> None:
415
+ """Show database size per table."""
416
+ actx: AppContext = ctx.obj
417
+ out = actx.output
418
+
419
+ db_url = actx.database_url
420
+ if not db_url:
421
+ out.error("No database_url configured.")
422
+ raise typer.Exit(1)
423
+
424
+ async def _run() -> list[dict]:
425
+ from kctl_api.core.db import dispose_engine, execute_query
426
+
427
+ try:
428
+ return await execute_query(
429
+ db_url,
430
+ """
431
+ SELECT
432
+ table_name,
433
+ pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS total_size,
434
+ pg_size_pretty(pg_relation_size(quote_ident(table_name))) AS table_size,
435
+ pg_size_pretty(
436
+ pg_total_relation_size(quote_ident(table_name))
437
+ - pg_relation_size(quote_ident(table_name))
438
+ ) AS index_size,
439
+ (SELECT COUNT(*) FROM information_schema.columns
440
+ WHERE table_name = t.table_name
441
+ AND table_schema = 'public') AS columns
442
+ FROM information_schema.tables t
443
+ WHERE table_schema = 'public'
444
+ ORDER BY pg_total_relation_size(quote_ident(table_name)) DESC
445
+ """,
446
+ )
447
+ finally:
448
+ await dispose_engine()
449
+
450
+ try:
451
+ rows_data = asyncio.run(_run())
452
+ except Exception as e:
453
+ out.error(f"Query failed: {e}")
454
+ raise typer.Exit(1) from None
455
+
456
+ rows = [
457
+ [r.get("table_name", ""), r.get("total_size", ""), r.get("table_size", ""), r.get("index_size", "")]
458
+ for r in rows_data
459
+ ]
460
+ out.table(
461
+ title="Database Table Sizes",
462
+ columns=[("Table", "bold"), ("Total", ""), ("Data", ""), ("Indexes", "")],
463
+ rows=rows,
464
+ data_for_json=rows_data,
465
+ )
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # connections
470
+ # ---------------------------------------------------------------------------
471
+ @app.command()
472
+ def connections(ctx: typer.Context) -> None:
473
+ """Show active database connections."""
474
+ actx: AppContext = ctx.obj
475
+ out = actx.output
476
+
477
+ db_url = actx.database_url
478
+ if not db_url:
479
+ out.error("No database_url configured.")
480
+ raise typer.Exit(1)
481
+
482
+ async def _run() -> list[dict]:
483
+ from kctl_api.core.db import dispose_engine, execute_query
484
+
485
+ try:
486
+ return await execute_query(
487
+ db_url,
488
+ """
489
+ SELECT
490
+ pid,
491
+ usename AS username,
492
+ application_name,
493
+ client_addr,
494
+ state,
495
+ wait_event_type,
496
+ wait_event,
497
+ LEFT(query, 80) AS query_preview
498
+ FROM pg_stat_activity
499
+ WHERE datname = current_database()
500
+ AND pid <> pg_backend_pid()
501
+ ORDER BY state, pid
502
+ """,
503
+ )
504
+ finally:
505
+ await dispose_engine()
506
+
507
+ try:
508
+ rows_data = asyncio.run(_run())
509
+ except Exception as e:
510
+ out.error(f"Query failed: {e}")
511
+ raise typer.Exit(1) from None
512
+
513
+ rows = [
514
+ [
515
+ str(r.get("pid", "")),
516
+ r.get("username", ""),
517
+ r.get("application_name", ""),
518
+ r.get("state", ""),
519
+ r.get("query_preview", "")[:60],
520
+ ]
521
+ for r in rows_data
522
+ ]
523
+ out.table(
524
+ title=f"Active DB Connections ({len(rows_data)})",
525
+ columns=[("PID", ""), ("User", "bold"), ("App", ""), ("State", ""), ("Query", "dim")],
526
+ rows=rows,
527
+ data_for_json=rows_data,
528
+ )
529
+
530
+
531
+ # ---------------------------------------------------------------------------
532
+ # slow-queries
533
+ # ---------------------------------------------------------------------------
534
+ @app.command(name="slow-queries")
535
+ def slow_queries(
536
+ ctx: typer.Context,
537
+ min_ms: Annotated[int, typer.Option("--min-ms", help="Minimum mean time in ms to include.")] = 100,
538
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max queries to show.")] = 20,
539
+ ) -> None:
540
+ """Show slow queries from pg_stat_statements (requires pg_stat_statements extension)."""
541
+ actx: AppContext = ctx.obj
542
+ out = actx.output
543
+
544
+ db_url = actx.database_url
545
+ if not db_url:
546
+ out.error("No database_url configured.")
547
+ raise typer.Exit(1)
548
+
549
+ async def _run() -> list[dict]:
550
+ from kctl_api.core.db import dispose_engine, execute_query
551
+
552
+ try:
553
+ return await execute_query(
554
+ db_url,
555
+ f"""
556
+ SELECT
557
+ ROUND(mean_exec_time::numeric, 2) AS mean_ms,
558
+ calls,
559
+ ROUND(total_exec_time::numeric, 2) AS total_ms,
560
+ LEFT(query, 100) AS query_preview
561
+ FROM pg_stat_statements
562
+ WHERE mean_exec_time >= {min_ms}
563
+ ORDER BY mean_exec_time DESC
564
+ LIMIT {limit}
565
+ """,
566
+ )
567
+ finally:
568
+ await dispose_engine()
569
+
570
+ try:
571
+ rows_data = asyncio.run(_run())
572
+ except Exception as e:
573
+ if "pg_stat_statements" in str(e):
574
+ out.warn("pg_stat_statements extension not enabled.")
575
+ out.info("Enable it: CREATE EXTENSION IF NOT EXISTS pg_stat_statements;")
576
+ out.info("Also add to postgresql.conf: shared_preload_libraries = 'pg_stat_statements'")
577
+ raise typer.Exit(1) from None
578
+ out.error(f"Query failed: {e}")
579
+ raise typer.Exit(1) from None
580
+
581
+ if not rows_data:
582
+ out.success(f"No queries with mean time >= {min_ms}ms found.")
583
+ return
584
+
585
+ rows = [
586
+ [
587
+ f"{r.get('mean_ms', 0)}ms",
588
+ str(r.get("calls", "")),
589
+ f"{r.get('total_ms', 0)}ms",
590
+ r.get("query_preview", "")[:80],
591
+ ]
592
+ for r in rows_data
593
+ ]
594
+ out.table(
595
+ title=f"Slow Queries (mean >= {min_ms}ms)",
596
+ columns=[("Mean", "bold"), ("Calls", ""), ("Total", ""), ("Query", "dim")],
597
+ rows=rows,
598
+ data_for_json=rows_data,
599
+ )
@@ -0,0 +1,84 @@
1
+ """Deployment management commands for kctl-api.
2
+
3
+ Status, logs, restart, and rollback via Dokploy API (stubs).
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
+
14
+ app = typer.Typer(name="deploy", help="Deployment management — status, logs, restart, rollback.", no_args_is_help=True)
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # status
19
+ # ---------------------------------------------------------------------------
20
+ @app.command()
21
+ def status(
22
+ ctx: typer.Context,
23
+ app_name: Annotated[str | None, typer.Argument(help="App name (all if omitted).")] = None,
24
+ ) -> None:
25
+ """Show deployment status (will use Dokploy API)."""
26
+ actx: AppContext = ctx.obj
27
+ out = actx.output
28
+ out.info(f"Not yet implemented. Will query Dokploy API for: {app_name or 'all apps'}")
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # logs
33
+ # ---------------------------------------------------------------------------
34
+ @app.command()
35
+ def logs(
36
+ ctx: typer.Context,
37
+ app_name: Annotated[str, typer.Argument(help="App name.")],
38
+ tail: Annotated[int, typer.Option("--tail", "-n", help="Number of lines.")] = 100,
39
+ ) -> None:
40
+ """View deployment logs (will use Dokploy API)."""
41
+ actx: AppContext = ctx.obj
42
+ out = actx.output
43
+ out.info(f"Not yet implemented. Will fetch last {tail} log lines for: {app_name}")
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # restart
48
+ # ---------------------------------------------------------------------------
49
+ @app.command()
50
+ def restart(
51
+ ctx: typer.Context,
52
+ app_name: Annotated[str, typer.Argument(help="App name to restart.")],
53
+ ) -> None:
54
+ """Restart a deployed app (will use Dokploy API)."""
55
+ actx: AppContext = ctx.obj
56
+ out = actx.output
57
+ out.info(f"Not yet implemented. Will restart deployment: {app_name}")
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # rollback
62
+ # ---------------------------------------------------------------------------
63
+ @app.command()
64
+ def rollback(
65
+ ctx: typer.Context,
66
+ app_name: Annotated[str, typer.Argument(help="App name to rollback.")],
67
+ revision: Annotated[str | None, typer.Option("--revision", "-r", help="Target revision.")] = None,
68
+ ) -> None:
69
+ """Rollback a deployment to a previous revision (will use Dokploy API)."""
70
+ actx: AppContext = ctx.obj
71
+ out = actx.output
72
+ rev_msg = f" to revision {revision}" if revision else " to previous revision"
73
+ out.info(f"Not yet implemented. Will rollback {app_name}{rev_msg}")
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # list
78
+ # ---------------------------------------------------------------------------
79
+ @app.command(name="list")
80
+ def list_deployments(ctx: typer.Context) -> None:
81
+ """List all deployments (will use Dokploy API)."""
82
+ actx: AppContext = ctx.obj
83
+ out = actx.output
84
+ out.info("Not yet implemented. Will list all Dokploy deployments.")