s3ui 1.0.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.
- s3ui/__init__.py +1 -0
- s3ui/app.py +56 -0
- s3ui/constants.py +39 -0
- s3ui/core/__init__.py +0 -0
- s3ui/core/cost.py +218 -0
- s3ui/core/credentials.py +165 -0
- s3ui/core/download_worker.py +260 -0
- s3ui/core/errors.py +104 -0
- s3ui/core/listing_cache.py +178 -0
- s3ui/core/s3_client.py +358 -0
- s3ui/core/stats.py +128 -0
- s3ui/core/transfers.py +281 -0
- s3ui/core/upload_worker.py +311 -0
- s3ui/db/__init__.py +0 -0
- s3ui/db/database.py +143 -0
- s3ui/db/migrations/001_initial.sql +114 -0
- s3ui/logging_setup.py +18 -0
- s3ui/main_window.py +969 -0
- s3ui/models/__init__.py +0 -0
- s3ui/models/s3_objects.py +295 -0
- s3ui/models/transfer_model.py +282 -0
- s3ui/resources/__init__.py +0 -0
- s3ui/resources/s3ui.png +0 -0
- s3ui/ui/__init__.py +0 -0
- s3ui/ui/breadcrumb_bar.py +150 -0
- s3ui/ui/confirm_delete.py +60 -0
- s3ui/ui/cost_dialog.py +163 -0
- s3ui/ui/get_info.py +50 -0
- s3ui/ui/local_pane.py +226 -0
- s3ui/ui/name_conflict.py +68 -0
- s3ui/ui/s3_pane.py +547 -0
- s3ui/ui/settings_dialog.py +328 -0
- s3ui/ui/setup_wizard.py +462 -0
- s3ui/ui/stats_dialog.py +162 -0
- s3ui/ui/transfer_panel.py +153 -0
- s3ui-1.0.0.dist-info/METADATA +118 -0
- s3ui-1.0.0.dist-info/RECORD +40 -0
- s3ui-1.0.0.dist-info/WHEEL +4 -0
- s3ui-1.0.0.dist-info/entry_points.txt +2 -0
- s3ui-1.0.0.dist-info/licenses/LICENSE +21 -0
s3ui/ui/setup_wizard.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""First-run setup wizard for configuring AWS credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from PyQt6.QtCore import QObject, QThread, pyqtSignal
|
|
8
|
+
from PyQt6.QtWidgets import (
|
|
9
|
+
QComboBox,
|
|
10
|
+
QHBoxLayout,
|
|
11
|
+
QLabel,
|
|
12
|
+
QLineEdit,
|
|
13
|
+
QListWidget,
|
|
14
|
+
QPushButton,
|
|
15
|
+
QRadioButton,
|
|
16
|
+
QVBoxLayout,
|
|
17
|
+
QWidget,
|
|
18
|
+
QWizard,
|
|
19
|
+
QWizardPage,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from s3ui.core.credentials import (
|
|
23
|
+
CredentialStore,
|
|
24
|
+
Profile,
|
|
25
|
+
TestResult,
|
|
26
|
+
discover_aws_profiles,
|
|
27
|
+
get_aws_profile_region,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("s3ui.setup_wizard")
|
|
31
|
+
|
|
32
|
+
# AWS regions (common subset)
|
|
33
|
+
AWS_REGIONS = [
|
|
34
|
+
("US East (N. Virginia)", "us-east-1"),
|
|
35
|
+
("US East (Ohio)", "us-east-2"),
|
|
36
|
+
("US West (N. California)", "us-west-1"),
|
|
37
|
+
("US West (Oregon)", "us-west-2"),
|
|
38
|
+
("Europe (Ireland)", "eu-west-1"),
|
|
39
|
+
("Europe (London)", "eu-west-2"),
|
|
40
|
+
("Europe (Frankfurt)", "eu-central-1"),
|
|
41
|
+
("Europe (Paris)", "eu-west-3"),
|
|
42
|
+
("Europe (Stockholm)", "eu-north-1"),
|
|
43
|
+
("Asia Pacific (Tokyo)", "ap-northeast-1"),
|
|
44
|
+
("Asia Pacific (Seoul)", "ap-northeast-2"),
|
|
45
|
+
("Asia Pacific (Singapore)", "ap-southeast-1"),
|
|
46
|
+
("Asia Pacific (Sydney)", "ap-southeast-2"),
|
|
47
|
+
("Asia Pacific (Mumbai)", "ap-south-1"),
|
|
48
|
+
("Canada (Central)", "ca-central-1"),
|
|
49
|
+
("South America (Sao Paulo)", "sa-east-1"),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _TestWorkerSignals(QObject):
|
|
54
|
+
finished = pyqtSignal(TestResult)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _TestWorker(QThread):
|
|
58
|
+
"""Background thread for testing AWS credentials."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, store: CredentialStore, profile: Profile) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
self.signals = _TestWorkerSignals()
|
|
63
|
+
self._store = store
|
|
64
|
+
self._profile = profile
|
|
65
|
+
|
|
66
|
+
def run(self) -> None:
|
|
67
|
+
result = self._store.test_connection(self._profile)
|
|
68
|
+
self.signals.finished.emit(result)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WelcomePage(QWizardPage):
|
|
72
|
+
"""Page 1: Welcome text."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, parent=None) -> None:
|
|
75
|
+
super().__init__(parent)
|
|
76
|
+
self.setTitle("Welcome to S3UI")
|
|
77
|
+
|
|
78
|
+
layout = QVBoxLayout(self)
|
|
79
|
+
label = QLabel(
|
|
80
|
+
"S3UI is a native file manager for Amazon S3\n"
|
|
81
|
+
"and S3-compatible services like MinIO.\n\n"
|
|
82
|
+
"You can browse, upload, download, and manage files\n"
|
|
83
|
+
"in your buckets with a familiar dual-pane interface.\n\n"
|
|
84
|
+
"Let's get started by connecting your account."
|
|
85
|
+
)
|
|
86
|
+
label.setWordWrap(True)
|
|
87
|
+
layout.addWidget(label)
|
|
88
|
+
layout.addStretch()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CredentialPage(QWizardPage):
|
|
92
|
+
"""Page 2: Choose an AWS CLI profile or enter credentials manually."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
store: CredentialStore,
|
|
97
|
+
aws_profiles: list[str] | None = None,
|
|
98
|
+
parent=None,
|
|
99
|
+
) -> None:
|
|
100
|
+
super().__init__(parent)
|
|
101
|
+
self._store = store
|
|
102
|
+
self._test_result: TestResult | None = None
|
|
103
|
+
self._worker: _TestWorker | None = None
|
|
104
|
+
self._aws_profiles: list[str] = aws_profiles if aws_profiles is not None else []
|
|
105
|
+
|
|
106
|
+
self.setTitle("AWS Credentials")
|
|
107
|
+
self.setSubTitle("Use an existing AWS CLI profile or enter credentials manually.")
|
|
108
|
+
|
|
109
|
+
layout = QVBoxLayout(self)
|
|
110
|
+
layout.setSpacing(6)
|
|
111
|
+
|
|
112
|
+
# --- Option 1: AWS CLI profile ---
|
|
113
|
+
self._aws_radio = QRadioButton("Use AWS CLI profile")
|
|
114
|
+
layout.addWidget(self._aws_radio)
|
|
115
|
+
|
|
116
|
+
self._aws_profile_combo = QComboBox()
|
|
117
|
+
self._aws_profile_combo.setMinimumWidth(200)
|
|
118
|
+
layout.addWidget(self._aws_profile_combo)
|
|
119
|
+
|
|
120
|
+
self._aws_info_label = QLabel("")
|
|
121
|
+
self._aws_info_label.setStyleSheet("color: gray; font-size: 11px; margin-left: 20px;")
|
|
122
|
+
self._aws_info_label.setWordWrap(True)
|
|
123
|
+
layout.addWidget(self._aws_info_label)
|
|
124
|
+
|
|
125
|
+
# Region override (for AWS profiles)
|
|
126
|
+
self._region_label = QLabel("Region (optional override):")
|
|
127
|
+
layout.addWidget(self._region_label)
|
|
128
|
+
|
|
129
|
+
self._region_combo = QComboBox()
|
|
130
|
+
self._region_combo.addItem("Auto-detect from profile", "")
|
|
131
|
+
for display_name, region_code in AWS_REGIONS:
|
|
132
|
+
self._region_combo.addItem(f"{display_name} ({region_code})", region_code)
|
|
133
|
+
layout.addWidget(self._region_combo)
|
|
134
|
+
|
|
135
|
+
# --- Option 2: Manual credentials ---
|
|
136
|
+
self._manual_radio = QRadioButton("Enter credentials manually")
|
|
137
|
+
layout.addWidget(self._manual_radio)
|
|
138
|
+
|
|
139
|
+
# Manual fields container — visibility controlled by radio
|
|
140
|
+
self._manual_widget = _ManualCredentialWidget()
|
|
141
|
+
self._manual_widget.fields_changed.connect(self.completeChanged)
|
|
142
|
+
layout.addWidget(self._manual_widget)
|
|
143
|
+
|
|
144
|
+
# Test connection
|
|
145
|
+
layout.addSpacing(8)
|
|
146
|
+
test_row = QHBoxLayout()
|
|
147
|
+
self._test_btn = QPushButton("Test Connection")
|
|
148
|
+
self._test_btn.clicked.connect(self._on_test_clicked)
|
|
149
|
+
test_row.addWidget(self._test_btn)
|
|
150
|
+
self._test_status = QLabel("")
|
|
151
|
+
test_row.addWidget(self._test_status, 1)
|
|
152
|
+
layout.addLayout(test_row)
|
|
153
|
+
|
|
154
|
+
# Error detail
|
|
155
|
+
self._error_label = QLabel("")
|
|
156
|
+
self._error_label.setWordWrap(True)
|
|
157
|
+
self._error_label.setStyleSheet("color: red;")
|
|
158
|
+
self._error_label.setVisible(False)
|
|
159
|
+
layout.addWidget(self._error_label)
|
|
160
|
+
|
|
161
|
+
# Now connect radio signals and populate — AFTER all widgets are created
|
|
162
|
+
self._aws_radio.toggled.connect(self._on_mode_changed)
|
|
163
|
+
self._manual_radio.toggled.connect(self._on_mode_changed)
|
|
164
|
+
|
|
165
|
+
# Populate AWS profiles (uses pre-discovered list or discovers fresh)
|
|
166
|
+
self._populate_profiles()
|
|
167
|
+
|
|
168
|
+
def _populate_profiles(self) -> None:
|
|
169
|
+
"""Populate the AWS profile combo with already-discovered profiles."""
|
|
170
|
+
# If no profiles were passed in, try discovering now
|
|
171
|
+
if not self._aws_profiles:
|
|
172
|
+
self._aws_profiles = discover_aws_profiles()
|
|
173
|
+
logger.debug("Wizard discovered %d AWS profiles", len(self._aws_profiles))
|
|
174
|
+
|
|
175
|
+
self._aws_profile_combo.clear()
|
|
176
|
+
|
|
177
|
+
if self._aws_profiles:
|
|
178
|
+
for name in self._aws_profiles:
|
|
179
|
+
region = get_aws_profile_region(name)
|
|
180
|
+
display = f"{name} ({region})" if region else name
|
|
181
|
+
self._aws_profile_combo.addItem(display, name)
|
|
182
|
+
self._aws_info_label.setText(
|
|
183
|
+
f"Found {len(self._aws_profiles)} profile(s) in ~/.aws/config"
|
|
184
|
+
)
|
|
185
|
+
self._aws_radio.setChecked(True)
|
|
186
|
+
else:
|
|
187
|
+
self._aws_profile_combo.addItem("(no profiles found)")
|
|
188
|
+
self._aws_profile_combo.setEnabled(False)
|
|
189
|
+
self._aws_radio.setEnabled(False)
|
|
190
|
+
self._aws_info_label.setText(
|
|
191
|
+
"No AWS CLI profiles found. Run 'aws configure' to create one,\n"
|
|
192
|
+
"or enter credentials manually below."
|
|
193
|
+
)
|
|
194
|
+
self._manual_radio.setChecked(True)
|
|
195
|
+
|
|
196
|
+
# Apply initial visibility
|
|
197
|
+
self._on_mode_changed()
|
|
198
|
+
|
|
199
|
+
def _on_mode_changed(self) -> None:
|
|
200
|
+
is_aws = self._aws_radio.isChecked()
|
|
201
|
+
has_profiles = bool(self._aws_profiles)
|
|
202
|
+
# AWS section
|
|
203
|
+
self._aws_profile_combo.setVisible(is_aws and has_profiles)
|
|
204
|
+
self._aws_info_label.setVisible(is_aws)
|
|
205
|
+
self._region_label.setVisible(is_aws and has_profiles)
|
|
206
|
+
self._region_combo.setVisible(is_aws and has_profiles)
|
|
207
|
+
# Manual section
|
|
208
|
+
self._manual_widget.setVisible(not is_aws)
|
|
209
|
+
# Reset test state
|
|
210
|
+
self._test_result = None
|
|
211
|
+
self._test_status.setText("")
|
|
212
|
+
self._error_label.setVisible(False)
|
|
213
|
+
self.completeChanged.emit()
|
|
214
|
+
|
|
215
|
+
def _on_test_clicked(self) -> None:
|
|
216
|
+
profile = self._build_profile()
|
|
217
|
+
if profile is None:
|
|
218
|
+
self._test_status.setText("Please fill in all required fields.")
|
|
219
|
+
self._test_status.setStyleSheet("color: orange;")
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
self._test_btn.setEnabled(False)
|
|
223
|
+
self._test_status.setText("Testing...")
|
|
224
|
+
self._test_status.setStyleSheet("color: gray;")
|
|
225
|
+
self._error_label.setVisible(False)
|
|
226
|
+
|
|
227
|
+
self._worker = _TestWorker(self._store, profile)
|
|
228
|
+
self._worker.signals.finished.connect(self._on_test_result)
|
|
229
|
+
self._worker.finished.connect(self._worker.deleteLater)
|
|
230
|
+
self._worker.start()
|
|
231
|
+
|
|
232
|
+
def _build_profile(self) -> Profile | None:
|
|
233
|
+
"""Build a Profile from the current UI state."""
|
|
234
|
+
if self._aws_radio.isChecked():
|
|
235
|
+
if not self._aws_profiles:
|
|
236
|
+
return None
|
|
237
|
+
name = self._aws_profile_combo.currentData()
|
|
238
|
+
region = self._region_combo.currentData() or ""
|
|
239
|
+
return Profile(name=name, region=region, is_aws_profile=True)
|
|
240
|
+
else:
|
|
241
|
+
name = self._manual_widget.profile_name()
|
|
242
|
+
access_key = self._manual_widget.access_key()
|
|
243
|
+
secret_key = self._manual_widget.secret_key()
|
|
244
|
+
region = self._manual_widget.region()
|
|
245
|
+
endpoint_url = self._manual_widget.endpoint_url()
|
|
246
|
+
if not name or not access_key or not secret_key:
|
|
247
|
+
return None
|
|
248
|
+
return Profile(
|
|
249
|
+
name=name,
|
|
250
|
+
access_key_id=access_key,
|
|
251
|
+
secret_access_key=secret_key,
|
|
252
|
+
region=region,
|
|
253
|
+
endpoint_url=endpoint_url,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _on_test_result(self, result: TestResult) -> None:
|
|
257
|
+
self._test_btn.setEnabled(True)
|
|
258
|
+
self._test_result = result
|
|
259
|
+
|
|
260
|
+
if result.success:
|
|
261
|
+
self._test_status.setText(f"Connected! Found {len(result.buckets)} bucket(s).")
|
|
262
|
+
self._test_status.setStyleSheet("color: green;")
|
|
263
|
+
self._error_label.setVisible(False)
|
|
264
|
+
else:
|
|
265
|
+
self._test_status.setText("Connection failed.")
|
|
266
|
+
self._test_status.setStyleSheet("color: red;")
|
|
267
|
+
detail = result.error_detail if result.error_detail else result.error_message
|
|
268
|
+
self._error_label.setText(f"{result.error_message}\n\nDetail: {detail}")
|
|
269
|
+
self._error_label.setVisible(True)
|
|
270
|
+
|
|
271
|
+
self.completeChanged.emit()
|
|
272
|
+
|
|
273
|
+
def isComplete(self) -> bool:
|
|
274
|
+
"""Page is complete when credentials are filled in (test is optional)."""
|
|
275
|
+
if self._aws_radio.isChecked():
|
|
276
|
+
return bool(self._aws_profiles)
|
|
277
|
+
# Manual mode: need name, access key, and secret key
|
|
278
|
+
return bool(
|
|
279
|
+
self._manual_widget.profile_name()
|
|
280
|
+
and self._manual_widget.access_key()
|
|
281
|
+
and self._manual_widget.secret_key()
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def get_profile(self) -> Profile:
|
|
285
|
+
return self._build_profile() or Profile(name="default")
|
|
286
|
+
|
|
287
|
+
def get_buckets(self) -> list[str]:
|
|
288
|
+
if self._test_result:
|
|
289
|
+
return self._test_result.buckets
|
|
290
|
+
return []
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class _ManualCredentialWidget(QWidget):
|
|
294
|
+
"""Sub-widget with manual credential entry fields."""
|
|
295
|
+
|
|
296
|
+
fields_changed = pyqtSignal()
|
|
297
|
+
|
|
298
|
+
def __init__(self, parent=None) -> None:
|
|
299
|
+
super().__init__(parent)
|
|
300
|
+
layout = QVBoxLayout(self)
|
|
301
|
+
layout.setContentsMargins(20, 8, 0, 8)
|
|
302
|
+
layout.setSpacing(4)
|
|
303
|
+
|
|
304
|
+
# Profile name
|
|
305
|
+
layout.addWidget(QLabel("Profile Name:"))
|
|
306
|
+
self._name_edit = QLineEdit()
|
|
307
|
+
self._name_edit.setText("default")
|
|
308
|
+
self._name_edit.setPlaceholderText("e.g., default, work, personal")
|
|
309
|
+
self._name_edit.textChanged.connect(self.fields_changed)
|
|
310
|
+
layout.addWidget(self._name_edit)
|
|
311
|
+
|
|
312
|
+
# Access Key ID
|
|
313
|
+
layout.addWidget(QLabel("Access Key ID:"))
|
|
314
|
+
self._access_key_edit = QLineEdit()
|
|
315
|
+
self._access_key_edit.setPlaceholderText("AKIA...")
|
|
316
|
+
self._access_key_edit.textChanged.connect(self.fields_changed)
|
|
317
|
+
layout.addWidget(self._access_key_edit)
|
|
318
|
+
|
|
319
|
+
# Secret Access Key
|
|
320
|
+
layout.addWidget(QLabel("Secret Access Key:"))
|
|
321
|
+
secret_row = QHBoxLayout()
|
|
322
|
+
self._secret_key_edit = QLineEdit()
|
|
323
|
+
self._secret_key_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
|
324
|
+
self._secret_key_edit.setPlaceholderText("Your secret key")
|
|
325
|
+
self._secret_key_edit.textChanged.connect(self.fields_changed)
|
|
326
|
+
secret_row.addWidget(self._secret_key_edit)
|
|
327
|
+
self._toggle_btn = QPushButton("Show")
|
|
328
|
+
self._toggle_btn.setFixedWidth(50)
|
|
329
|
+
self._toggle_btn.clicked.connect(self._toggle_visibility)
|
|
330
|
+
secret_row.addWidget(self._toggle_btn)
|
|
331
|
+
layout.addLayout(secret_row)
|
|
332
|
+
|
|
333
|
+
# Region
|
|
334
|
+
layout.addWidget(QLabel("Region:"))
|
|
335
|
+
self._region_combo = QComboBox()
|
|
336
|
+
for display_name, region_code in AWS_REGIONS:
|
|
337
|
+
self._region_combo.addItem(f"{display_name} ({region_code})", region_code)
|
|
338
|
+
layout.addWidget(self._region_combo)
|
|
339
|
+
|
|
340
|
+
# Endpoint URL (for MinIO, LocalStack, etc.)
|
|
341
|
+
layout.addWidget(QLabel("Endpoint URL (optional, for S3-compatible services):"))
|
|
342
|
+
self._endpoint_edit = QLineEdit()
|
|
343
|
+
self._endpoint_edit.setPlaceholderText("e.g., http://localhost:9000")
|
|
344
|
+
layout.addWidget(self._endpoint_edit)
|
|
345
|
+
|
|
346
|
+
def _toggle_visibility(self) -> None:
|
|
347
|
+
if self._secret_key_edit.echoMode() == QLineEdit.EchoMode.Password:
|
|
348
|
+
self._secret_key_edit.setEchoMode(QLineEdit.EchoMode.Normal)
|
|
349
|
+
self._toggle_btn.setText("Hide")
|
|
350
|
+
else:
|
|
351
|
+
self._secret_key_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
|
352
|
+
self._toggle_btn.setText("Show")
|
|
353
|
+
|
|
354
|
+
def profile_name(self) -> str:
|
|
355
|
+
return self._name_edit.text().strip()
|
|
356
|
+
|
|
357
|
+
def access_key(self) -> str:
|
|
358
|
+
return self._access_key_edit.text().strip()
|
|
359
|
+
|
|
360
|
+
def secret_key(self) -> str:
|
|
361
|
+
return self._secret_key_edit.text().strip()
|
|
362
|
+
|
|
363
|
+
def region(self) -> str:
|
|
364
|
+
return self._region_combo.currentData()
|
|
365
|
+
|
|
366
|
+
def endpoint_url(self) -> str:
|
|
367
|
+
return self._endpoint_edit.text().strip()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class BucketPage(QWizardPage):
|
|
371
|
+
"""Page 3: Pick a bucket."""
|
|
372
|
+
|
|
373
|
+
def __init__(self, parent=None) -> None:
|
|
374
|
+
super().__init__(parent)
|
|
375
|
+
self.setTitle("Select a Bucket")
|
|
376
|
+
self.setSubTitle("Choose which S3 bucket to open, or type a name.")
|
|
377
|
+
|
|
378
|
+
layout = QVBoxLayout(self)
|
|
379
|
+
|
|
380
|
+
self._bucket_list = QListWidget()
|
|
381
|
+
self._bucket_list.currentItemChanged.connect(lambda: self.completeChanged.emit())
|
|
382
|
+
layout.addWidget(self._bucket_list, 1)
|
|
383
|
+
|
|
384
|
+
# Manual entry for when bucket listing wasn't available
|
|
385
|
+
self._manual_label = QLabel("Or enter a bucket name:")
|
|
386
|
+
layout.addWidget(self._manual_label)
|
|
387
|
+
self._manual_edit = QLineEdit()
|
|
388
|
+
self._manual_edit.setPlaceholderText("my-bucket-name")
|
|
389
|
+
self._manual_edit.textChanged.connect(self.completeChanged)
|
|
390
|
+
layout.addWidget(self._manual_edit)
|
|
391
|
+
|
|
392
|
+
# Info label when no buckets are available
|
|
393
|
+
self._info_label = QLabel(
|
|
394
|
+
"No buckets were discovered. You can enter a bucket name manually,\n"
|
|
395
|
+
"or skip this step and select a bucket later from the toolbar."
|
|
396
|
+
)
|
|
397
|
+
self._info_label.setWordWrap(True)
|
|
398
|
+
self._info_label.setStyleSheet("color: gray;")
|
|
399
|
+
self._info_label.setVisible(False)
|
|
400
|
+
layout.addWidget(self._info_label)
|
|
401
|
+
|
|
402
|
+
def initializePage(self) -> None:
|
|
403
|
+
wizard = self.wizard()
|
|
404
|
+
cred_page = wizard.page(1)
|
|
405
|
+
buckets = cred_page.get_buckets()
|
|
406
|
+
self._bucket_list.clear()
|
|
407
|
+
self._manual_edit.clear()
|
|
408
|
+
|
|
409
|
+
if buckets:
|
|
410
|
+
self._bucket_list.addItems(sorted(buckets))
|
|
411
|
+
self._bucket_list.setCurrentRow(0)
|
|
412
|
+
self._bucket_list.setVisible(True)
|
|
413
|
+
self._manual_label.setVisible(False)
|
|
414
|
+
self._manual_edit.setVisible(False)
|
|
415
|
+
self._info_label.setVisible(False)
|
|
416
|
+
else:
|
|
417
|
+
self._bucket_list.setVisible(False)
|
|
418
|
+
self._manual_label.setVisible(True)
|
|
419
|
+
self._manual_edit.setVisible(True)
|
|
420
|
+
self._info_label.setVisible(True)
|
|
421
|
+
|
|
422
|
+
def isComplete(self) -> bool:
|
|
423
|
+
# Complete if a bucket is selected from list OR entered manually OR left empty (skip)
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
def selected_bucket(self) -> str:
|
|
427
|
+
item = self._bucket_list.currentItem()
|
|
428
|
+
if item:
|
|
429
|
+
return item.text()
|
|
430
|
+
return self._manual_edit.text().strip()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class SetupWizard(QWizard):
|
|
434
|
+
"""First-run wizard for setting up AWS credentials."""
|
|
435
|
+
|
|
436
|
+
def __init__(
|
|
437
|
+
self,
|
|
438
|
+
store: CredentialStore | None = None,
|
|
439
|
+
parent=None,
|
|
440
|
+
aws_profiles: list[str] | None = None,
|
|
441
|
+
) -> None:
|
|
442
|
+
super().__init__(parent)
|
|
443
|
+
self._store = store or CredentialStore()
|
|
444
|
+
self.setWindowTitle("S3UI Setup")
|
|
445
|
+
self.setMinimumSize(600, 550)
|
|
446
|
+
self.setWizardStyle(QWizard.WizardStyle.ModernStyle)
|
|
447
|
+
|
|
448
|
+
self._welcome = WelcomePage()
|
|
449
|
+
self._cred_page = CredentialPage(self._store, aws_profiles=aws_profiles)
|
|
450
|
+
self._bucket_page = BucketPage()
|
|
451
|
+
|
|
452
|
+
self.addPage(self._welcome)
|
|
453
|
+
self.addPage(self._cred_page)
|
|
454
|
+
self.addPage(self._bucket_page)
|
|
455
|
+
|
|
456
|
+
def get_profile(self) -> Profile:
|
|
457
|
+
"""Return the profile configured by the user."""
|
|
458
|
+
return self._cred_page.get_profile()
|
|
459
|
+
|
|
460
|
+
def get_bucket(self) -> str:
|
|
461
|
+
"""Return the bucket selected (or entered) by the user."""
|
|
462
|
+
return self._bucket_page.selected_bucket()
|
s3ui/ui/stats_dialog.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Bucket statistics dialog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PyQt6.QtWidgets import (
|
|
8
|
+
QDialog,
|
|
9
|
+
QDialogButtonBox,
|
|
10
|
+
QLabel,
|
|
11
|
+
QProgressBar,
|
|
12
|
+
QPushButton,
|
|
13
|
+
QTableWidget,
|
|
14
|
+
QTableWidgetItem,
|
|
15
|
+
QVBoxLayout,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from s3ui.core.stats import BucketSnapshot, StatsCollector
|
|
19
|
+
from s3ui.models.s3_objects import _format_size
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from s3ui.core.s3_client import S3Client
|
|
23
|
+
from s3ui.db.database import Database
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StatsDialog(QDialog):
|
|
27
|
+
"""Shows bucket statistics and storage breakdown."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
s3_client: S3Client | None = None,
|
|
32
|
+
bucket: str = "",
|
|
33
|
+
db: Database | None = None,
|
|
34
|
+
parent=None,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(parent)
|
|
37
|
+
self._s3 = s3_client
|
|
38
|
+
self._bucket = bucket
|
|
39
|
+
self._db = db
|
|
40
|
+
self._collector: StatsCollector | None = None
|
|
41
|
+
|
|
42
|
+
self.setWindowTitle(f"Bucket Stats — {bucket}" if bucket else "Bucket Stats")
|
|
43
|
+
self.setMinimumSize(500, 400)
|
|
44
|
+
|
|
45
|
+
layout = QVBoxLayout(self)
|
|
46
|
+
|
|
47
|
+
# Summary
|
|
48
|
+
self._summary = QLabel("No data yet. Click Scan to begin.")
|
|
49
|
+
self._summary.setStyleSheet("font-size: 14px;")
|
|
50
|
+
self._summary.setWordWrap(True)
|
|
51
|
+
layout.addWidget(self._summary)
|
|
52
|
+
|
|
53
|
+
# Progress
|
|
54
|
+
self._progress_bar = QProgressBar()
|
|
55
|
+
self._progress_bar.setRange(0, 0) # indeterminate
|
|
56
|
+
self._progress_bar.setVisible(False)
|
|
57
|
+
layout.addWidget(self._progress_bar)
|
|
58
|
+
|
|
59
|
+
self._progress_label = QLabel("")
|
|
60
|
+
self._progress_label.setVisible(False)
|
|
61
|
+
layout.addWidget(self._progress_label)
|
|
62
|
+
|
|
63
|
+
# Storage breakdown table
|
|
64
|
+
self._breakdown_table = QTableWidget()
|
|
65
|
+
self._breakdown_table.setColumnCount(3)
|
|
66
|
+
self._breakdown_table.setHorizontalHeaderLabels(["Storage Class", "Size", "Objects"])
|
|
67
|
+
self._breakdown_table.setVisible(False)
|
|
68
|
+
layout.addWidget(self._breakdown_table)
|
|
69
|
+
|
|
70
|
+
# Top 10 largest
|
|
71
|
+
self._largest_label = QLabel("Largest Files:")
|
|
72
|
+
self._largest_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
|
73
|
+
self._largest_label.setVisible(False)
|
|
74
|
+
layout.addWidget(self._largest_label)
|
|
75
|
+
|
|
76
|
+
self._largest_table = QTableWidget()
|
|
77
|
+
self._largest_table.setColumnCount(2)
|
|
78
|
+
self._largest_table.setHorizontalHeaderLabels(["Key", "Size"])
|
|
79
|
+
self._largest_table.setVisible(False)
|
|
80
|
+
layout.addWidget(self._largest_table)
|
|
81
|
+
|
|
82
|
+
# Buttons
|
|
83
|
+
btn_layout = QDialogButtonBox()
|
|
84
|
+
self._scan_btn = QPushButton("Scan")
|
|
85
|
+
self._scan_btn.clicked.connect(self._start_scan)
|
|
86
|
+
btn_layout.addButton(self._scan_btn, QDialogButtonBox.ButtonRole.ActionRole)
|
|
87
|
+
|
|
88
|
+
self._cancel_btn = QPushButton("Cancel Scan")
|
|
89
|
+
self._cancel_btn.clicked.connect(self._cancel_scan)
|
|
90
|
+
self._cancel_btn.setVisible(False)
|
|
91
|
+
btn_layout.addButton(self._cancel_btn, QDialogButtonBox.ButtonRole.ActionRole)
|
|
92
|
+
|
|
93
|
+
close_btn = btn_layout.addButton(QDialogButtonBox.StandardButton.Close)
|
|
94
|
+
close_btn.clicked.connect(self.accept)
|
|
95
|
+
layout.addWidget(btn_layout)
|
|
96
|
+
|
|
97
|
+
def _start_scan(self) -> None:
|
|
98
|
+
if not self._s3 or not self._bucket:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
self._scan_btn.setVisible(False)
|
|
102
|
+
self._cancel_btn.setVisible(True)
|
|
103
|
+
self._progress_bar.setVisible(True)
|
|
104
|
+
self._progress_label.setVisible(True)
|
|
105
|
+
self._summary.setText("Scanning...")
|
|
106
|
+
|
|
107
|
+
self._collector = StatsCollector(self._s3, self._bucket, self._db, self)
|
|
108
|
+
self._collector.signals.progress.connect(self._on_progress)
|
|
109
|
+
self._collector.signals.complete.connect(self._on_complete)
|
|
110
|
+
self._collector.signals.error.connect(self._on_error)
|
|
111
|
+
self._collector.finished.connect(self._collector.deleteLater)
|
|
112
|
+
self._collector.start()
|
|
113
|
+
|
|
114
|
+
def _cancel_scan(self) -> None:
|
|
115
|
+
if self._collector:
|
|
116
|
+
self._collector.cancel()
|
|
117
|
+
self._scan_btn.setVisible(True)
|
|
118
|
+
self._cancel_btn.setVisible(False)
|
|
119
|
+
self._progress_bar.setVisible(False)
|
|
120
|
+
self._progress_label.setVisible(False)
|
|
121
|
+
self._summary.setText("Scan cancelled.")
|
|
122
|
+
|
|
123
|
+
def _on_progress(self, count: int) -> None:
|
|
124
|
+
self._progress_label.setText(f"Scanned {count:,} objects...")
|
|
125
|
+
|
|
126
|
+
def _on_complete(self, snapshot: BucketSnapshot) -> None:
|
|
127
|
+
self._scan_btn.setVisible(True)
|
|
128
|
+
self._scan_btn.setText("Rescan")
|
|
129
|
+
self._cancel_btn.setVisible(False)
|
|
130
|
+
self._progress_bar.setVisible(False)
|
|
131
|
+
self._progress_label.setVisible(False)
|
|
132
|
+
|
|
133
|
+
self._summary.setText(
|
|
134
|
+
f"Total: {snapshot.total_count:,} objects, {_format_size(snapshot.total_bytes)}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Breakdown table
|
|
138
|
+
self._breakdown_table.setVisible(True)
|
|
139
|
+
self._breakdown_table.setRowCount(len(snapshot.bytes_by_class))
|
|
140
|
+
for i, (cls, size) in enumerate(
|
|
141
|
+
sorted(snapshot.bytes_by_class.items(), key=lambda x: -x[1])
|
|
142
|
+
):
|
|
143
|
+
self._breakdown_table.setItem(i, 0, QTableWidgetItem(cls))
|
|
144
|
+
self._breakdown_table.setItem(i, 1, QTableWidgetItem(_format_size(size)))
|
|
145
|
+
count = snapshot.count_by_class.get(cls, 0)
|
|
146
|
+
self._breakdown_table.setItem(i, 2, QTableWidgetItem(f"{count:,}"))
|
|
147
|
+
|
|
148
|
+
# Top 10 largest
|
|
149
|
+
if snapshot.top_largest:
|
|
150
|
+
self._largest_label.setVisible(True)
|
|
151
|
+
self._largest_table.setVisible(True)
|
|
152
|
+
self._largest_table.setRowCount(len(snapshot.top_largest))
|
|
153
|
+
for i, entry in enumerate(snapshot.top_largest):
|
|
154
|
+
self._largest_table.setItem(i, 0, QTableWidgetItem(entry["key"]))
|
|
155
|
+
self._largest_table.setItem(i, 1, QTableWidgetItem(_format_size(entry["size"])))
|
|
156
|
+
|
|
157
|
+
def _on_error(self, msg: str) -> None:
|
|
158
|
+
self._scan_btn.setVisible(True)
|
|
159
|
+
self._cancel_btn.setVisible(False)
|
|
160
|
+
self._progress_bar.setVisible(False)
|
|
161
|
+
self._progress_label.setVisible(False)
|
|
162
|
+
self._summary.setText(f"Error: {msg}")
|