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,277 @@
|
|
|
1
|
+
"""Route introspection commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Inspect endpoints, auth requirements, and middleware chains from the OpenAPI spec.
|
|
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(
|
|
15
|
+
name="routes", help="Endpoint introspection — list, auth, middleware, unprotected.", no_args_is_help=True
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_AUTH_KEYWORDS = ("bearer", "api_key", "apikey", "oauth", "security", "authorization")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fetch_spec(actx: AppContext) -> dict:
|
|
22
|
+
data = actx.client.get("/openapi.json")
|
|
23
|
+
if not data:
|
|
24
|
+
raise RuntimeError("Empty response from /openapi.json")
|
|
25
|
+
return data
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_protected(operation: dict) -> bool:
|
|
29
|
+
"""Return True if the operation has a security requirement."""
|
|
30
|
+
security = operation.get("security")
|
|
31
|
+
if security is not None:
|
|
32
|
+
return bool(security) # empty list [] means explicitly no auth
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _extract_routes(spec: dict) -> list[dict]:
|
|
37
|
+
"""Extract all routes from an OpenAPI spec."""
|
|
38
|
+
routes = []
|
|
39
|
+
for path, path_item in spec.get("paths", {}).items():
|
|
40
|
+
for method, operation in path_item.items():
|
|
41
|
+
if method not in ("get", "post", "put", "patch", "delete", "head", "options"):
|
|
42
|
+
continue
|
|
43
|
+
routes.append(
|
|
44
|
+
{
|
|
45
|
+
"method": method.upper(),
|
|
46
|
+
"path": path,
|
|
47
|
+
"operation_id": operation.get("operationId", ""),
|
|
48
|
+
"summary": operation.get("summary", ""),
|
|
49
|
+
"tags": operation.get("tags", []),
|
|
50
|
+
"protected": _is_protected(operation),
|
|
51
|
+
"security": operation.get("security", []),
|
|
52
|
+
"deprecated": operation.get("deprecated", False),
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
return routes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# list
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
@app.command(name="list")
|
|
62
|
+
def list_routes(ctx: typer.Context) -> None:
|
|
63
|
+
"""List all API endpoints with methods, paths, and auth status."""
|
|
64
|
+
actx: AppContext = ctx.obj
|
|
65
|
+
out = actx.output
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
spec = _fetch_spec(actx)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
out.error(f"Failed to fetch spec: {e}")
|
|
71
|
+
raise typer.Exit(1) from None
|
|
72
|
+
|
|
73
|
+
routes = _extract_routes(spec)
|
|
74
|
+
|
|
75
|
+
rows = [
|
|
76
|
+
[
|
|
77
|
+
r["method"],
|
|
78
|
+
r["path"],
|
|
79
|
+
"[green]yes[/green]" if r["protected"] else "[yellow]no[/yellow]",
|
|
80
|
+
"[red]deprecated[/red]" if r["deprecated"] else "",
|
|
81
|
+
", ".join(r["tags"]),
|
|
82
|
+
r["summary"][:60],
|
|
83
|
+
]
|
|
84
|
+
for r in routes
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
out.table(
|
|
88
|
+
title=f"API Routes ({len(routes)})",
|
|
89
|
+
columns=[
|
|
90
|
+
("Method", "bold"),
|
|
91
|
+
("Path", ""),
|
|
92
|
+
("Auth", ""),
|
|
93
|
+
("Status", ""),
|
|
94
|
+
("Tags", ""),
|
|
95
|
+
("Summary", ""),
|
|
96
|
+
],
|
|
97
|
+
rows=rows,
|
|
98
|
+
data_for_json=routes,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# auth
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
@app.command()
|
|
106
|
+
def auth(ctx: typer.Context) -> None:
|
|
107
|
+
"""Group endpoints by authentication requirement."""
|
|
108
|
+
actx: AppContext = ctx.obj
|
|
109
|
+
out = actx.output
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
spec = _fetch_spec(actx)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
out.error(f"Failed to fetch spec: {e}")
|
|
115
|
+
raise typer.Exit(1) from None
|
|
116
|
+
|
|
117
|
+
routes = _extract_routes(spec)
|
|
118
|
+
protected = [r for r in routes if r["protected"]]
|
|
119
|
+
unprotected = [r for r in routes if not r["protected"]]
|
|
120
|
+
|
|
121
|
+
# Get security scheme names from components
|
|
122
|
+
security_schemes = list(spec.get("components", {}).get("securitySchemes", {}).keys())
|
|
123
|
+
|
|
124
|
+
out.header(f"Authentication Summary ({len(routes)} total routes)")
|
|
125
|
+
out.text("")
|
|
126
|
+
out.text(f" Protected: [green]{len(protected)}[/green]")
|
|
127
|
+
out.text(f" Unprotected: [yellow]{len(unprotected)}[/yellow]")
|
|
128
|
+
if security_schemes:
|
|
129
|
+
out.text(f" Schemes: {', '.join(security_schemes)}")
|
|
130
|
+
out.text("")
|
|
131
|
+
|
|
132
|
+
if protected:
|
|
133
|
+
rows = [[r["method"], r["path"], ", ".join(str(s) for s in r["security"])] for r in protected]
|
|
134
|
+
out.table(
|
|
135
|
+
title=f"Protected Endpoints ({len(protected)})",
|
|
136
|
+
columns=[("Method", "bold"), ("Path", ""), ("Security", "green")],
|
|
137
|
+
rows=rows,
|
|
138
|
+
data_for_json=protected,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if actx.json_mode:
|
|
142
|
+
out.raw_json({"protected": protected, "unprotected": unprotected, "schemes": security_schemes})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# middleware
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
@app.command()
|
|
149
|
+
def middleware(
|
|
150
|
+
ctx: typer.Context,
|
|
151
|
+
path: Annotated[str, typer.Argument(help="Endpoint path to inspect (e.g. /api/v1/users).")],
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Describe the middleware chain for a specific endpoint path."""
|
|
154
|
+
actx: AppContext = ctx.obj
|
|
155
|
+
out = actx.output
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
spec = _fetch_spec(actx)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
out.error(f"Failed to fetch spec: {e}")
|
|
161
|
+
raise typer.Exit(1) from None
|
|
162
|
+
|
|
163
|
+
paths = spec.get("paths", {})
|
|
164
|
+
matched = {k: v for k, v in paths.items() if path in k}
|
|
165
|
+
|
|
166
|
+
if not matched:
|
|
167
|
+
out.warn(f"No endpoints matching '{path}' found in spec.")
|
|
168
|
+
raise typer.Exit(1)
|
|
169
|
+
|
|
170
|
+
# Infer middleware from path patterns and security
|
|
171
|
+
chain: list[dict] = []
|
|
172
|
+
|
|
173
|
+
# CORS — always present
|
|
174
|
+
chain.append({"middleware": "CORS", "status": "enabled", "note": "All origins policy from config"})
|
|
175
|
+
|
|
176
|
+
# Auth middleware
|
|
177
|
+
for _ep_path, path_item in matched.items():
|
|
178
|
+
for method, operation in path_item.items():
|
|
179
|
+
if method not in ("get", "post", "put", "patch", "delete"):
|
|
180
|
+
continue
|
|
181
|
+
if _is_protected(operation):
|
|
182
|
+
chain.append({"middleware": "JWT Auth", "status": "enabled", "note": "Bearer token required"})
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
# Rate limiting — always present
|
|
186
|
+
chain.append(
|
|
187
|
+
{
|
|
188
|
+
"middleware": "Rate Limit",
|
|
189
|
+
"status": "enabled",
|
|
190
|
+
"note": "Tier-based: free=30, user=100, premium=300, admin=600 req/min",
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Request validation
|
|
195
|
+
chain.append(
|
|
196
|
+
{"middleware": "Request Validation", "status": "enabled", "note": "Pydantic body + query param validation"}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
rows = [[c["middleware"], c["status"], c["note"]] for c in chain]
|
|
200
|
+
out.table(
|
|
201
|
+
title=f"Middleware Chain: {path}",
|
|
202
|
+
columns=[("Middleware", "bold"), ("Status", "green"), ("Note", "")],
|
|
203
|
+
rows=rows,
|
|
204
|
+
data_for_json={"path": path, "chain": chain},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# unprotected
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
@app.command()
|
|
212
|
+
def unprotected(ctx: typer.Context) -> None:
|
|
213
|
+
"""Find endpoints without authentication requirements."""
|
|
214
|
+
actx: AppContext = ctx.obj
|
|
215
|
+
out = actx.output
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
spec = _fetch_spec(actx)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
out.error(f"Failed to fetch spec: {e}")
|
|
221
|
+
raise typer.Exit(1) from None
|
|
222
|
+
|
|
223
|
+
routes = _extract_routes(spec)
|
|
224
|
+
unprotected_routes = [r for r in routes if not r["protected"]]
|
|
225
|
+
|
|
226
|
+
# Filter out common public endpoints
|
|
227
|
+
public_patterns = ("/health", "/openapi", "/docs", "/redoc", "/metrics")
|
|
228
|
+
flagged = [r for r in unprotected_routes if not any(r["path"].startswith(p) for p in public_patterns)]
|
|
229
|
+
|
|
230
|
+
if not flagged:
|
|
231
|
+
out.success(
|
|
232
|
+
f"All non-public endpoints require authentication. ({len(unprotected_routes)} public endpoints skipped)"
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
rows = [[r["method"], r["path"], ", ".join(r["tags"]), r["summary"][:60]] for r in flagged]
|
|
237
|
+
out.table(
|
|
238
|
+
title=f"Potentially Unprotected Endpoints ({len(flagged)})",
|
|
239
|
+
columns=[("Method", "bold"), ("Path", "yellow"), ("Tags", ""), ("Summary", "")],
|
|
240
|
+
rows=rows,
|
|
241
|
+
data_for_json=flagged,
|
|
242
|
+
)
|
|
243
|
+
out.warn(f"{len(flagged)} endpoints may be missing authentication.")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# graph
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
@app.command()
|
|
250
|
+
def graph(ctx: typer.Context) -> None:
|
|
251
|
+
"""Show route count per tag/module/router."""
|
|
252
|
+
actx: AppContext = ctx.obj
|
|
253
|
+
out = actx.output
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
spec = _fetch_spec(actx)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
out.error(f"Failed to fetch spec: {e}")
|
|
259
|
+
raise typer.Exit(1) from None
|
|
260
|
+
|
|
261
|
+
routes = _extract_routes(spec)
|
|
262
|
+
|
|
263
|
+
# Count by tag
|
|
264
|
+
tag_counts: dict[str, int] = {}
|
|
265
|
+
for route in routes:
|
|
266
|
+
for tag in route["tags"] or ["(untagged)"]:
|
|
267
|
+
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
|
268
|
+
|
|
269
|
+
sorted_tags = sorted(tag_counts.items(), key=lambda x: -x[1])
|
|
270
|
+
|
|
271
|
+
rows = [[tag, str(count), "#" * min(count, 40)] for tag, count in sorted_tags]
|
|
272
|
+
out.table(
|
|
273
|
+
title=f"Routes per Module ({len(routes)} total)",
|
|
274
|
+
columns=[("Module/Tag", "bold"), ("Count", ""), ("Bar", "green")],
|
|
275
|
+
rows=rows,
|
|
276
|
+
data_for_json=[{"tag": t, "count": c} for t, c in sorted_tags],
|
|
277
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""SaaS management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Create tenants, check status, and manage plans.
|
|
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.exceptions import APIError, AuthenticationError
|
|
14
|
+
from kctl_api.core.exceptions import ConnectionError as KctlConnectionError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="saas", help="SaaS tenant management — create, status, plans.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
_BASE = "/api/v1/saas"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# create
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
@app.command()
|
|
25
|
+
def create(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
name: Annotated[str, typer.Option("--name", "-n", help="Tenant name.")],
|
|
28
|
+
plan: Annotated[str, typer.Option("--plan", "-p", help="Plan ID or slug.")] = "free",
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Create a new SaaS tenant via POST /api/v1/saas/tenants."""
|
|
31
|
+
actx: AppContext = ctx.obj
|
|
32
|
+
out = actx.output
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
result = actx.client.post(f"{_BASE}/tenants", json={"name": name, "plan": plan})
|
|
36
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
37
|
+
out.error(str(e))
|
|
38
|
+
raise typer.Exit(1) from None
|
|
39
|
+
|
|
40
|
+
out.success(f"Tenant created: {name}")
|
|
41
|
+
if actx.json_mode:
|
|
42
|
+
out.raw_json(result)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# status
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
@app.command()
|
|
49
|
+
def status(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
tenant_id: Annotated[str, typer.Argument(help="Tenant ID.")],
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Get tenant provisioning status via GET /api/v1/saas/tenants/{slug}/status."""
|
|
54
|
+
actx: AppContext = ctx.obj
|
|
55
|
+
out = actx.output
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
data = actx.client.get(f"{_BASE}/tenants/{tenant_id}/status")
|
|
59
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
60
|
+
out.error(str(e))
|
|
61
|
+
raise typer.Exit(1) from None
|
|
62
|
+
|
|
63
|
+
if not data:
|
|
64
|
+
out.error(f"Tenant not found: {tenant_id}")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
|
|
67
|
+
out.detail(
|
|
68
|
+
title=f"Tenant: {data.get('name', tenant_id)}",
|
|
69
|
+
sections=[
|
|
70
|
+
("Details", [(k, str(v)) for k, v in data.items()]),
|
|
71
|
+
],
|
|
72
|
+
data_for_json=data,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# select-plan
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
@app.command(name="select-plan")
|
|
80
|
+
def select_plan(
|
|
81
|
+
ctx: typer.Context,
|
|
82
|
+
tenant_id: Annotated[str, typer.Argument(help="Tenant ID.")],
|
|
83
|
+
plan: Annotated[str, typer.Argument(help="Plan ID or slug to switch to.")],
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Change tenant plan via POST /api/v1/saas/tenants/{slug}/select-plan."""
|
|
86
|
+
actx: AppContext = ctx.obj
|
|
87
|
+
out = actx.output
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
result = actx.client.post(f"{_BASE}/tenants/{tenant_id}/select-plan", json={"plan": plan})
|
|
91
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
92
|
+
out.error(str(e))
|
|
93
|
+
raise typer.Exit(1) from None
|
|
94
|
+
|
|
95
|
+
out.success(f"Tenant {tenant_id} plan changed to {plan}.")
|
|
96
|
+
if actx.json_mode:
|
|
97
|
+
out.raw_json(result)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# list
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
@app.command(name="list")
|
|
104
|
+
def list_tenants(
|
|
105
|
+
ctx: typer.Context,
|
|
106
|
+
page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
|
|
107
|
+
per_page: Annotated[int, typer.Option("--per-page", "-n", help="Items per page.")] = 20,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""List SaaS tenants via GET /api/v1/saas/tenants."""
|
|
110
|
+
actx: AppContext = ctx.obj
|
|
111
|
+
out = actx.output
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
data = actx.client.get(f"{_BASE}/tenants", params={"page": page, "per_page": per_page})
|
|
115
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
116
|
+
out.error(str(e))
|
|
117
|
+
raise typer.Exit(1) from None
|
|
118
|
+
|
|
119
|
+
items = data.get("items", []) if isinstance(data, dict) else []
|
|
120
|
+
total = data.get("total", len(items)) if isinstance(data, dict) else len(items)
|
|
121
|
+
|
|
122
|
+
rows: list[list[str]] = []
|
|
123
|
+
for t in items:
|
|
124
|
+
rows.append(
|
|
125
|
+
[
|
|
126
|
+
str(t.get("id", "")),
|
|
127
|
+
t.get("name", ""),
|
|
128
|
+
t.get("plan", ""),
|
|
129
|
+
t.get("status", ""),
|
|
130
|
+
str(t.get("created_at", "")),
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
out.table(
|
|
135
|
+
title=f"SaaS Tenants (page {page}, {total} total)",
|
|
136
|
+
columns=[
|
|
137
|
+
("ID", "bold"),
|
|
138
|
+
("Name", ""),
|
|
139
|
+
("Plan", ""),
|
|
140
|
+
("Status", ""),
|
|
141
|
+
("Created", "dim"),
|
|
142
|
+
],
|
|
143
|
+
rows=rows,
|
|
144
|
+
data_for_json=items,
|
|
145
|
+
)
|