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,609 @@
|
|
|
1
|
+
"""Redis management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Inspect keys, get/delete values, and view server info.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from kctl_api.core.callbacks import AppContext
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="redis", help="Redis management — info, keys, get, delete, stats.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# info
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
@app.command()
|
|
22
|
+
def info(ctx: typer.Context) -> None:
|
|
23
|
+
"""Show Redis server info via async PING + INFO."""
|
|
24
|
+
actx: AppContext = ctx.obj
|
|
25
|
+
out = actx.output
|
|
26
|
+
|
|
27
|
+
redis_url = actx.redis_url
|
|
28
|
+
if not redis_url:
|
|
29
|
+
out.error("No redis_url configured.")
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
|
|
32
|
+
async def _run() -> dict:
|
|
33
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
client = get_redis(redis_url)
|
|
37
|
+
info_data = await client.info()
|
|
38
|
+
return dict(info_data) if info_data else {}
|
|
39
|
+
finally:
|
|
40
|
+
await close_redis()
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
data = asyncio.run(_run())
|
|
44
|
+
except ImportError as e:
|
|
45
|
+
out.error(str(e))
|
|
46
|
+
raise typer.Exit(1) from None
|
|
47
|
+
except Exception as e:
|
|
48
|
+
out.error(f"Redis error: {e}")
|
|
49
|
+
raise typer.Exit(1) from None
|
|
50
|
+
|
|
51
|
+
# Show key metrics
|
|
52
|
+
sections = [
|
|
53
|
+
(
|
|
54
|
+
"Server",
|
|
55
|
+
[
|
|
56
|
+
("Version", str(data.get("redis_version", ""))),
|
|
57
|
+
("Uptime (seconds)", str(data.get("uptime_in_seconds", ""))),
|
|
58
|
+
("Connected Clients", str(data.get("connected_clients", ""))),
|
|
59
|
+
("Used Memory", str(data.get("used_memory_human", ""))),
|
|
60
|
+
(
|
|
61
|
+
"Total Keys",
|
|
62
|
+
str(
|
|
63
|
+
sum(
|
|
64
|
+
data.get(f"db{i}", {}).get("keys", 0)
|
|
65
|
+
for i in range(16)
|
|
66
|
+
if isinstance(data.get(f"db{i}"), dict)
|
|
67
|
+
)
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
],
|
|
71
|
+
),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
out.detail(title="Redis Info", sections=sections, data_for_json=data)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# keys
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
@app.command()
|
|
81
|
+
def keys(
|
|
82
|
+
ctx: typer.Context,
|
|
83
|
+
pattern: Annotated[str, typer.Argument(help="Key pattern (e.g. 'myapp:*').")] = "*",
|
|
84
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max keys to return.")] = 50,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""List Redis keys matching a pattern via async SCAN."""
|
|
87
|
+
actx: AppContext = ctx.obj
|
|
88
|
+
out = actx.output
|
|
89
|
+
|
|
90
|
+
redis_url = actx.redis_url
|
|
91
|
+
if not redis_url:
|
|
92
|
+
out.error("No redis_url configured.")
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
|
|
95
|
+
async def _run() -> list[str]:
|
|
96
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
client = get_redis(redis_url)
|
|
100
|
+
result: list[str] = []
|
|
101
|
+
async for key in client.scan_iter(match=pattern, count=100):
|
|
102
|
+
result.append(key)
|
|
103
|
+
if len(result) >= limit:
|
|
104
|
+
break
|
|
105
|
+
return result
|
|
106
|
+
finally:
|
|
107
|
+
await close_redis()
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
found_keys = asyncio.run(_run())
|
|
111
|
+
except ImportError as e:
|
|
112
|
+
out.error(str(e))
|
|
113
|
+
raise typer.Exit(1) from None
|
|
114
|
+
except Exception as e:
|
|
115
|
+
out.error(f"Redis error: {e}")
|
|
116
|
+
raise typer.Exit(1) from None
|
|
117
|
+
|
|
118
|
+
rows = [[k] for k in found_keys]
|
|
119
|
+
|
|
120
|
+
out.table(
|
|
121
|
+
title=f"Redis Keys ({len(found_keys)} matching '{pattern}')",
|
|
122
|
+
columns=[("Key", "")],
|
|
123
|
+
rows=rows,
|
|
124
|
+
data_for_json=[{"key": k} for k in found_keys],
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# get
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
@app.command(name="get")
|
|
132
|
+
def get_key(
|
|
133
|
+
ctx: typer.Context,
|
|
134
|
+
key: Annotated[str, typer.Argument(help="Redis key to retrieve.")],
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Get a Redis key value."""
|
|
137
|
+
actx: AppContext = ctx.obj
|
|
138
|
+
out = actx.output
|
|
139
|
+
|
|
140
|
+
redis_url = actx.redis_url
|
|
141
|
+
if not redis_url:
|
|
142
|
+
out.error("No redis_url configured.")
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
|
|
145
|
+
async def _run() -> str | None:
|
|
146
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
client = get_redis(redis_url)
|
|
150
|
+
return await client.get(key)
|
|
151
|
+
finally:
|
|
152
|
+
await close_redis()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
value = asyncio.run(_run())
|
|
156
|
+
except Exception as e:
|
|
157
|
+
out.error(f"Redis error: {e}")
|
|
158
|
+
raise typer.Exit(1) from None
|
|
159
|
+
|
|
160
|
+
if value is None:
|
|
161
|
+
out.info(f"Key not found: {key}")
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
if actx.json_mode:
|
|
165
|
+
out.raw_json({"key": key, "value": value})
|
|
166
|
+
else:
|
|
167
|
+
out.text(f"{key} = {value}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# delete
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
@app.command()
|
|
174
|
+
def delete(
|
|
175
|
+
ctx: typer.Context,
|
|
176
|
+
key: Annotated[str, typer.Argument(help="Redis key to delete.")],
|
|
177
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Delete a Redis key."""
|
|
180
|
+
actx: AppContext = ctx.obj
|
|
181
|
+
out = actx.output
|
|
182
|
+
|
|
183
|
+
redis_url = actx.redis_url
|
|
184
|
+
if not redis_url:
|
|
185
|
+
out.error("No redis_url configured.")
|
|
186
|
+
raise typer.Exit(1)
|
|
187
|
+
|
|
188
|
+
if not force:
|
|
189
|
+
confirm = typer.confirm(f"Delete Redis key '{key}'?", default=False)
|
|
190
|
+
if not confirm:
|
|
191
|
+
out.info("Cancelled.")
|
|
192
|
+
raise typer.Exit(0)
|
|
193
|
+
|
|
194
|
+
async def _run() -> int:
|
|
195
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
client = get_redis(redis_url)
|
|
199
|
+
return await client.delete(key)
|
|
200
|
+
finally:
|
|
201
|
+
await close_redis()
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
deleted = asyncio.run(_run())
|
|
205
|
+
except Exception as e:
|
|
206
|
+
out.error(f"Redis error: {e}")
|
|
207
|
+
raise typer.Exit(1) from None
|
|
208
|
+
|
|
209
|
+
if deleted:
|
|
210
|
+
out.success(f"Key '{key}' deleted.")
|
|
211
|
+
else:
|
|
212
|
+
out.info(f"Key '{key}' did not exist.")
|
|
213
|
+
|
|
214
|
+
if actx.json_mode:
|
|
215
|
+
out.raw_json({"key": key, "deleted": deleted})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# flush
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
@app.command()
|
|
222
|
+
def flush(
|
|
223
|
+
ctx: typer.Context,
|
|
224
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Flush the current Redis database (FLUSHDB)."""
|
|
227
|
+
actx: AppContext = ctx.obj
|
|
228
|
+
out = actx.output
|
|
229
|
+
|
|
230
|
+
redis_url = actx.redis_url
|
|
231
|
+
if not redis_url:
|
|
232
|
+
out.error("No redis_url configured.")
|
|
233
|
+
raise typer.Exit(1)
|
|
234
|
+
|
|
235
|
+
if not force:
|
|
236
|
+
confirm = typer.confirm("Flush the entire Redis database?", default=False)
|
|
237
|
+
if not confirm:
|
|
238
|
+
out.info("Cancelled.")
|
|
239
|
+
raise typer.Exit(0)
|
|
240
|
+
|
|
241
|
+
async def _run() -> bool:
|
|
242
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
client = get_redis(redis_url)
|
|
246
|
+
return await client.flushdb()
|
|
247
|
+
finally:
|
|
248
|
+
await close_redis()
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
asyncio.run(_run())
|
|
252
|
+
except Exception as e:
|
|
253
|
+
out.error(f"Redis error: {e}")
|
|
254
|
+
raise typer.Exit(1) from None
|
|
255
|
+
|
|
256
|
+
out.success("Redis database flushed.")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# stats
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
@app.command()
|
|
263
|
+
def stats(ctx: typer.Context) -> None:
|
|
264
|
+
"""Show Redis memory and key statistics."""
|
|
265
|
+
actx: AppContext = ctx.obj
|
|
266
|
+
out = actx.output
|
|
267
|
+
|
|
268
|
+
redis_url = actx.redis_url
|
|
269
|
+
if not redis_url:
|
|
270
|
+
out.error("No redis_url configured.")
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
|
|
273
|
+
async def _run() -> dict:
|
|
274
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
client = get_redis(redis_url)
|
|
278
|
+
info_data = await client.info("memory")
|
|
279
|
+
dbsize = await client.dbsize()
|
|
280
|
+
result = dict(info_data) if info_data else {}
|
|
281
|
+
result["dbsize"] = dbsize
|
|
282
|
+
return result
|
|
283
|
+
finally:
|
|
284
|
+
await close_redis()
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
data = asyncio.run(_run())
|
|
288
|
+
except Exception as e:
|
|
289
|
+
out.error(f"Redis error: {e}")
|
|
290
|
+
raise typer.Exit(1) from None
|
|
291
|
+
|
|
292
|
+
out.detail(
|
|
293
|
+
title="Redis Stats",
|
|
294
|
+
sections=[
|
|
295
|
+
(
|
|
296
|
+
"Memory",
|
|
297
|
+
[
|
|
298
|
+
("Used Memory", str(data.get("used_memory_human", ""))),
|
|
299
|
+
("Peak Memory", str(data.get("used_memory_peak_human", ""))),
|
|
300
|
+
("Total Keys", str(data.get("dbsize", ""))),
|
|
301
|
+
],
|
|
302
|
+
),
|
|
303
|
+
],
|
|
304
|
+
data_for_json=data,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# monitor
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
@app.command()
|
|
312
|
+
def monitor(
|
|
313
|
+
ctx: typer.Context,
|
|
314
|
+
duration: Annotated[int, typer.Option("--duration", help="Monitor duration in seconds.")] = 10,
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Real-time Redis MONITOR — show commands as they execute."""
|
|
317
|
+
actx: AppContext = ctx.obj
|
|
318
|
+
out = actx.output
|
|
319
|
+
|
|
320
|
+
redis_url = actx.redis_url
|
|
321
|
+
if not redis_url:
|
|
322
|
+
out.error("No redis_url configured.")
|
|
323
|
+
raise typer.Exit(1)
|
|
324
|
+
|
|
325
|
+
out.info(f"Redis MONITOR — capturing commands for {duration}s (Ctrl+C to stop) ...")
|
|
326
|
+
out.warn("MONITOR can impact Redis performance. Use in dev/staging only.")
|
|
327
|
+
|
|
328
|
+
async def _run() -> list[str]:
|
|
329
|
+
import time
|
|
330
|
+
|
|
331
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
client = get_redis(redis_url)
|
|
335
|
+
commands: list[str] = []
|
|
336
|
+
deadline = time.monotonic() + duration
|
|
337
|
+
|
|
338
|
+
# Use raw pubsub monitor via execute_command
|
|
339
|
+
# Note: redis-py doesn't have a native monitor context; use raw approach
|
|
340
|
+
monitor_client = client.monitor() # type: ignore[attr-defined]
|
|
341
|
+
async with monitor_client as m:
|
|
342
|
+
while time.monotonic() < deadline:
|
|
343
|
+
try:
|
|
344
|
+
command = await asyncio.wait_for(m.next_command(), timeout=1.0)
|
|
345
|
+
line = f"[{command.get('time', '')}] {command.get('command', '')}"
|
|
346
|
+
commands.append(line)
|
|
347
|
+
typer.echo(f" {line}")
|
|
348
|
+
except TimeoutError:
|
|
349
|
+
continue
|
|
350
|
+
except Exception:
|
|
351
|
+
break
|
|
352
|
+
return commands
|
|
353
|
+
finally:
|
|
354
|
+
await close_redis()
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
commands = asyncio.run(_run())
|
|
358
|
+
out.success(f"Captured {len(commands)} commands.")
|
|
359
|
+
except AttributeError:
|
|
360
|
+
out.warn("MONITOR not available via this client version.")
|
|
361
|
+
out.info("Use: redis-cli MONITOR (direct Redis CLI)")
|
|
362
|
+
except Exception as e:
|
|
363
|
+
out.error(f"Redis error: {e}")
|
|
364
|
+
raise typer.Exit(1) from None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# memory
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
@app.command()
|
|
371
|
+
def memory(ctx: typer.Context) -> None:
|
|
372
|
+
"""Show memory usage per key prefix."""
|
|
373
|
+
actx: AppContext = ctx.obj
|
|
374
|
+
out = actx.output
|
|
375
|
+
|
|
376
|
+
redis_url = actx.redis_url
|
|
377
|
+
if not redis_url:
|
|
378
|
+
out.error("No redis_url configured.")
|
|
379
|
+
raise typer.Exit(1)
|
|
380
|
+
|
|
381
|
+
async def _run() -> dict[str, dict]:
|
|
382
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
client = get_redis(redis_url)
|
|
386
|
+
prefix_stats: dict[str, dict] = {}
|
|
387
|
+
count = 0
|
|
388
|
+
|
|
389
|
+
async for key in client.scan_iter(match="*", count=500):
|
|
390
|
+
count += 1
|
|
391
|
+
if count > 5000:
|
|
392
|
+
break
|
|
393
|
+
# Extract prefix (up to first : or first 20 chars)
|
|
394
|
+
key_str = str(key)
|
|
395
|
+
prefix = key_str.split(":")[0] if ":" in key_str else key_str[:20]
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
mem = await client.memory_usage(key) # type: ignore[attr-defined]
|
|
399
|
+
if mem:
|
|
400
|
+
if prefix not in prefix_stats:
|
|
401
|
+
prefix_stats[prefix] = {"count": 0, "bytes": 0}
|
|
402
|
+
prefix_stats[prefix]["count"] += 1
|
|
403
|
+
prefix_stats[prefix]["bytes"] += mem
|
|
404
|
+
except Exception:
|
|
405
|
+
if prefix not in prefix_stats:
|
|
406
|
+
prefix_stats[prefix] = {"count": 0, "bytes": 0}
|
|
407
|
+
prefix_stats[prefix]["count"] += 1
|
|
408
|
+
|
|
409
|
+
return prefix_stats
|
|
410
|
+
finally:
|
|
411
|
+
await close_redis()
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
stats = asyncio.run(_run())
|
|
415
|
+
except Exception as e:
|
|
416
|
+
out.error(f"Redis error: {e}")
|
|
417
|
+
raise typer.Exit(1) from None
|
|
418
|
+
|
|
419
|
+
if not stats:
|
|
420
|
+
out.info("No keys found.")
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
sorted_stats = sorted(stats.items(), key=lambda x: -x[1].get("bytes", 0))
|
|
424
|
+
|
|
425
|
+
rows = [
|
|
426
|
+
[
|
|
427
|
+
prefix,
|
|
428
|
+
str(data["count"]),
|
|
429
|
+
f"{round(data.get('bytes', 0) / 1024, 1)} KB" if data.get("bytes") else "N/A",
|
|
430
|
+
]
|
|
431
|
+
for prefix, data in sorted_stats
|
|
432
|
+
]
|
|
433
|
+
out.table(
|
|
434
|
+
title="Redis Memory by Prefix",
|
|
435
|
+
columns=[("Prefix", "bold"), ("Keys", ""), ("Memory", "")],
|
|
436
|
+
rows=rows,
|
|
437
|
+
data_for_json=[{"prefix": p, **d} for p, d in sorted_stats],
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# pubsub
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
@app.command()
|
|
445
|
+
def pubsub(ctx: typer.Context) -> None:
|
|
446
|
+
"""Show active Pub/Sub channels and subscriber counts."""
|
|
447
|
+
actx: AppContext = ctx.obj
|
|
448
|
+
out = actx.output
|
|
449
|
+
|
|
450
|
+
redis_url = actx.redis_url
|
|
451
|
+
if not redis_url:
|
|
452
|
+
out.error("No redis_url configured.")
|
|
453
|
+
raise typer.Exit(1)
|
|
454
|
+
|
|
455
|
+
async def _run() -> dict:
|
|
456
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
457
|
+
|
|
458
|
+
try:
|
|
459
|
+
client = get_redis(redis_url)
|
|
460
|
+
channels = await client.pubsub_channels()
|
|
461
|
+
numsub = await client.pubsub_numsub(*channels) if channels else {}
|
|
462
|
+
numpat = await client.pubsub_numpat()
|
|
463
|
+
return {
|
|
464
|
+
"channels": [str(c) for c in channels],
|
|
465
|
+
"subscribers": {str(k): v for k, v in numsub.items()} if isinstance(numsub, dict) else {},
|
|
466
|
+
"pattern_subscriptions": numpat,
|
|
467
|
+
}
|
|
468
|
+
finally:
|
|
469
|
+
await close_redis()
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
data = asyncio.run(_run())
|
|
473
|
+
except Exception as e:
|
|
474
|
+
out.error(f"Redis error: {e}")
|
|
475
|
+
raise typer.Exit(1) from None
|
|
476
|
+
|
|
477
|
+
channels = data.get("channels", [])
|
|
478
|
+
subscribers = data.get("subscribers", {})
|
|
479
|
+
|
|
480
|
+
if not channels:
|
|
481
|
+
out.info("No active Pub/Sub channels.")
|
|
482
|
+
out.text(f" Pattern subscriptions: {data.get('pattern_subscriptions', 0)}")
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
rows = [[ch, str(subscribers.get(ch, 0))] for ch in channels]
|
|
486
|
+
out.table(
|
|
487
|
+
title=f"Active Pub/Sub Channels ({len(channels)})",
|
|
488
|
+
columns=[("Channel", "bold"), ("Subscribers", "")],
|
|
489
|
+
rows=rows,
|
|
490
|
+
data_for_json=data,
|
|
491
|
+
)
|
|
492
|
+
out.info(f"Pattern subscriptions: {data.get('pattern_subscriptions', 0)}")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
# expire-audit
|
|
497
|
+
# ---------------------------------------------------------------------------
|
|
498
|
+
@app.command(name="expire-audit")
|
|
499
|
+
def expire_audit(
|
|
500
|
+
ctx: typer.Context,
|
|
501
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max keys to check.")] = 200,
|
|
502
|
+
) -> None:
|
|
503
|
+
"""Find keys without TTL (potential memory leaks)."""
|
|
504
|
+
actx: AppContext = ctx.obj
|
|
505
|
+
out = actx.output
|
|
506
|
+
|
|
507
|
+
redis_url = actx.redis_url
|
|
508
|
+
if not redis_url:
|
|
509
|
+
out.error("No redis_url configured.")
|
|
510
|
+
raise typer.Exit(1)
|
|
511
|
+
|
|
512
|
+
async def _run() -> tuple[list[str], int]:
|
|
513
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
client = get_redis(redis_url)
|
|
517
|
+
no_ttl: list[str] = []
|
|
518
|
+
total = 0
|
|
519
|
+
|
|
520
|
+
async for key in client.scan_iter(match="*", count=100):
|
|
521
|
+
total += 1
|
|
522
|
+
if total > limit:
|
|
523
|
+
break
|
|
524
|
+
ttl = await client.ttl(key)
|
|
525
|
+
if ttl == -1: # -1 means no TTL, -2 means key doesn't exist
|
|
526
|
+
no_ttl.append(str(key))
|
|
527
|
+
|
|
528
|
+
return no_ttl, total
|
|
529
|
+
finally:
|
|
530
|
+
await close_redis()
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
no_ttl_keys, total_checked = asyncio.run(_run())
|
|
534
|
+
except Exception as e:
|
|
535
|
+
out.error(f"Redis error: {e}")
|
|
536
|
+
raise typer.Exit(1) from None
|
|
537
|
+
|
|
538
|
+
out.info(f"Checked {total_checked} keys — {len(no_ttl_keys)} without TTL.")
|
|
539
|
+
|
|
540
|
+
if not no_ttl_keys:
|
|
541
|
+
out.success("All sampled keys have TTL set.")
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
rows = [[k] for k in no_ttl_keys[:50]]
|
|
545
|
+
out.table(
|
|
546
|
+
title=f"Keys Without TTL ({len(no_ttl_keys)} found)",
|
|
547
|
+
columns=[("Key", "yellow")],
|
|
548
|
+
rows=rows,
|
|
549
|
+
data_for_json=[{"key": k} for k in no_ttl_keys],
|
|
550
|
+
)
|
|
551
|
+
out.warn(f"{len(no_ttl_keys)} keys without TTL may cause unbounded memory growth.")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# ---------------------------------------------------------------------------
|
|
555
|
+
# keys-by-prefix
|
|
556
|
+
# ---------------------------------------------------------------------------
|
|
557
|
+
@app.command(name="keys-by-prefix")
|
|
558
|
+
def keys_by_prefix(
|
|
559
|
+
ctx: typer.Context,
|
|
560
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max keys to scan.")] = 1000,
|
|
561
|
+
) -> None:
|
|
562
|
+
"""Group Redis keys by prefix (up to first colon)."""
|
|
563
|
+
actx: AppContext = ctx.obj
|
|
564
|
+
out = actx.output
|
|
565
|
+
|
|
566
|
+
redis_url = actx.redis_url
|
|
567
|
+
if not redis_url:
|
|
568
|
+
out.error("No redis_url configured.")
|
|
569
|
+
raise typer.Exit(1)
|
|
570
|
+
|
|
571
|
+
async def _run() -> dict[str, int]:
|
|
572
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
client = get_redis(redis_url)
|
|
576
|
+
prefix_counts: dict[str, int] = {}
|
|
577
|
+
count = 0
|
|
578
|
+
|
|
579
|
+
async for key in client.scan_iter(match="*", count=100):
|
|
580
|
+
count += 1
|
|
581
|
+
if count > limit:
|
|
582
|
+
break
|
|
583
|
+
key_str = str(key)
|
|
584
|
+
prefix = key_str.split(":")[0] if ":" in key_str else f"(no prefix) {key_str[:20]}"
|
|
585
|
+
prefix_counts[prefix] = prefix_counts.get(prefix, 0) + 1
|
|
586
|
+
|
|
587
|
+
return prefix_counts
|
|
588
|
+
finally:
|
|
589
|
+
await close_redis()
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
prefix_counts = asyncio.run(_run())
|
|
593
|
+
except Exception as e:
|
|
594
|
+
out.error(f"Redis error: {e}")
|
|
595
|
+
raise typer.Exit(1) from None
|
|
596
|
+
|
|
597
|
+
if not prefix_counts:
|
|
598
|
+
out.info("No keys found.")
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
sorted_prefixes = sorted(prefix_counts.items(), key=lambda x: -x[1])
|
|
602
|
+
|
|
603
|
+
rows = [[prefix, str(count), "#" * min(count, 30)] for prefix, count in sorted_prefixes]
|
|
604
|
+
out.table(
|
|
605
|
+
title=f"Keys by Prefix (sampled {limit})",
|
|
606
|
+
columns=[("Prefix", "bold"), ("Count", ""), ("Bar", "green")],
|
|
607
|
+
rows=rows,
|
|
608
|
+
data_for_json=[{"prefix": p, "count": c} for p, c in sorted_prefixes],
|
|
609
|
+
)
|