dataplat 0.1.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.
- dataplat/__init__.py +3 -0
- dataplat/cli/__init__.py +1 -0
- dataplat/cli/_missing.py +108 -0
- dataplat/cli/bi/__init__.py +1 -0
- dataplat/cli/bi/app.py +14 -0
- dataplat/cli/bi/superset.py +558 -0
- dataplat/cli/ci/__init__.py +1 -0
- dataplat/cli/ci/app.py +14 -0
- dataplat/cli/ci/github/__init__.py +1 -0
- dataplat/cli/ci/github/app.py +10 -0
- dataplat/cli/ci/github/runner.py +399 -0
- dataplat/cli/cloud/__init__.py +1 -0
- dataplat/cli/cloud/app.py +14 -0
- dataplat/cli/cloud/aws/__init__.py +1 -0
- dataplat/cli/cloud/aws/_common.py +77 -0
- dataplat/cli/cloud/aws/app.py +12 -0
- dataplat/cli/cloud/aws/rds.py +686 -0
- dataplat/cli/cloud/aws/redshift.py +312 -0
- dataplat/cli/cloud/aws/secrets.py +875 -0
- dataplat/cli/config.py +492 -0
- dataplat/cli/db/__init__.py +342 -0
- dataplat/cli/db/_common.py +130 -0
- dataplat/cli/db/_report.py +180 -0
- dataplat/cli/db/dbt_orphans.py +842 -0
- dataplat/cli/db/describe.py +939 -0
- dataplat/cli/db/long_queries.py +299 -0
- dataplat/cli/db/role.py +434 -0
- dataplat/cli/db/role_create.py +452 -0
- dataplat/cli/db/role_drop.py +290 -0
- dataplat/cli/db/role_list.py +147 -0
- dataplat/cli/db/top_tables.py +337 -0
- dataplat/cli/ingest/__init__.py +1 -0
- dataplat/cli/ingest/airbyte/__init__.py +5 -0
- dataplat/cli/ingest/airbyte/_common.py +36 -0
- dataplat/cli/ingest/airbyte/_cursor.py +268 -0
- dataplat/cli/ingest/airbyte/_resource.py +192 -0
- dataplat/cli/ingest/airbyte/app.py +31 -0
- dataplat/cli/ingest/airbyte/connections.py +1234 -0
- dataplat/cli/ingest/airbyte/definitions.py +122 -0
- dataplat/cli/ingest/airbyte/destinations.py +26 -0
- dataplat/cli/ingest/airbyte/enums.py +45 -0
- dataplat/cli/ingest/airbyte/jobs.py +112 -0
- dataplat/cli/ingest/airbyte/sources.py +26 -0
- dataplat/cli/ingest/airbyte/tags.py +57 -0
- dataplat/cli/ingest/airbyte/templates.py +144 -0
- dataplat/cli/ingest/airbyte/tui.py +123 -0
- dataplat/cli/ingest/airbyte/workspaces.py +80 -0
- dataplat/cli/ingest/app.py +14 -0
- dataplat/cli/open.py +117 -0
- dataplat/cli/status.py +308 -0
- dataplat/core/__init__.py +1 -0
- dataplat/core/deps.py +138 -0
- dataplat/core/envrc.py +125 -0
- dataplat/core/errors.py +23 -0
- dataplat/main.py +87 -0
- dataplat/services/__init__.py +1 -0
- dataplat/services/airbyte/__init__.py +1 -0
- dataplat/services/airbyte/_resource.py +113 -0
- dataplat/services/airbyte/client.py +280 -0
- dataplat/services/airbyte/connections.py +306 -0
- dataplat/services/airbyte/definitions.py +64 -0
- dataplat/services/airbyte/destinations.py +68 -0
- dataplat/services/airbyte/jobs.py +72 -0
- dataplat/services/airbyte/sources.py +58 -0
- dataplat/services/airbyte/tags.py +120 -0
- dataplat/services/airbyte/workspaces.py +47 -0
- dataplat/services/aws/__init__.py +1 -0
- dataplat/services/aws/auth.py +80 -0
- dataplat/services/db/__init__.py +1 -0
- dataplat/services/db/connection.py +169 -0
- dataplat/services/db/describe.py +1034 -0
- dataplat/services/db/long_queries.py +378 -0
- dataplat/services/db/orphans.py +429 -0
- dataplat/services/db/role.py +812 -0
- dataplat/services/db/role_admin.py +458 -0
- dataplat/services/db/role_dialects.py +521 -0
- dataplat/services/db/targets.py +128 -0
- dataplat/services/db/top_tables.py +193 -0
- dataplat/services/superset/__init__.py +1 -0
- dataplat/services/superset/client.py +319 -0
- dataplat-0.1.0.dist-info/METADATA +326 -0
- dataplat-0.1.0.dist-info/RECORD +85 -0
- dataplat-0.1.0.dist-info/WHEEL +4 -0
- dataplat-0.1.0.dist-info/entry_points.txt +2 -0
- dataplat-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
"""AWS RDS monitoring commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from math import ceil
|
|
10
|
+
from shutil import get_terminal_size
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from botocore.exceptions import ClientError
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from dataplat.cli.cloud.aws._common import (
|
|
17
|
+
JsonOption,
|
|
18
|
+
cli_session,
|
|
19
|
+
make_table,
|
|
20
|
+
profile_option,
|
|
21
|
+
region_option,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="rds",
|
|
26
|
+
help="Monitor AWS RDS instances",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
# defaults
|
|
33
|
+
|
|
34
|
+
# CloudWatch metric definitions: (MetricName, Unit, display suffix)
|
|
35
|
+
_CW_METRICS: list[tuple[str, str, str]] = [
|
|
36
|
+
("CPUUtilization", "Percent", "%"),
|
|
37
|
+
("FreeableMemory", "Bytes", "bytes"),
|
|
38
|
+
("FreeStorageSpace", "Bytes", "bytes"),
|
|
39
|
+
("DatabaseConnections", "Count", ""),
|
|
40
|
+
("EBSByteBalance%", "Percent", "%"),
|
|
41
|
+
("EBSIOBalance%", "Percent", "%"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Thresholds for colour-coding (metric_name -> (warn_func, crit_func))
|
|
45
|
+
# Each function receives the metric value and returns True when the threshold
|
|
46
|
+
# is breached. "None" means no threshold is defined for that level.
|
|
47
|
+
_THRESHOLDS: dict[
|
|
48
|
+
str, tuple[Callable[[float], bool] | None, Callable[[float], bool] | None]
|
|
49
|
+
] = {
|
|
50
|
+
"CPUUtilization": (lambda v: v >= 70, lambda v: v >= 90),
|
|
51
|
+
"FreeableMemory": (lambda v: v < 1 * 1024**3, lambda v: v < 512 * 1024**2),
|
|
52
|
+
"FreeStorageSpace": (lambda v: v < 20 * 1024**3, lambda v: v < 5 * 1024**3),
|
|
53
|
+
"DatabaseConnections": (lambda v: v >= 200, lambda v: v >= 400),
|
|
54
|
+
"EBSByteBalance%": (lambda v: v < 40, lambda v: v < 20),
|
|
55
|
+
"EBSIOBalance%": (lambda v: v < 40, lambda v: v < 20),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_session(profile: str | None, region: str | None):
|
|
60
|
+
"""Return a boto3 session with shared AWS auth handling."""
|
|
61
|
+
return cli_session(console, profile, region)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _human_bytes(n: float) -> str:
|
|
65
|
+
"""Return a human-readable byte string."""
|
|
66
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
67
|
+
if abs(n) < 1024:
|
|
68
|
+
return f"{n:.1f} {unit}"
|
|
69
|
+
n /= 1024
|
|
70
|
+
return f"{n:.1f} PB"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _format_value(metric_name: str, value: float) -> str:
|
|
74
|
+
"""Format a raw metric value for display."""
|
|
75
|
+
if "Memory" in metric_name or "Storage" in metric_name:
|
|
76
|
+
return _human_bytes(value)
|
|
77
|
+
if "Percent" in metric_name or metric_name.endswith("%"):
|
|
78
|
+
return f"{value:.1f}%"
|
|
79
|
+
if metric_name == "CPUUtilization":
|
|
80
|
+
return f"{value:.1f}%"
|
|
81
|
+
if metric_name == "DatabaseConnections":
|
|
82
|
+
return str(int(value))
|
|
83
|
+
return f"{value:.2f}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _colorize(metric_name: str, value: float, text: str) -> str:
|
|
87
|
+
"""Wrap *text* in a Rich colour tag based on threshold breaches."""
|
|
88
|
+
thresholds = _THRESHOLDS.get(metric_name)
|
|
89
|
+
if not thresholds:
|
|
90
|
+
return text
|
|
91
|
+
warn_fn, crit_fn = thresholds
|
|
92
|
+
if crit_fn and crit_fn(value):
|
|
93
|
+
return f"[bold red]{text}[/bold red]"
|
|
94
|
+
if warn_fn and warn_fn(value):
|
|
95
|
+
return f"[yellow]{text}[/yellow]"
|
|
96
|
+
return f"[green]{text}[/green]"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _friendly_name(metric_name: str) -> str:
|
|
100
|
+
"""Convert CloudWatch metric names to friendlier labels."""
|
|
101
|
+
mapping = {
|
|
102
|
+
"CPUUtilization": "CPU Utilization",
|
|
103
|
+
"FreeableMemory": "Freeable Memory (RAM)",
|
|
104
|
+
"FreeStorageSpace": "Free Storage Space (Disk)",
|
|
105
|
+
"DatabaseConnections": "Database Connections",
|
|
106
|
+
"EBSByteBalance%": "EBS Byte Balance",
|
|
107
|
+
"EBSIOBalance%": "EBS IO Balance",
|
|
108
|
+
}
|
|
109
|
+
return mapping.get(metric_name, metric_name)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_make_table = make_table
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# shared CLI options
|
|
116
|
+
ProfileOption = profile_option()
|
|
117
|
+
RegionOption = region_option()
|
|
118
|
+
InstanceOption = typer.Option(
|
|
119
|
+
None,
|
|
120
|
+
"--instance",
|
|
121
|
+
"-i",
|
|
122
|
+
help="RDS DB instance identifier. Defaults to DP_RDS_INSTANCE.",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_instance(instance: str | None) -> str:
|
|
127
|
+
"""Resolve the instance flag against DP_RDS_INSTANCE, or exit."""
|
|
128
|
+
resolved = instance or os.getenv("DP_RDS_INSTANCE")
|
|
129
|
+
if not resolved:
|
|
130
|
+
console.print(
|
|
131
|
+
"[red]Error: no RDS instance given. Pass --instance or set "
|
|
132
|
+
"DP_RDS_INSTANCE.[/red]"
|
|
133
|
+
)
|
|
134
|
+
raise typer.Exit(code=1)
|
|
135
|
+
return resolved
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _fetch_metric_summaries(
|
|
139
|
+
cw,
|
|
140
|
+
instance: str,
|
|
141
|
+
start: datetime,
|
|
142
|
+
end: datetime,
|
|
143
|
+
period: int,
|
|
144
|
+
) -> list[tuple[str, dict[str, float] | None]]:
|
|
145
|
+
"""Fetch every metric's summary in a single get_metric_data call."""
|
|
146
|
+
queries: list[dict] = []
|
|
147
|
+
stats = (("avg", "Average"), ("min", "Minimum"), ("max", "Maximum"))
|
|
148
|
+
for i, (metric_name, unit, _suffix) in enumerate(_CW_METRICS):
|
|
149
|
+
for stat_key, stat in stats:
|
|
150
|
+
queries.append(
|
|
151
|
+
{
|
|
152
|
+
"Id": f"m{i}_{stat_key}",
|
|
153
|
+
"MetricStat": {
|
|
154
|
+
"Metric": {
|
|
155
|
+
"Namespace": "AWS/RDS",
|
|
156
|
+
"MetricName": metric_name,
|
|
157
|
+
"Dimensions": [
|
|
158
|
+
{
|
|
159
|
+
"Name": "DBInstanceIdentifier",
|
|
160
|
+
"Value": instance,
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
"Period": period,
|
|
165
|
+
"Stat": stat,
|
|
166
|
+
"Unit": unit,
|
|
167
|
+
},
|
|
168
|
+
"ReturnData": True,
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
resp = cw.get_metric_data(
|
|
172
|
+
MetricDataQueries=queries,
|
|
173
|
+
StartTime=start,
|
|
174
|
+
EndTime=end,
|
|
175
|
+
ScanBy="TimestampAscending",
|
|
176
|
+
)
|
|
177
|
+
by_id: dict[str, list[float]] = {
|
|
178
|
+
r["Id"]: r.get("Values", []) for r in resp.get("MetricDataResults", [])
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
summaries: list[tuple[str, dict[str, float] | None]] = []
|
|
182
|
+
for i, (metric_name, _unit, _suffix) in enumerate(_CW_METRICS):
|
|
183
|
+
avgs = by_id.get(f"m{i}_avg", [])
|
|
184
|
+
if not avgs:
|
|
185
|
+
summaries.append((metric_name, None))
|
|
186
|
+
continue
|
|
187
|
+
mins = by_id.get(f"m{i}_min", []) or avgs
|
|
188
|
+
maxs = by_id.get(f"m{i}_max", []) or avgs
|
|
189
|
+
summaries.append(
|
|
190
|
+
(
|
|
191
|
+
metric_name,
|
|
192
|
+
{
|
|
193
|
+
"latest": avgs[-1],
|
|
194
|
+
"average": sum(avgs) / len(avgs),
|
|
195
|
+
"min": min(mins),
|
|
196
|
+
"max": max(maxs),
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return summaries
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# commands
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command("metrics")
|
|
207
|
+
def metrics(
|
|
208
|
+
instance: str | None = InstanceOption,
|
|
209
|
+
profile: str | None = ProfileOption,
|
|
210
|
+
region: str | None = RegionOption,
|
|
211
|
+
period: int = typer.Option(
|
|
212
|
+
300,
|
|
213
|
+
"--period",
|
|
214
|
+
help="CloudWatch aggregation period in seconds (default 300 = 5 min).",
|
|
215
|
+
),
|
|
216
|
+
hours: float = typer.Option(
|
|
217
|
+
1.0,
|
|
218
|
+
"--hours",
|
|
219
|
+
help="Look-back window in hours (default 1).",
|
|
220
|
+
),
|
|
221
|
+
as_json: bool = JsonOption,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Show key RDS metrics for an instance.
|
|
224
|
+
|
|
225
|
+
Fetches the latest CloudWatch data points for CPU, RAM, Disk,
|
|
226
|
+
Connections, EBS Byte Balance % and EBS IO Balance % — one batched
|
|
227
|
+
get_metric_data call.
|
|
228
|
+
"""
|
|
229
|
+
instance = resolve_instance(instance)
|
|
230
|
+
session = _get_session(profile, region)
|
|
231
|
+
cw = session.client("cloudwatch")
|
|
232
|
+
|
|
233
|
+
now = datetime.now(UTC)
|
|
234
|
+
start = now - timedelta(hours=hours)
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
with console.status("[bold blue]Fetching CloudWatch metrics…[/bold blue]"):
|
|
238
|
+
summaries = _fetch_metric_summaries(cw, instance, start, now, period)
|
|
239
|
+
except ClientError as exc:
|
|
240
|
+
code = exc.response.get("Error", {}).get("Code", "ClientError")
|
|
241
|
+
msg = exc.response.get("Error", {}).get("Message", str(exc))
|
|
242
|
+
console.print(f"[red]{code} on get_metric_data: {msg}[/red]")
|
|
243
|
+
raise typer.Exit(code=1)
|
|
244
|
+
|
|
245
|
+
if as_json:
|
|
246
|
+
payload = [
|
|
247
|
+
{"metric": name, **(summary or {})}
|
|
248
|
+
for name, summary in summaries
|
|
249
|
+
]
|
|
250
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
table = _make_table(f"RDS Metrics — {instance}")
|
|
254
|
+
table.add_column("Metric", style="bold cyan", min_width=24)
|
|
255
|
+
table.add_column("Latest", justify="right", min_width=12, style="bright_white")
|
|
256
|
+
table.add_column("Average", justify="right", min_width=12, style="bright_white")
|
|
257
|
+
table.add_column("Min", justify="right", min_width=12, style="bright_white")
|
|
258
|
+
table.add_column("Max", justify="right", min_width=12, style="bright_white")
|
|
259
|
+
|
|
260
|
+
for metric_name, summary in summaries:
|
|
261
|
+
if summary is None:
|
|
262
|
+
table.add_row(
|
|
263
|
+
_friendly_name(metric_name),
|
|
264
|
+
"[dim]no data[/dim]",
|
|
265
|
+
"[dim]—[/dim]",
|
|
266
|
+
"[dim]—[/dim]",
|
|
267
|
+
"[dim]—[/dim]",
|
|
268
|
+
)
|
|
269
|
+
continue
|
|
270
|
+
latest = summary["latest"]
|
|
271
|
+
table.add_row(
|
|
272
|
+
_friendly_name(metric_name),
|
|
273
|
+
_colorize(metric_name, latest, _format_value(metric_name, latest)),
|
|
274
|
+
_format_value(metric_name, summary["average"]),
|
|
275
|
+
_format_value(metric_name, summary["min"]),
|
|
276
|
+
_format_value(metric_name, summary["max"]),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
console.print()
|
|
280
|
+
console.print(table)
|
|
281
|
+
console.print(
|
|
282
|
+
f"\n[dim]Window: last {hours}h · Period: {period}s · "
|
|
283
|
+
f"Database: {instance}[/dim]"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@app.command("plot")
|
|
288
|
+
def plot(
|
|
289
|
+
instance: str | None = InstanceOption,
|
|
290
|
+
profile: str | None = ProfileOption,
|
|
291
|
+
region: str | None = RegionOption,
|
|
292
|
+
period: int = typer.Option(
|
|
293
|
+
300,
|
|
294
|
+
"--period",
|
|
295
|
+
help="CloudWatch aggregation period in seconds (default 300 = 5 min).",
|
|
296
|
+
),
|
|
297
|
+
hours: float = typer.Option(
|
|
298
|
+
6.0,
|
|
299
|
+
"--hours",
|
|
300
|
+
help="Look-back window in hours (default 6).",
|
|
301
|
+
),
|
|
302
|
+
metric: list[str] | None = typer.Option(
|
|
303
|
+
None,
|
|
304
|
+
"--metric",
|
|
305
|
+
"-m",
|
|
306
|
+
help=(
|
|
307
|
+
"Metric(s) to plot. Can be specified multiple times. "
|
|
308
|
+
"Choices: cpu, memory, storage, connections, ebs-byte, ebs-io. "
|
|
309
|
+
"Defaults to all."
|
|
310
|
+
),
|
|
311
|
+
),
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Plot key RDS metrics over time in the terminal.
|
|
314
|
+
|
|
315
|
+
Renders sparkline-style charts for CPU, RAM, Disk, Connections and
|
|
316
|
+
EBS balance metrics using the last N hours of CloudWatch data.
|
|
317
|
+
"""
|
|
318
|
+
import plotext as plt
|
|
319
|
+
|
|
320
|
+
# Map short names to CW metric names
|
|
321
|
+
_METRIC_ALIASES: dict[str, str] = {
|
|
322
|
+
"cpu": "CPUUtilization",
|
|
323
|
+
"memory": "FreeableMemory",
|
|
324
|
+
"storage": "FreeStorageSpace",
|
|
325
|
+
"connections": "DatabaseConnections",
|
|
326
|
+
"ebs-byte": "EBSByteBalance%",
|
|
327
|
+
"ebs-io": "EBSIOBalance%",
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
# Determine which metrics to plot
|
|
331
|
+
if metric:
|
|
332
|
+
selected = []
|
|
333
|
+
for m in metric:
|
|
334
|
+
key = m.lower().strip()
|
|
335
|
+
if key not in _METRIC_ALIASES:
|
|
336
|
+
console.print(
|
|
337
|
+
f"[red]Unknown metric '{m}'. "
|
|
338
|
+
f"Choose from: {', '.join(_METRIC_ALIASES)}[/red]"
|
|
339
|
+
)
|
|
340
|
+
raise typer.Exit(code=1)
|
|
341
|
+
selected.append(_METRIC_ALIASES[key])
|
|
342
|
+
cw_metrics = [row for row in _CW_METRICS if row[0] in selected]
|
|
343
|
+
else:
|
|
344
|
+
cw_metrics = list(_CW_METRICS)
|
|
345
|
+
|
|
346
|
+
instance = resolve_instance(instance)
|
|
347
|
+
session = _get_session(profile, region)
|
|
348
|
+
cw = session.client("cloudwatch")
|
|
349
|
+
|
|
350
|
+
now = datetime.now(UTC)
|
|
351
|
+
start = now - timedelta(hours=hours)
|
|
352
|
+
|
|
353
|
+
series: list[tuple[str, list[datetime], list[float]]] = []
|
|
354
|
+
|
|
355
|
+
with console.status("[bold blue]Fetching CloudWatch metrics…[/bold blue]"):
|
|
356
|
+
for metric_name, unit, _suffix in cw_metrics:
|
|
357
|
+
try:
|
|
358
|
+
resp = cw.get_metric_statistics(
|
|
359
|
+
Namespace="AWS/RDS",
|
|
360
|
+
MetricName=metric_name,
|
|
361
|
+
Dimensions=[
|
|
362
|
+
{"Name": "DBInstanceIdentifier", "Value": instance},
|
|
363
|
+
],
|
|
364
|
+
StartTime=start,
|
|
365
|
+
EndTime=now,
|
|
366
|
+
Period=period,
|
|
367
|
+
Statistics=["Average"],
|
|
368
|
+
Unit=unit,
|
|
369
|
+
)
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
console.print(f"[red]Error fetching {metric_name}: {exc}[/red]")
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
datapoints = resp.get("Datapoints", [])
|
|
375
|
+
if not datapoints:
|
|
376
|
+
console.print(
|
|
377
|
+
f"[yellow]No data for {_friendly_name(metric_name)}[/yellow]"
|
|
378
|
+
)
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
datapoints.sort(key=lambda d: d["Timestamp"])
|
|
382
|
+
timestamps = [d["Timestamp"] for d in datapoints]
|
|
383
|
+
values = [d["Average"] for d in datapoints]
|
|
384
|
+
series.append((metric_name, timestamps, values))
|
|
385
|
+
|
|
386
|
+
if not series:
|
|
387
|
+
console.print("[yellow]No metric data to plot.[/yellow]")
|
|
388
|
+
raise typer.Exit()
|
|
389
|
+
|
|
390
|
+
def _even_ticks(lower: float, upper: float, count: int) -> list[float]:
|
|
391
|
+
if count <= 1 or lower == upper:
|
|
392
|
+
return [lower]
|
|
393
|
+
step = (upper - lower) / (count - 1)
|
|
394
|
+
return [lower + step * i for i in range(count)]
|
|
395
|
+
|
|
396
|
+
def _gb_ticks(values: list[float]) -> tuple[list[float], list[str], float, float]:
|
|
397
|
+
v_min = min(values)
|
|
398
|
+
v_max = max(values)
|
|
399
|
+
if v_min == v_max:
|
|
400
|
+
pad = max(0.5, v_max * 0.05) if v_max != 0 else 0.5
|
|
401
|
+
lower, upper = v_min - pad, v_max + pad
|
|
402
|
+
else:
|
|
403
|
+
pad = (v_max - v_min) * 0.10
|
|
404
|
+
lower, upper = v_min - pad, v_max + pad
|
|
405
|
+
if lower < 0:
|
|
406
|
+
lower = 0.0
|
|
407
|
+
ticks = _even_ticks(lower, upper, 5)
|
|
408
|
+
span = upper - lower
|
|
409
|
+
decimals = 0 if span >= 50 else (1 if span >= 5 else 2)
|
|
410
|
+
labels = [f"{tick:.{decimals}f}" for tick in ticks]
|
|
411
|
+
return ticks, labels, lower, upper
|
|
412
|
+
|
|
413
|
+
def _x_ticks(
|
|
414
|
+
timestamps: list[datetime], max_ticks: int = 5
|
|
415
|
+
) -> tuple[list[float], list[str]]:
|
|
416
|
+
n = len(timestamps)
|
|
417
|
+
if n == 0:
|
|
418
|
+
return [], []
|
|
419
|
+
if n == 1:
|
|
420
|
+
return [0], [timestamps[0].strftime("%H:%M")]
|
|
421
|
+
|
|
422
|
+
tick_count = min(max_ticks, n)
|
|
423
|
+
positions = _even_ticks(0.0, float(n - 1), tick_count)
|
|
424
|
+
|
|
425
|
+
same_day = timestamps[0].date() == timestamps[-1].date()
|
|
426
|
+
label_fmt = "%H:%M" if same_day else "%m/%d %H:%M"
|
|
427
|
+
labels = [timestamps[round(pos)].strftime(label_fmt) for pos in positions]
|
|
428
|
+
return positions, labels
|
|
429
|
+
|
|
430
|
+
def _value_ticks(
|
|
431
|
+
values: list[float], target_ticks: int = 5, clamp_zero: bool = False
|
|
432
|
+
) -> tuple[list[float], list[str], float, float]:
|
|
433
|
+
v_min = min(values)
|
|
434
|
+
v_max = max(values)
|
|
435
|
+
if v_min == v_max:
|
|
436
|
+
pad = max(1.0, abs(v_max) * 0.1) if v_max != 0 else 1.0
|
|
437
|
+
lower, upper = v_min - pad, v_max + pad
|
|
438
|
+
else:
|
|
439
|
+
pad = (v_max - v_min) * 0.10
|
|
440
|
+
lower, upper = v_min - pad, v_max + pad
|
|
441
|
+
if clamp_zero:
|
|
442
|
+
lower = max(0.0, lower)
|
|
443
|
+
ticks = _even_ticks(lower, upper, max(2, target_ticks))
|
|
444
|
+
span = upper - lower
|
|
445
|
+
decimals = 0 if span >= 50 else (1 if span >= 5 else 2)
|
|
446
|
+
labels = [f"{tick:.{decimals}f}" for tick in ticks]
|
|
447
|
+
return ticks, labels, lower, upper
|
|
448
|
+
|
|
449
|
+
def _format_plot_value(metric_name: str, value: float) -> str:
|
|
450
|
+
if metric_name == "CPUUtilization" or metric_name.endswith("%"):
|
|
451
|
+
value = min(value, 100.0)
|
|
452
|
+
return f"{value:.1f}%"
|
|
453
|
+
if "Memory" in metric_name or "Storage" in metric_name:
|
|
454
|
+
return f"{value:.1f} GB"
|
|
455
|
+
if metric_name == "DatabaseConnections":
|
|
456
|
+
return str(int(round(value)))
|
|
457
|
+
return f"{value:.2f}"
|
|
458
|
+
|
|
459
|
+
# Render metrics in a 3x2 grid (for the default 6 metrics).
|
|
460
|
+
num = len(series)
|
|
461
|
+
cols = 1 if num == 1 else 2
|
|
462
|
+
rows = ceil(num / cols)
|
|
463
|
+
plt.subplots(rows, cols)
|
|
464
|
+
plt.theme("dark")
|
|
465
|
+
term = get_terminal_size((140, 42))
|
|
466
|
+
plt.plotsize(max(60, term.columns - 2), max(18, term.lines - 6))
|
|
467
|
+
subplot_width = max(24, (term.columns - 2) // cols)
|
|
468
|
+
x_tick_target = max(4, min(8, subplot_width // 12))
|
|
469
|
+
# plotext API changed across versions (`cls` vs `clt`)
|
|
470
|
+
plt.clt()
|
|
471
|
+
|
|
472
|
+
for idx, (metric_name, timestamps, values) in enumerate(series, start=1):
|
|
473
|
+
row = (idx - 1) // cols + 1
|
|
474
|
+
col = (idx - 1) % cols + 1
|
|
475
|
+
plt.subplot(row, col)
|
|
476
|
+
|
|
477
|
+
# Convert to human-readable values for memory/storage
|
|
478
|
+
display_values = values
|
|
479
|
+
y_label = _friendly_name(metric_name)
|
|
480
|
+
if "Memory" in metric_name or "Storage" in metric_name:
|
|
481
|
+
display_values = [v / 1024**3 for v in values]
|
|
482
|
+
y_label += " (GB)"
|
|
483
|
+
elif metric_name == "CPUUtilization" or metric_name.endswith("%"):
|
|
484
|
+
y_label += " (%)"
|
|
485
|
+
|
|
486
|
+
# Use numeric X coordinates and custom tick labels to avoid
|
|
487
|
+
# plotext date parsing requirements (which vary by version).
|
|
488
|
+
x_values = list(range(len(timestamps)))
|
|
489
|
+
tick_positions, tick_labels = _x_ticks(timestamps, max_ticks=x_tick_target)
|
|
490
|
+
|
|
491
|
+
# Render as dot chart to emphasize datapoints.
|
|
492
|
+
plt.scatter(x_values, display_values, marker="dot")
|
|
493
|
+
if len(x_values) > 1:
|
|
494
|
+
plt.xlim(x_values[0], x_values[-1])
|
|
495
|
+
plt.xticks(tick_positions, tick_labels)
|
|
496
|
+
|
|
497
|
+
y_tick_values: list[float]
|
|
498
|
+
y_tick_labels: list[str]
|
|
499
|
+
y_plot_min = min(display_values)
|
|
500
|
+
y_plot_max = max(display_values)
|
|
501
|
+
if metric_name == "CPUUtilization" or metric_name.endswith("%"):
|
|
502
|
+
capped_values = [min(v, 100.0) for v in display_values]
|
|
503
|
+
y_min = min(capped_values)
|
|
504
|
+
y_max = max(capped_values)
|
|
505
|
+
if y_min == y_max:
|
|
506
|
+
pad = max(0.5, y_max * 0.1 if y_max != 0 else 1.0)
|
|
507
|
+
y_min = max(0.0, y_min - pad)
|
|
508
|
+
y_max = min(100.0, y_max + pad)
|
|
509
|
+
else:
|
|
510
|
+
pad = (y_max - y_min) * 0.10
|
|
511
|
+
y_min = max(0.0, y_min - pad)
|
|
512
|
+
y_max = min(100.0, y_max + pad)
|
|
513
|
+
if y_min >= y_max:
|
|
514
|
+
y_min = max(0.0, y_max - 1.0)
|
|
515
|
+
y_tick_values = _even_ticks(y_min, y_max, 6)
|
|
516
|
+
span = y_max - y_min
|
|
517
|
+
decimals = 0 if span >= 50 else (1 if span >= 5 else 2)
|
|
518
|
+
y_tick_labels = [f"{tick:.{decimals}f}" for tick in y_tick_values]
|
|
519
|
+
y_plot_min, y_plot_max = y_min, y_max
|
|
520
|
+
elif "Memory" in metric_name or "Storage" in metric_name:
|
|
521
|
+
y_ticks, y_labels, y_min, y_max = _gb_ticks(display_values)
|
|
522
|
+
y_tick_values = y_ticks
|
|
523
|
+
y_tick_labels = y_labels
|
|
524
|
+
y_plot_min, y_plot_max = y_min, y_max
|
|
525
|
+
else:
|
|
526
|
+
y_ticks, y_labels, y_min, y_max = _value_ticks(
|
|
527
|
+
display_values,
|
|
528
|
+
target_ticks=5,
|
|
529
|
+
clamp_zero=metric_name == "DatabaseConnections",
|
|
530
|
+
)
|
|
531
|
+
y_tick_values = y_ticks
|
|
532
|
+
y_tick_labels = y_labels
|
|
533
|
+
y_plot_min, y_plot_max = y_min, y_max
|
|
534
|
+
|
|
535
|
+
# Highlight extrema per chart.
|
|
536
|
+
min_idx = min(range(len(display_values)), key=display_values.__getitem__)
|
|
537
|
+
max_idx = max(range(len(display_values)), key=display_values.__getitem__)
|
|
538
|
+
min_x, min_y = x_values[min_idx], display_values[min_idx]
|
|
539
|
+
max_x, max_y = x_values[max_idx], display_values[max_idx]
|
|
540
|
+
|
|
541
|
+
y_axis_lower = y_plot_min
|
|
542
|
+
y_axis_upper = y_plot_max
|
|
543
|
+
if min_idx != max_idx:
|
|
544
|
+
y_span = max(y_plot_max - y_plot_min, 1e-9)
|
|
545
|
+
y_offset = max(
|
|
546
|
+
y_span * 0.045,
|
|
547
|
+
(
|
|
548
|
+
1.0
|
|
549
|
+
if metric_name == "CPUUtilization" or metric_name.endswith("%")
|
|
550
|
+
else 0.1
|
|
551
|
+
),
|
|
552
|
+
)
|
|
553
|
+
x_offset = max(1, (len(x_values) - 1) // 28)
|
|
554
|
+
x_mid = (x_values[0] + x_values[-1]) / 2
|
|
555
|
+
|
|
556
|
+
# Prefer labels away from points and keep them inside the plot by
|
|
557
|
+
# choosing side-aware horizontal alignment.
|
|
558
|
+
if max_x >= x_mid:
|
|
559
|
+
max_label_x = max(max_x - x_offset, x_values[0])
|
|
560
|
+
max_align = "right"
|
|
561
|
+
else:
|
|
562
|
+
max_label_x = min(max_x + x_offset, x_values[-1])
|
|
563
|
+
max_align = "left"
|
|
564
|
+
|
|
565
|
+
if min_x >= x_mid:
|
|
566
|
+
min_label_x = max(min_x - x_offset, x_values[0])
|
|
567
|
+
min_align = "right"
|
|
568
|
+
else:
|
|
569
|
+
min_label_x = min(min_x + x_offset, x_values[-1])
|
|
570
|
+
min_align = "left"
|
|
571
|
+
|
|
572
|
+
# Always keep MAX above and MIN below their markers.
|
|
573
|
+
max_label_y = max_y + y_offset
|
|
574
|
+
min_label_y = min_y - y_offset
|
|
575
|
+
|
|
576
|
+
# Add chart space only when label coordinates would clip.
|
|
577
|
+
y_pad = max(y_span * 0.02, y_offset * 0.25)
|
|
578
|
+
if max_label_y + y_pad > y_axis_upper:
|
|
579
|
+
y_axis_upper = max_label_y + y_pad
|
|
580
|
+
if min_label_y - y_pad < y_axis_lower:
|
|
581
|
+
y_axis_lower = min_label_y - y_pad
|
|
582
|
+
if metric_name == "CPUUtilization" or metric_name.endswith("%"):
|
|
583
|
+
y_axis_lower = max(y_axis_lower, 0.0)
|
|
584
|
+
y_axis_upper = min(y_axis_upper, 100.0)
|
|
585
|
+
max_label_y = min(max_label_y, y_axis_upper)
|
|
586
|
+
min_label_y = max(min_label_y, y_axis_lower)
|
|
587
|
+
|
|
588
|
+
plt.scatter([max_x], [max_y], marker="x", color="green")
|
|
589
|
+
plt.scatter([min_x], [min_y], marker="x", color="red")
|
|
590
|
+
plt.text(
|
|
591
|
+
f"MAX {_format_plot_value(metric_name, max_y)}",
|
|
592
|
+
max_label_x,
|
|
593
|
+
max_label_y,
|
|
594
|
+
color="green",
|
|
595
|
+
alignment=max_align,
|
|
596
|
+
)
|
|
597
|
+
plt.text(
|
|
598
|
+
f"MIN {_format_plot_value(metric_name, min_y)}",
|
|
599
|
+
min_label_x,
|
|
600
|
+
min_label_y,
|
|
601
|
+
color="red",
|
|
602
|
+
alignment=min_align,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
plt.ylim(y_axis_lower, y_axis_upper)
|
|
606
|
+
plt.yticks(y_tick_values, y_tick_labels)
|
|
607
|
+
plt.title(y_label)
|
|
608
|
+
plt.show()
|
|
609
|
+
|
|
610
|
+
console.print(
|
|
611
|
+
f"\n[dim]Window: last {hours}h · Period: {period}s · "
|
|
612
|
+
f"Database: {instance}[/dim]"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
@app.command("list")
|
|
617
|
+
def list_instances(
|
|
618
|
+
profile: str | None = ProfileOption,
|
|
619
|
+
region: str | None = RegionOption,
|
|
620
|
+
as_json: bool = JsonOption,
|
|
621
|
+
) -> None:
|
|
622
|
+
"""List all RDS instances in the account."""
|
|
623
|
+
session = _get_session(profile, region)
|
|
624
|
+
rds = session.client("rds")
|
|
625
|
+
|
|
626
|
+
try:
|
|
627
|
+
with console.status("[bold blue]Fetching RDS instances…[/bold blue]"):
|
|
628
|
+
paginator = rds.get_paginator("describe_db_instances")
|
|
629
|
+
instances = []
|
|
630
|
+
for page in paginator.paginate():
|
|
631
|
+
instances.extend(page["DBInstances"])
|
|
632
|
+
except ClientError as exc:
|
|
633
|
+
code = exc.response.get("Error", {}).get("Code", "ClientError")
|
|
634
|
+
msg = exc.response.get("Error", {}).get("Message", str(exc))
|
|
635
|
+
console.print(f"[red]{code} on describe_db_instances: {msg}[/red]")
|
|
636
|
+
raise typer.Exit(code=1)
|
|
637
|
+
|
|
638
|
+
if as_json:
|
|
639
|
+
payload = [
|
|
640
|
+
{
|
|
641
|
+
"identifier": inst["DBInstanceIdentifier"],
|
|
642
|
+
"class": inst["DBInstanceClass"],
|
|
643
|
+
"engine": f"{inst['Engine']} {inst.get('EngineVersion', '')}".strip(),
|
|
644
|
+
"status": inst["DBInstanceStatus"],
|
|
645
|
+
"storage_gb": inst.get("AllocatedStorage"),
|
|
646
|
+
"multi_az": bool(inst.get("MultiAZ")),
|
|
647
|
+
}
|
|
648
|
+
for inst in sorted(instances, key=lambda i: i["DBInstanceIdentifier"])
|
|
649
|
+
]
|
|
650
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
if not instances:
|
|
654
|
+
console.print("[yellow]No RDS instances found.[/yellow]")
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
table = _make_table("RDS Instances")
|
|
658
|
+
table.add_column("Identifier", style="bold cyan")
|
|
659
|
+
table.add_column("Class")
|
|
660
|
+
table.add_column("Engine")
|
|
661
|
+
table.add_column("Status")
|
|
662
|
+
table.add_column("Storage (GB)", justify="right", style="bright_white")
|
|
663
|
+
table.add_column("Multi-AZ", justify="center")
|
|
664
|
+
|
|
665
|
+
for inst in sorted(instances, key=lambda i: i["DBInstanceIdentifier"]):
|
|
666
|
+
status = inst["DBInstanceStatus"]
|
|
667
|
+
status_styled = (
|
|
668
|
+
f"[green]{status}[/green]"
|
|
669
|
+
if status == "available"
|
|
670
|
+
else f"[yellow]{status}[/yellow]"
|
|
671
|
+
)
|
|
672
|
+
table.add_row(
|
|
673
|
+
inst["DBInstanceIdentifier"],
|
|
674
|
+
inst["DBInstanceClass"],
|
|
675
|
+
f"{inst['Engine']} {inst.get('EngineVersion', '')}",
|
|
676
|
+
status_styled,
|
|
677
|
+
str(inst.get("AllocatedStorage", "—")),
|
|
678
|
+
"Yes" if inst.get("MultiAZ") else "No",
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
console.print()
|
|
682
|
+
console.print(table)
|
|
683
|
+
console.print(
|
|
684
|
+
f"\n[dim]{len(instances)} instance(s) · "
|
|
685
|
+
f"Profile: {profile} · Region: {region}[/dim]"
|
|
686
|
+
)
|