devops-aws-costlens 1.0.1__tar.gz → 1.2.0__tar.gz
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.
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/PKG-INFO +1 -1
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/__init__.py +1 -1
- devops_aws_costlens-1.2.0/aws_costlens/app_controller.py +505 -0
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/aws_api.py +2 -1
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/cli_commands.py +16 -9
- devops_aws_costlens-1.2.0/aws_costlens/common_utils.py +323 -0
- devops_aws_costlens-1.2.0/aws_costlens/console_setup.py +46 -0
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/cost_controller.py +105 -64
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/profiles_controller.py +7 -4
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/report_exporter.py +2 -1
- devops_aws_costlens-1.2.0/aws_costlens/visuals.py +87 -0
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/pyproject.toml +1 -1
- devops_aws_costlens-1.0.1/aws_costlens/app_controller.py +0 -344
- devops_aws_costlens-1.0.1/aws_costlens/common_utils.py +0 -309
- devops_aws_costlens-1.0.1/aws_costlens/visuals.py +0 -38
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/.gitignore +0 -0
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/LICENSE +0 -0
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/README.md +0 -0
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/main.py +0 -0
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/models.py +0 -0
- {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/pdf_renderer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devops-aws-costlens
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: AWS Cost Intelligence Tool - Terminal-based cost and resource dashboard.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Calza36/aws-costlens
|
|
6
6
|
Project-URL: Source, https://github.com/Calza36/aws-costlens
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""Main application controller for AWS CostLens."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import track
|
|
12
|
+
from rich.status import Status
|
|
13
|
+
from rich.table import Column, Table
|
|
14
|
+
|
|
15
|
+
# Setup UTF-8 console for Windows
|
|
16
|
+
from aws_costlens.console_setup import setup_console
|
|
17
|
+
setup_console()
|
|
18
|
+
|
|
19
|
+
from aws_costlens.aws_api import (
|
|
20
|
+
get_accessible_regions,
|
|
21
|
+
get_account_id,
|
|
22
|
+
get_aws_profiles,
|
|
23
|
+
get_budgets,
|
|
24
|
+
get_stopped_instances,
|
|
25
|
+
get_unused_eips,
|
|
26
|
+
get_unused_volumes,
|
|
27
|
+
get_untagged_resources,
|
|
28
|
+
)
|
|
29
|
+
from aws_costlens.cost_controller import (
|
|
30
|
+
export_to_csv,
|
|
31
|
+
export_to_json,
|
|
32
|
+
get_cost_data,
|
|
33
|
+
get_trend,
|
|
34
|
+
)
|
|
35
|
+
from aws_costlens.report_exporter import ExportHandler
|
|
36
|
+
from aws_costlens.common_utils import (
|
|
37
|
+
clean_rich_tags,
|
|
38
|
+
export_audit_report_to_csv,
|
|
39
|
+
export_audit_report_to_json,
|
|
40
|
+
export_audit_report_to_pdf,
|
|
41
|
+
export_cost_dashboard_to_pdf,
|
|
42
|
+
export_trend_data_to_json,
|
|
43
|
+
)
|
|
44
|
+
from aws_costlens.profiles_controller import process_combined_profiles, process_single_profile
|
|
45
|
+
from aws_costlens.visuals import create_trend_bars
|
|
46
|
+
from aws_costlens.models import ProfileData
|
|
47
|
+
|
|
48
|
+
console = Console(force_terminal=True, legacy_windows=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_dashboard(
|
|
52
|
+
profiles: Optional[List[str]] = None,
|
|
53
|
+
regions: Optional[List[str]] = None,
|
|
54
|
+
all_profiles: bool = False,
|
|
55
|
+
combine: bool = False,
|
|
56
|
+
audit: bool = False,
|
|
57
|
+
trend: bool = False,
|
|
58
|
+
report_name: Optional[str] = None,
|
|
59
|
+
report_types: Optional[List[str]] = None,
|
|
60
|
+
output_dir: Optional[str] = None,
|
|
61
|
+
s3_bucket: Optional[str] = None,
|
|
62
|
+
s3_prefix: Optional[str] = None,
|
|
63
|
+
time_range: Optional[Union[int, str]] = None,
|
|
64
|
+
tags: Optional[Dict[str, str]] = None,
|
|
65
|
+
) -> int:
|
|
66
|
+
"""
|
|
67
|
+
Run the AWS CostLens application.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
profiles: List of AWS profiles to process
|
|
71
|
+
regions: List of regions to check
|
|
72
|
+
all_profiles: Process all available profiles
|
|
73
|
+
combine: Merge results from multiple profiles
|
|
74
|
+
audit: Run resource scan
|
|
75
|
+
trend: Show cost history
|
|
76
|
+
report_name: Base name for report files
|
|
77
|
+
report_types: List of export formats (pdf, csv, json)
|
|
78
|
+
output_dir: Output directory for reports
|
|
79
|
+
s3_bucket: Optional S3 bucket for uploads
|
|
80
|
+
s3_prefix: Optional S3 path
|
|
81
|
+
time_range: Custom time range
|
|
82
|
+
tags: Tag filters
|
|
83
|
+
"""
|
|
84
|
+
# Initialize profiles
|
|
85
|
+
with Status("[bright_cyan]🔄 Connecting to AWS...", spinner="dots12", speed=0.1):
|
|
86
|
+
profiles_to_use = _initialize_profiles(profiles, all_profiles)
|
|
87
|
+
if not profiles_to_use:
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
# Run audit report if requested
|
|
91
|
+
if audit:
|
|
92
|
+
_run_audit_report(profiles_to_use, regions, report_name, report_types, output_dir, s3_bucket, s3_prefix)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
# Run trend analysis if requested
|
|
96
|
+
if trend:
|
|
97
|
+
_run_trend_analysis(profiles_to_use, combine, report_name, report_types, output_dir, s3_bucket, s3_prefix, tags)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
# Run main cost dashboard
|
|
101
|
+
_run_cost_dashboard(
|
|
102
|
+
profiles_to_use=profiles_to_use,
|
|
103
|
+
user_regions=regions,
|
|
104
|
+
combine=combine,
|
|
105
|
+
report_name=report_name,
|
|
106
|
+
report_types=report_types,
|
|
107
|
+
output_dir=output_dir,
|
|
108
|
+
s3_bucket=s3_bucket,
|
|
109
|
+
s3_prefix=s3_prefix,
|
|
110
|
+
time_range=time_range,
|
|
111
|
+
tags=tags,
|
|
112
|
+
)
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _initialize_profiles(profiles: Optional[List[str]], all_profiles: bool) -> List[str]:
|
|
117
|
+
"""Initialize AWS profiles based on arguments."""
|
|
118
|
+
available_profiles = get_aws_profiles()
|
|
119
|
+
if not available_profiles:
|
|
120
|
+
console.log("[bold red]No AWS profiles found. Please configure AWS CLI first.[/]")
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
profiles_to_use = []
|
|
124
|
+
if profiles:
|
|
125
|
+
for profile in profiles:
|
|
126
|
+
if profile in available_profiles:
|
|
127
|
+
profiles_to_use.append(profile)
|
|
128
|
+
else:
|
|
129
|
+
console.log(f"[yellow]Warning: Profile '{profile}' not found in AWS configuration[/]")
|
|
130
|
+
if not profiles_to_use:
|
|
131
|
+
console.log("[bold red]None of the specified profiles were found in AWS configuration.[/]")
|
|
132
|
+
return []
|
|
133
|
+
elif all_profiles:
|
|
134
|
+
profiles_to_use = available_profiles
|
|
135
|
+
else:
|
|
136
|
+
if "default" in available_profiles:
|
|
137
|
+
profiles_to_use = ["default"]
|
|
138
|
+
else:
|
|
139
|
+
profiles_to_use = available_profiles
|
|
140
|
+
console.log("[yellow]No default profile found. Using all available profiles.[/]")
|
|
141
|
+
|
|
142
|
+
return profiles_to_use
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _run_audit_report(
|
|
146
|
+
profiles_to_use: List[str],
|
|
147
|
+
regions: Optional[List[str]],
|
|
148
|
+
report_name: Optional[str],
|
|
149
|
+
report_types: Optional[List[str]],
|
|
150
|
+
output_dir: Optional[str],
|
|
151
|
+
s3_bucket: Optional[str],
|
|
152
|
+
s3_prefix: Optional[str],
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Generate and export a resource scan report."""
|
|
155
|
+
console.print("[bold bright_green]⚡ Scanning resources...[/]")
|
|
156
|
+
console.print("[dim]Checking: EC2, RDS, Lambda, ELBv2[/]\n")
|
|
157
|
+
|
|
158
|
+
table = Table(
|
|
159
|
+
Column("Profile", justify="center"),
|
|
160
|
+
Column("Account ID", justify="center"),
|
|
161
|
+
Column("Untagged Resources"),
|
|
162
|
+
Column("Stopped EC2 Instances"),
|
|
163
|
+
Column("Unused Volumes"),
|
|
164
|
+
Column("Unused EIPs"),
|
|
165
|
+
Column("Budget Alerts"),
|
|
166
|
+
title="🔍 Resource Scan Results",
|
|
167
|
+
show_lines=True,
|
|
168
|
+
box=box.ASCII_DOUBLE_HEAD,
|
|
169
|
+
style="bright_green",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
audit_data = []
|
|
173
|
+
raw_audit_data = []
|
|
174
|
+
nl = "\n"
|
|
175
|
+
comma_nl = ",\n"
|
|
176
|
+
|
|
177
|
+
for profile in profiles_to_use:
|
|
178
|
+
session = boto3.Session(profile_name=profile)
|
|
179
|
+
account_id = get_account_id(session) or "Unknown"
|
|
180
|
+
check_regions = regions or get_accessible_regions(session)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
untagged = get_untagged_resources(session, check_regions)
|
|
184
|
+
anomalies = []
|
|
185
|
+
for service, region_map in untagged.items():
|
|
186
|
+
if region_map:
|
|
187
|
+
service_block = f"[bright_yellow]{service}[/]:\n"
|
|
188
|
+
for region, ids in region_map.items():
|
|
189
|
+
if ids:
|
|
190
|
+
ids_block = "\n".join(f"[orange1]{res_id}[/]" for res_id in ids)
|
|
191
|
+
service_block += f"\n{region}:\n{ids_block}\n"
|
|
192
|
+
anomalies.append(service_block)
|
|
193
|
+
if not any(region_map for region_map in untagged.values()):
|
|
194
|
+
anomalies = ["None"]
|
|
195
|
+
except Exception as e:
|
|
196
|
+
anomalies = [f"Error: {str(e)}"]
|
|
197
|
+
|
|
198
|
+
stopped = get_stopped_instances(session, check_regions)
|
|
199
|
+
stopped_list = [f"{r}:\n[gold1]{nl.join(ids)}[/]" for r, ids in stopped.items()] or ["None"]
|
|
200
|
+
|
|
201
|
+
unused_vols = get_unused_volumes(session, check_regions)
|
|
202
|
+
vols_list = [f"{r}:\n[dark_orange]{nl.join(ids)}[/]" for r, ids in unused_vols.items()] or ["None"]
|
|
203
|
+
|
|
204
|
+
unused_eips = get_unused_eips(session, check_regions)
|
|
205
|
+
eips_list = [f"{r}:\n{comma_nl.join(ids)}" for r, ids in unused_eips.items()] or ["None"]
|
|
206
|
+
|
|
207
|
+
budget_data = get_budgets(session)
|
|
208
|
+
alerts = []
|
|
209
|
+
for b in budget_data:
|
|
210
|
+
if b["actual"] > b["limit"]:
|
|
211
|
+
alerts.append(f"[red1]{b['name']}[/]: ${b['actual']:.2f} > ${b['limit']:.2f}")
|
|
212
|
+
if not alerts:
|
|
213
|
+
alerts = ["No budgets exceeded"]
|
|
214
|
+
|
|
215
|
+
audit_data.append({
|
|
216
|
+
"profile": profile,
|
|
217
|
+
"account_id": account_id,
|
|
218
|
+
"untagged_resources": clean_rich_tags("\n".join(anomalies)),
|
|
219
|
+
"stopped_instances": clean_rich_tags("\n".join(stopped_list)),
|
|
220
|
+
"unused_volumes": clean_rich_tags("\n".join(vols_list)),
|
|
221
|
+
"unused_eips": clean_rich_tags("\n".join(eips_list)),
|
|
222
|
+
"budget_alerts": clean_rich_tags("\n".join(alerts)),
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
raw_audit_data.append({
|
|
226
|
+
"profile": profile,
|
|
227
|
+
"account_id": account_id,
|
|
228
|
+
"untagged_resources": untagged,
|
|
229
|
+
"stopped_instances": stopped,
|
|
230
|
+
"unused_volumes": unused_vols,
|
|
231
|
+
"unused_eips": unused_eips,
|
|
232
|
+
"budget_alerts": budget_data,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
table.add_row(
|
|
236
|
+
f"[dark_magenta]{profile}[/]",
|
|
237
|
+
account_id,
|
|
238
|
+
"\n".join(anomalies),
|
|
239
|
+
"\n".join(stopped_list),
|
|
240
|
+
"\n".join(vols_list),
|
|
241
|
+
"\n".join(eips_list),
|
|
242
|
+
"\n".join(alerts),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
console.print(table)
|
|
246
|
+
# Note already shown at the start of scan
|
|
247
|
+
|
|
248
|
+
# Export if requested
|
|
249
|
+
if report_name and report_types:
|
|
250
|
+
export_handler = ExportHandler(
|
|
251
|
+
output_dir=output_dir or os.getcwd(),
|
|
252
|
+
s3_bucket=s3_bucket,
|
|
253
|
+
s3_prefix=s3_prefix,
|
|
254
|
+
profile=profiles_to_use[0] if profiles_to_use else None,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
for report_type in report_types:
|
|
258
|
+
if report_type == "csv":
|
|
259
|
+
csv_content = export_audit_report_to_csv(audit_data)
|
|
260
|
+
export_handler.save_csv(csv_content, f"{report_name}.csv")
|
|
261
|
+
elif report_type == "json":
|
|
262
|
+
json_content = export_audit_report_to_json(raw_audit_data)
|
|
263
|
+
export_handler.save_json(json_content, f"{report_name}.json")
|
|
264
|
+
elif report_type == "pdf":
|
|
265
|
+
pdf_bytes = export_audit_report_to_pdf(audit_data, report_name)
|
|
266
|
+
export_handler.save_pdf(pdf_bytes, f"{report_name}.pdf")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _run_trend_analysis(
|
|
270
|
+
profiles_to_use: List[str],
|
|
271
|
+
combine: bool,
|
|
272
|
+
report_name: Optional[str],
|
|
273
|
+
report_types: Optional[List[str]],
|
|
274
|
+
output_dir: Optional[str],
|
|
275
|
+
s3_bucket: Optional[str],
|
|
276
|
+
s3_prefix: Optional[str],
|
|
277
|
+
tags: Optional[Dict[str, str]],
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Analyze and display cost trends."""
|
|
280
|
+
console.print("[bold bright_magenta]📊 Loading cost history...[/]")
|
|
281
|
+
raw_trend_data = []
|
|
282
|
+
|
|
283
|
+
if combine:
|
|
284
|
+
account_profiles = defaultdict(list)
|
|
285
|
+
for profile in profiles_to_use:
|
|
286
|
+
try:
|
|
287
|
+
session = boto3.Session(profile_name=profile)
|
|
288
|
+
account_id = get_account_id(session)
|
|
289
|
+
if account_id:
|
|
290
|
+
account_profiles[account_id].append(profile)
|
|
291
|
+
except Exception as e:
|
|
292
|
+
console.print(f"[red]Error checking account ID for profile {profile}: {str(e)}[/]")
|
|
293
|
+
|
|
294
|
+
for account_id, profile_list in account_profiles.items():
|
|
295
|
+
try:
|
|
296
|
+
primary_profile = profile_list[0]
|
|
297
|
+
session = boto3.Session(profile_name=primary_profile)
|
|
298
|
+
cost_data = get_trend(session, tags)
|
|
299
|
+
trend_data = cost_data.get("monthly_costs")
|
|
300
|
+
|
|
301
|
+
if not trend_data:
|
|
302
|
+
console.print(f"[yellow]No trend data available for account {account_id}[/]")
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
profiles_str = ", ".join(profile_list)
|
|
306
|
+
console.print(f"\n[bright_yellow]Account: {account_id} (Profiles: {profiles_str})[/]")
|
|
307
|
+
raw_trend_data.append(cost_data)
|
|
308
|
+
create_trend_bars(trend_data)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
console.print(f"[red]Error getting trend for account {account_id}: {str(e)}[/]")
|
|
311
|
+
else:
|
|
312
|
+
for profile in profiles_to_use:
|
|
313
|
+
try:
|
|
314
|
+
session = boto3.Session(profile_name=profile)
|
|
315
|
+
cost_data = get_trend(session, tags)
|
|
316
|
+
trend_data = cost_data.get("monthly_costs")
|
|
317
|
+
account_id = cost_data.get("account_id", "Unknown")
|
|
318
|
+
|
|
319
|
+
if not trend_data:
|
|
320
|
+
console.print(f"[yellow]No trend data available for profile {profile}[/]")
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
console.print(f"\n[bright_yellow]Account: {account_id} (Profile: {profile})[/]")
|
|
324
|
+
raw_trend_data.append(cost_data)
|
|
325
|
+
create_trend_bars(trend_data)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
console.print(f"[red]Error getting trend for profile {profile}: {str(e)}[/]")
|
|
328
|
+
|
|
329
|
+
# Export if requested
|
|
330
|
+
if raw_trend_data and report_name and report_types:
|
|
331
|
+
export_handler = ExportHandler(
|
|
332
|
+
output_dir=output_dir or os.getcwd(),
|
|
333
|
+
s3_bucket=s3_bucket,
|
|
334
|
+
s3_prefix=s3_prefix,
|
|
335
|
+
profile=profiles_to_use[0] if profiles_to_use else None,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if "json" in report_types:
|
|
339
|
+
json_content = export_trend_data_to_json(raw_trend_data, report_name)
|
|
340
|
+
export_handler.save_json(json_content, f"{report_name}_trend.json")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _get_display_table_period_info(
|
|
344
|
+
profiles_to_use: List[str], time_range: Optional[Union[int, str]]
|
|
345
|
+
) -> Tuple[str, str, str, str]:
|
|
346
|
+
"""Get period information for the display table."""
|
|
347
|
+
if profiles_to_use:
|
|
348
|
+
try:
|
|
349
|
+
sample_session = boto3.Session(profile_name=profiles_to_use[0])
|
|
350
|
+
sample_cost_data = get_cost_data(sample_session, time_range)
|
|
351
|
+
previous_period_name = sample_cost_data.get("previous_period_name", "Last Month Due")
|
|
352
|
+
current_period_name = sample_cost_data.get("current_period_name", "Current Month Cost")
|
|
353
|
+
previous_period_dates = f"{sample_cost_data['previous_period_start']} to {sample_cost_data['previous_period_end']}"
|
|
354
|
+
current_period_dates = f"{sample_cost_data['current_period_start']} to {sample_cost_data['current_period_end']}"
|
|
355
|
+
return (previous_period_name, current_period_name, previous_period_dates, current_period_dates)
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
return "Last Month Due", "Current Month Cost", "N/A", "N/A"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def create_display_table(
|
|
362
|
+
previous_period_dates: str,
|
|
363
|
+
current_period_dates: str,
|
|
364
|
+
previous_period_name: str = "Last Month Due",
|
|
365
|
+
current_period_name: str = "Current Month Cost",
|
|
366
|
+
) -> Table:
|
|
367
|
+
"""Create and configure the display table with dynamic column names."""
|
|
368
|
+
return Table(
|
|
369
|
+
Column("AWS Account Profile", justify="center", vertical="middle"),
|
|
370
|
+
Column(f"{previous_period_name}\n({previous_period_dates})", justify="center", vertical="middle"),
|
|
371
|
+
Column(f"{current_period_name}\n({current_period_dates})", justify="center", vertical="middle"),
|
|
372
|
+
Column("Previous Costs by Service", vertical="middle"),
|
|
373
|
+
Column("Current Costs by Service", vertical="middle"),
|
|
374
|
+
Column("Budget Status", vertical="middle"),
|
|
375
|
+
Column("EC2 Summary", justify="center", vertical="middle"),
|
|
376
|
+
title="💰 Spending Overview",
|
|
377
|
+
caption="CostLens",
|
|
378
|
+
box=box.ASCII_DOUBLE_HEAD,
|
|
379
|
+
show_lines=True,
|
|
380
|
+
style="bright_cyan",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def add_profile_to_table(table: Table, profile_data: ProfileData) -> None:
|
|
385
|
+
"""Add profile data to the display table."""
|
|
386
|
+
if profile_data["success"]:
|
|
387
|
+
percentage_change = profile_data.get("percent_change_in_total_cost")
|
|
388
|
+
change_text = ""
|
|
389
|
+
|
|
390
|
+
if percentage_change is not None:
|
|
391
|
+
if percentage_change > 0:
|
|
392
|
+
change_text = f"\n\n[bright_red]⬆ {percentage_change:.2f}%"
|
|
393
|
+
elif percentage_change < 0:
|
|
394
|
+
change_text = f"\n\n[bright_green]⬇ {abs(percentage_change):.2f}%"
|
|
395
|
+
elif percentage_change == 0:
|
|
396
|
+
change_text = "\n\n[bright_yellow]➡ 0.00%[/]"
|
|
397
|
+
|
|
398
|
+
current_month_with_change = f"[bold red]${profile_data['current_month']:.2f}[/]{change_text}"
|
|
399
|
+
|
|
400
|
+
table.add_row(
|
|
401
|
+
f"[bright_magenta]Profile: {profile_data['profile']}\nAccount: {profile_data['account_id']}[/]",
|
|
402
|
+
f"[bold red]${profile_data['last_month']:.2f}[/]",
|
|
403
|
+
current_month_with_change,
|
|
404
|
+
"[bright_green]" + "\n".join(profile_data["previous_service_costs_formatted"]) + "[/]",
|
|
405
|
+
"[bright_green]" + "\n".join(profile_data["service_costs_formatted"]) + "[/]",
|
|
406
|
+
"[bright_yellow]" + "\n\n".join(profile_data["budget_info"]) + "[/]",
|
|
407
|
+
"\n".join(profile_data["ec2_summary_formatted"]),
|
|
408
|
+
)
|
|
409
|
+
else:
|
|
410
|
+
table.add_row(
|
|
411
|
+
f"[bright_magenta]{profile_data['profile']}[/]",
|
|
412
|
+
"[red]Error[/]",
|
|
413
|
+
"[red]Error[/]",
|
|
414
|
+
"[red]Error[/]",
|
|
415
|
+
f"[red]Failed to process profile: {profile_data['error']}[/]",
|
|
416
|
+
"[red]N/A[/]",
|
|
417
|
+
"[red]N/A[/]",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _run_cost_dashboard(
|
|
422
|
+
profiles_to_use: List[str],
|
|
423
|
+
user_regions: Optional[List[str]],
|
|
424
|
+
combine: bool,
|
|
425
|
+
report_name: Optional[str],
|
|
426
|
+
report_types: Optional[List[str]],
|
|
427
|
+
output_dir: Optional[str],
|
|
428
|
+
s3_bucket: Optional[str],
|
|
429
|
+
s3_prefix: Optional[str],
|
|
430
|
+
time_range: Optional[Union[int, str]],
|
|
431
|
+
tags: Optional[Dict[str, str]],
|
|
432
|
+
) -> None:
|
|
433
|
+
"""Run cost dashboard and generate reports."""
|
|
434
|
+
with Status("[bright_cyan]💰 Preparing dashboard...", spinner="dots12", speed=0.1):
|
|
435
|
+
(
|
|
436
|
+
previous_period_name,
|
|
437
|
+
current_period_name,
|
|
438
|
+
previous_period_dates,
|
|
439
|
+
current_period_dates,
|
|
440
|
+
) = _get_display_table_period_info(profiles_to_use, time_range)
|
|
441
|
+
|
|
442
|
+
table = create_display_table(
|
|
443
|
+
previous_period_dates,
|
|
444
|
+
current_period_dates,
|
|
445
|
+
previous_period_name,
|
|
446
|
+
current_period_name,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
export_data: List[ProfileData] = []
|
|
450
|
+
|
|
451
|
+
if combine:
|
|
452
|
+
account_profiles = defaultdict(list)
|
|
453
|
+
for profile in profiles_to_use:
|
|
454
|
+
try:
|
|
455
|
+
session = boto3.Session(profile_name=profile)
|
|
456
|
+
current_account_id = get_account_id(session)
|
|
457
|
+
if current_account_id:
|
|
458
|
+
account_profiles[current_account_id].append(profile)
|
|
459
|
+
else:
|
|
460
|
+
console.log(f"[yellow]Could not determine account ID for profile {profile}[/]")
|
|
461
|
+
except Exception as e:
|
|
462
|
+
console.log(f"[bold red]Error checking account ID for profile {profile}: {str(e)}[/]")
|
|
463
|
+
|
|
464
|
+
for account_id_key, profile_list in track(
|
|
465
|
+
account_profiles.items(), description="[bright_cyan]Retrieving AWS costs..."
|
|
466
|
+
):
|
|
467
|
+
if len(profile_list) > 1:
|
|
468
|
+
profile_data = process_combined_profiles(
|
|
469
|
+
account_id_key, profile_list, user_regions, time_range, tags
|
|
470
|
+
)
|
|
471
|
+
else:
|
|
472
|
+
profile_data = process_single_profile(
|
|
473
|
+
profile_list[0], user_regions, time_range, tags
|
|
474
|
+
)
|
|
475
|
+
export_data.append(profile_data)
|
|
476
|
+
add_profile_to_table(table, profile_data)
|
|
477
|
+
else:
|
|
478
|
+
for profile in track(profiles_to_use, description="[bright_cyan]Retrieving AWS costs..."):
|
|
479
|
+
profile_data = process_single_profile(profile, user_regions, time_range, tags)
|
|
480
|
+
export_data.append(profile_data)
|
|
481
|
+
add_profile_to_table(table, profile_data)
|
|
482
|
+
|
|
483
|
+
console.print(table)
|
|
484
|
+
|
|
485
|
+
# Export if requested
|
|
486
|
+
if report_name and report_types:
|
|
487
|
+
export_handler = ExportHandler(
|
|
488
|
+
output_dir=output_dir or os.getcwd(),
|
|
489
|
+
s3_bucket=s3_bucket,
|
|
490
|
+
s3_prefix=s3_prefix,
|
|
491
|
+
profile=profiles_to_use[0] if profiles_to_use else None,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
for report_type in report_types:
|
|
495
|
+
if report_type == "csv":
|
|
496
|
+
csv_content = export_to_csv(export_data, report_name, previous_period_dates, current_period_dates)
|
|
497
|
+
export_handler.save_csv(csv_content, f"{report_name}.csv")
|
|
498
|
+
elif report_type == "json":
|
|
499
|
+
json_content = export_to_json(export_data, report_name)
|
|
500
|
+
export_handler.save_json(json_content, f"{report_name}.json")
|
|
501
|
+
elif report_type == "pdf":
|
|
502
|
+
pdf_bytes = export_cost_dashboard_to_pdf(
|
|
503
|
+
export_data, report_name, previous_period_dates, current_period_dates
|
|
504
|
+
)
|
|
505
|
+
export_handler.save_pdf(pdf_bytes, f"{report_name}.pdf")
|
|
@@ -10,7 +10,8 @@ from rich.console import Console
|
|
|
10
10
|
|
|
11
11
|
from aws_costlens.models import BudgetInfo, EC2Summary, RegionName
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# Force UTF-8 and modern Windows terminal mode for Unicode support
|
|
14
|
+
console = Console(force_terminal=True, legacy_windows=False)
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def get_aws_profiles() -> List[str]:
|
|
@@ -4,24 +4,31 @@ import argparse
|
|
|
4
4
|
import sys
|
|
5
5
|
from typing import Dict, List, Optional
|
|
6
6
|
|
|
7
|
+
# Setup UTF-8 console for Windows
|
|
8
|
+
from aws_costlens.console_setup import setup_console
|
|
9
|
+
setup_console()
|
|
10
|
+
|
|
7
11
|
from rich.console import Console
|
|
8
12
|
|
|
9
13
|
from aws_costlens import __version__
|
|
10
14
|
from aws_costlens.app_controller import run_dashboard
|
|
11
15
|
from aws_costlens.common_utils import load_config_file
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
# Force UTF-8 and modern Windows terminal mode for Unicode support
|
|
18
|
+
console = Console(force_terminal=True, legacy_windows=False)
|
|
14
19
|
|
|
15
20
|
|
|
16
21
|
def welcome_banner() -> None:
|
|
17
|
-
"""Display the welcome banner."""
|
|
18
|
-
banner
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
""
|
|
23
|
-
|
|
24
|
-
console.print(
|
|
22
|
+
"""Display the welcome banner with version."""
|
|
23
|
+
# Compact banner with version on the right
|
|
24
|
+
console.print()
|
|
25
|
+
console.print(
|
|
26
|
+
"[bold bright_cyan]╔═══════════════════════════════════════════════╗[/] "
|
|
27
|
+
f"[dim italic]v{__version__}[/]"
|
|
28
|
+
)
|
|
29
|
+
console.print("[bold bright_cyan]║ AWS CostLens - Cost Intelligence Tool ║[/]")
|
|
30
|
+
console.print("[bold bright_cyan]╚═══════════════════════════════════════════════╝[/]")
|
|
31
|
+
console.print() # Single line space before content
|
|
25
32
|
|
|
26
33
|
|
|
27
34
|
def parse_tags(tag_strings: Optional[List[str]]) -> Optional[Dict[str, str]]:
|