468 lines
24 KiB
Python
468 lines
24 KiB
Python
from PySide6.QtCore import Qt, QThread, Signal, QSortFilterProxyModel
|
||
from PySide6.QtGui import QStandardItem, QStandardItemModel
|
||
from PySide6.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel,
|
||
QPushButton, QListWidget, QStackedWidget, QLineEdit, QTableView,
|
||
QFileDialog, QMessageBox, QComboBox, QHeaderView, QFrame, QProgressBar, QScrollArea, QSizePolicy, QLayout, QTabWidget)
|
||
|
||
from samsara.ui.branding import application_icon
|
||
from samsara.collectors.demo import collect_demo, DEMO_HOST, LINUX_DEMO_HOST
|
||
from samsara.collectors.importer import load_inventory
|
||
from samsara.collectors.linux import collect_local_linux, CollectionCancelled
|
||
from samsara.collectors.windows import collect_local_windows
|
||
from samsara.core.detection import detect_local_os, check_profile, PROFILES
|
||
from samsara.analyzers.assessment import assess
|
||
from samsara.analyzers.summary import summarize
|
||
from samsara.reporting.export import export_html, export_json
|
||
|
||
|
||
class CollectionJob(QThread):
|
||
stage = Signal(str)
|
||
result = Signal(dict)
|
||
failed = Signal(str)
|
||
|
||
def __init__(self, source, profile='auto', inventory_path='', parent=None):
|
||
super().__init__(parent)
|
||
self.source, self.profile, self.inventory_path = source, profile, inventory_path
|
||
|
||
def run(self):
|
||
try:
|
||
if self.source == 'local':
|
||
self.stage.emit('Detecting operating system…')
|
||
detected = detect_local_os()
|
||
self.stage.emit('Detected ' + detected['name'] + '. Collecting inventory…')
|
||
if detected['platform'] == 'windows':
|
||
data = collect_local_windows(profile=self.profile, stage=self.stage.emit, cancelled=self.isInterruptionRequested)
|
||
else:
|
||
check_profile(detected, self.profile)
|
||
data = collect_local_linux(stage=self.stage.emit, cancelled=self.isInterruptionRequested)
|
||
elif self.source == 'file':
|
||
self.stage.emit('Reading saved inventory…')
|
||
data = load_inventory(self.inventory_path)
|
||
else:
|
||
self.stage.emit('Loading sample assessment…')
|
||
data = collect_demo(DEMO_HOST if self.source == 'windows_demo' else LINUX_DEMO_HOST)
|
||
if self.isInterruptionRequested():
|
||
raise CollectionCancelled()
|
||
self.stage.emit('Preparing assessment report…')
|
||
self.result.emit(data)
|
||
except CollectionCancelled:
|
||
self.failed.emit('Cancelled. Your previous assessment has been kept.')
|
||
except ValueError as exc:
|
||
self.failed.emit(str(exc))
|
||
except Exception:
|
||
self.failed.emit('Unable to complete assessment. Check file access and collection availability; previous results have been kept.')
|
||
|
||
|
||
class SearchTable(QWidget):
|
||
def __init__(self, rows):
|
||
super().__init__()
|
||
layout = QVBoxLayout(self)
|
||
self.setMinimumHeight(230)
|
||
search = QLineEdit(placeholderText='Search all columns…')
|
||
layout.addWidget(search)
|
||
view = QTableView()
|
||
model = QStandardItemModel(self)
|
||
columns = list(dict.fromkeys(key for row in rows for key in row))
|
||
model.setHorizontalHeaderLabels([key.replace('_', ' ').title() for key in columns])
|
||
for row in rows:
|
||
items = []
|
||
for key in columns:
|
||
value = row.get(key)
|
||
item = QStandardItem()
|
||
item.setData('' if value is None else value if isinstance(value, (str, int, float)) else str(value), Qt.ItemDataRole.DisplayRole)
|
||
item.setEditable(False)
|
||
items.append(item)
|
||
model.appendRow(items)
|
||
proxy = QSortFilterProxyModel(self)
|
||
proxy.setSourceModel(model)
|
||
proxy.setFilterKeyColumn(-1)
|
||
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||
search.textChanged.connect(proxy.setFilterFixedString)
|
||
view.setModel(proxy)
|
||
view.setSortingEnabled(True)
|
||
view.setAlternatingRowColors(True)
|
||
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||
view.horizontalHeader().setStretchLastSection(True)
|
||
view.resizeColumnsToContents()
|
||
layout.addWidget(view)
|
||
if not rows:
|
||
layout.addWidget(QLabel('No records in this inventory. This is not proof of absence.'))
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle('Samsara')
|
||
self.setWindowIcon(application_icon())
|
||
self.resize(1180, 820)
|
||
self.setMinimumSize(800, 620)
|
||
self.job = None
|
||
self.data = None
|
||
self.assessment = None
|
||
self.history = []
|
||
self.records = []
|
||
self.setStyleSheet('''
|
||
QWidget { background:#131b27; color:#e5ecf5; font-size:14px; }
|
||
QLabel#title { font-size:27px; font-weight:600; }
|
||
QLabel#muted { color:#a4b3c7; }
|
||
QLabel#summary { font-size:17px; font-weight:600; }
|
||
QFrame#card { background:#1b2839; border:1px solid #304158; border-radius:8px; }
|
||
QFrame#card QLabel { background:transparent; }
|
||
QPushButton { background:#283b52; border:1px solid #40546c; border-radius:5px; padding:9px 16px; }
|
||
QPushButton#primary { background:#287ac0; border:0; font-weight:600; }
|
||
QPushButton:hover { background:#346087; } QPushButton:disabled { color:#7e8c9e; background:#253143; }
|
||
QLineEdit,QComboBox { background:#1e2c3e; border:1px solid #465b75; border-radius:5px; padding:9px; min-height:20px; }
|
||
QListWidget { background:#172231; border:0; outline:0; }
|
||
QListWidget::item { padding:14px 12px; margin:3px; border-radius:5px; }
|
||
QListWidget::item:selected { background:#254b70; }
|
||
QTabBar::tab { background:#1b2839; padding:10px 12px; border-bottom:2px solid transparent; }
|
||
QTabBar::tab:selected { border-bottom:2px solid #469fe4; background:#24384f; }
|
||
QHeaderView::section { background:#273a51; padding:9px; border:0; }
|
||
QTableView { alternate-background-color:#1b293c; gridline-color:#304057; border:1px solid #304057; }
|
||
QProgressBar { max-height:4px; border:0; } QProgressBar::chunk { background:#398ed1; }
|
||
''')
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
outer = QHBoxLayout(central)
|
||
outer.setContentsMargins(0, 0, 0, 0)
|
||
sidebar = QVBoxLayout()
|
||
sidebar.setContentsMargins(12, 22, 12, 16)
|
||
brand_row = QHBoxLayout()
|
||
brand_row.setSpacing(8)
|
||
self.logo = QLabel()
|
||
self.logo.setFixedSize(40, 40)
|
||
self.logo.setPixmap(application_icon().pixmap(40, 40))
|
||
self.logo.setToolTip('Samsara')
|
||
self.logo.setAccessibleName('Samsara logo')
|
||
brand_row.addWidget(self.logo)
|
||
brand = QLabel('Samsara')
|
||
brand.setObjectName('summary')
|
||
brand_row.addWidget(brand)
|
||
sidebar.addLayout(brand_row)
|
||
sidebar.addWidget(self.label('Retirement assessments', muted=True))
|
||
self.nav = QListWidget()
|
||
self.nav.addItems(['Summary', 'Analyze', 'History', 'Reports', 'Settings'])
|
||
self.nav.setFixedWidth(170)
|
||
sidebar.addWidget(self.nav)
|
||
outer.addLayout(sidebar)
|
||
self.pages = QStackedWidget()
|
||
outer.addWidget(self.pages, 1)
|
||
self.build_pages()
|
||
self.nav.currentRowChanged.connect(lambda index: self.pages.setCurrentIndex({0: 1, 1: 0, 2: 2, 3: 3, 4: 4}[index]))
|
||
self.nav.setCurrentRow(1)
|
||
self.source_changed()
|
||
|
||
def label(self, text, muted=False):
|
||
label = QLabel(text)
|
||
label.setWordWrap(True)
|
||
label.setTextFormat(Qt.TextFormat.PlainText)
|
||
if muted:
|
||
label.setObjectName('muted')
|
||
return label
|
||
|
||
def page(self, title, subtitle):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
layout.setContentsMargins(24, 22, 24, 20)
|
||
layout.setSpacing(14)
|
||
heading = self.label(title)
|
||
heading.setObjectName('title')
|
||
layout.addWidget(heading)
|
||
layout.addWidget(self.label(subtitle, muted=True))
|
||
layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
scroll.setWidget(widget)
|
||
self.pages.addWidget(scroll)
|
||
return layout
|
||
|
||
def build_pages(self):
|
||
analyze = self.page('Analyze', 'Choose a source, then analyze its retirement dependencies and risks.')
|
||
card = QFrame()
|
||
card.setObjectName('card')
|
||
card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||
form = QVBoxLayout(card)
|
||
form.setContentsMargins(18, 16, 18, 16)
|
||
form.setSpacing(10)
|
||
row = QHBoxLayout()
|
||
source_col = QVBoxLayout()
|
||
source_col.addWidget(self.label('Assessment source'))
|
||
self.source = QComboBox()
|
||
for label, value in [('This computer', 'local'), ('Saved inventory file', 'file'),
|
||
('Windows Server sample', 'windows_demo'), ('RHEL sample', 'linux_demo')]:
|
||
self.source.addItem(label, value)
|
||
self.source.currentIndexChanged.connect(self.source_changed)
|
||
source_col.addWidget(self.source)
|
||
row.addLayout(source_col, 1)
|
||
os_col = QVBoxLayout()
|
||
os_col.addWidget(self.label('Operating system'))
|
||
self.target = QComboBox()
|
||
for label, value in PROFILES:
|
||
self.target.addItem(label, value)
|
||
os_col.addWidget(self.target)
|
||
row.addLayout(os_col, 1)
|
||
form.addLayout(row)
|
||
self.file_row = QWidget()
|
||
file_layout = QHBoxLayout(self.file_row)
|
||
file_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.inventory_path = QLineEdit(placeholderText='Choose an inventory JSON file…')
|
||
file_layout.addWidget(self.inventory_path, 1)
|
||
self.browse = QPushButton('Browse…')
|
||
self.browse.clicked.connect(self.browse_inventory)
|
||
file_layout.addWidget(self.browse)
|
||
form.addWidget(self.file_row)
|
||
self.source_hint = self.label('', muted=True)
|
||
form.addWidget(self.source_hint)
|
||
actions = QHBoxLayout()
|
||
self.analyze_button = QPushButton('Analyze')
|
||
self.analyze_button.setObjectName('primary')
|
||
self.analyze_button.clicked.connect(self.start_collection)
|
||
actions.addWidget(self.analyze_button)
|
||
self.cancel_button = QPushButton('Cancel')
|
||
self.cancel_button.clicked.connect(self.cancel_collection)
|
||
self.cancel_button.setVisible(False)
|
||
actions.addWidget(self.cancel_button)
|
||
actions.addStretch()
|
||
form.addLayout(actions)
|
||
analyze.addWidget(card)
|
||
self.status = self.label('Ready to analyze.', muted=True)
|
||
analyze.addWidget(self.status)
|
||
self.progress = QProgressBar()
|
||
self.progress.setRange(0, 0)
|
||
self.progress.hide()
|
||
analyze.addWidget(self.progress)
|
||
analyze.addStretch()
|
||
summary_page = self.page('Assessment', 'Start with the summary, then explore the evidence in each tab.')
|
||
self.result_title = self.label('No assessment yet')
|
||
self.result_title.setObjectName('summary')
|
||
self.banner = self.label('Choose Analyze to collect or open inventory.', muted=True)
|
||
summary_page.addWidget(self.result_title)
|
||
summary_page.addWidget(self.banner)
|
||
self.result_tabs = QTabWidget()
|
||
self.result_tabs.setUsesScrollButtons(True)
|
||
self.result_tabs.setDocumentMode(True)
|
||
summary_page.addWidget(self.result_tabs, 1)
|
||
self.summary_widget = QWidget()
|
||
summary_layout = QVBoxLayout(self.summary_widget)
|
||
summary_layout.setContentsMargins(12, 16, 12, 16)
|
||
summary_layout.setSpacing(16)
|
||
score_card = QFrame()
|
||
score_card.setObjectName('card')
|
||
score_card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||
score_layout = QVBoxLayout(score_card)
|
||
self.retirement_score = self.label('Retirement risk estimate: —')
|
||
self.retirement_score.setObjectName('summary')
|
||
score_layout.addWidget(self.retirement_score)
|
||
score_layout.addWidget(self.label('Higher scores mean more retirement concerns. Heuristic only; administrator review is required.', muted=True))
|
||
self.coverage_note = self.label('', muted=True)
|
||
score_layout.addWidget(self.coverage_note)
|
||
summary_layout.addWidget(score_card)
|
||
self.os_type = self.label('OS type: —')
|
||
self.os_version = self.label('Version: —')
|
||
summary_layout.addWidget(self.os_type)
|
||
summary_layout.addWidget(self.os_version)
|
||
software_heading = self.label('Important software')
|
||
software_heading.setObjectName('summary')
|
||
summary_layout.addWidget(software_heading)
|
||
self.important_software = self.label('Run an assessment to see detected server software.')
|
||
summary_layout.addWidget(self.important_software)
|
||
summary_layout.addWidget(self.label('Workload name matches and application services; see Software for the full inventory.', muted=True))
|
||
jobs_heading = self.label('Scheduled jobs & scripts')
|
||
jobs_heading.setObjectName('summary')
|
||
summary_layout.addWidget(jobs_heading)
|
||
self.scheduled_jobs = self.label('Run an assessment to see enabled schedules.')
|
||
summary_layout.addWidget(self.scheduled_jobs)
|
||
summary_layout.addStretch()
|
||
self.quick_save = QPushButton('Save HTML report')
|
||
self.quick_save.setEnabled(False)
|
||
self.quick_save.clicked.connect(lambda: self.save_report('html'))
|
||
summary_layout.addWidget(self.quick_save)
|
||
self.result_tabs.addTab(self.summary_widget, 'Summary')
|
||
self.detail_areas = {}
|
||
for title, key in [('Networking', 'network'), ('Software', 'software'), ('Tasks', 'tasks'),
|
||
('Dependencies', 'dependencies'), ('Services', 'services'), ('Storage & shares', 'storage'),
|
||
('Roles', 'roles'), ('Certificates', 'certificates'), ('Risk findings', 'risk'),
|
||
('Collection checks', 'checks'), ('System', 'overview')]:
|
||
tab = QWidget()
|
||
area = QVBoxLayout(tab)
|
||
area.addWidget(self.label('No assessment loaded.', muted=True))
|
||
self.detail_areas[key] = area
|
||
self.result_tabs.addTab(tab, title)
|
||
self.result_tabs.currentChanged.connect(self.show_details)
|
||
history = self.page('History', 'Assessments from this session. Select a record to open it.')
|
||
self.history_list = QListWidget()
|
||
self.history_list.itemActivated.connect(self.open_history)
|
||
self.history_list.itemClicked.connect(self.open_history)
|
||
history.addWidget(self.history_list)
|
||
self.history_empty = self.label('No assessments yet.', muted=True)
|
||
history.addWidget(self.history_empty)
|
||
reports = self.page('Reports', 'Save the current assessment, including evidence and missing checks.')
|
||
self.report_label = self.label('Analyze a computer or open an inventory first.', muted=True)
|
||
reports.addWidget(self.report_label)
|
||
self.report_buttons = []
|
||
for label, kind in [('Save HTML report', 'html'), ('Export JSON', 'json')]:
|
||
button = QPushButton(label)
|
||
button.clicked.connect(lambda checked=False, fmt=kind: self.save_report(fmt))
|
||
button.setEnabled(False)
|
||
self.report_buttons.append(button)
|
||
reports.addWidget(button)
|
||
reports.addStretch()
|
||
settings = self.page('Settings', 'Collection availability and assessment behavior.')
|
||
settings.addWidget(self.label('Windows Server 2016 / 2019 / 2022 / 2025\nRed Hat Enterprise Linux 7 (legacy) / 8 / 9 / 10 and compatible distributions'))
|
||
settings.addWidget(self.label('Local Windows Server and Linux collectors are available. Windows validation on a supported host is pending. Remote collection is a separate follow-up. Saved inventories and samples work on either desktop.', muted=True))
|
||
settings.addWidget(self.label('Read-only collection • No credentials stored • Session history only', muted=True))
|
||
settings.addStretch()
|
||
|
||
def source_changed(self):
|
||
source = self.source.currentData()
|
||
self.file_row.setVisible(source == 'file')
|
||
self.target.setEnabled(source == 'local' and self.job is None)
|
||
if source != 'local':
|
||
self.target.setCurrentIndex(0)
|
||
hints = {'local': 'Auto-detect identifies this computer’s OS and runs its available collector. No remote connection is made.',
|
||
'file': 'The operating system is read from the saved inventory. No connection is made.',
|
||
'windows_demo': 'Synthetic Windows Server 2022 example. No server is contacted.',
|
||
'linux_demo': 'Synthetic RHEL 9 example. No server is contacted.'}
|
||
self.source_hint.setText(hints[source])
|
||
|
||
def browse_inventory(self):
|
||
path, _ = QFileDialog.getOpenFileName(self, 'Open inventory', '', 'Inventory JSON (*.json)')
|
||
if path:
|
||
self.inventory_path.setText(path)
|
||
|
||
def start_collection(self):
|
||
if self.job is not None:
|
||
return
|
||
source = self.source.currentData()
|
||
if source == 'file' and not self.inventory_path.text().strip():
|
||
self.status.setText('Choose an inventory file before analyzing.')
|
||
return
|
||
self.set_busy(True)
|
||
self.status.setText('Starting assessment…')
|
||
self.job = CollectionJob(source, self.target.currentData(), self.inventory_path.text().strip(), self)
|
||
self.job.stage.connect(self.status.setText)
|
||
self.job.result.connect(self.collection_done)
|
||
self.job.failed.connect(self.status.setText)
|
||
self.job.finished.connect(self.job_finished)
|
||
self.job.start()
|
||
|
||
def set_busy(self, busy):
|
||
self.analyze_button.setEnabled(not busy)
|
||
self.source.setEnabled(not busy)
|
||
self.target.setEnabled(not busy and self.source.currentData() == 'local')
|
||
self.browse.setEnabled(not busy)
|
||
self.inventory_path.setEnabled(not busy)
|
||
self.cancel_button.setVisible(busy)
|
||
self.cancel_button.setEnabled(busy)
|
||
self.progress.setVisible(busy)
|
||
|
||
def cancel_collection(self):
|
||
if self.job is not None:
|
||
self.job.requestInterruption()
|
||
self.cancel_button.setEnabled(False)
|
||
self.status.setText('Cancelling…')
|
||
|
||
def source_label(self, data):
|
||
if data['synthetic']:
|
||
return 'Synthetic sample'
|
||
return data['server'].get('collection_source', 'Imported inventory — source not independently verified')
|
||
|
||
def collection_done(self, data):
|
||
assessment = assess(data)
|
||
self.history.append({'hostname': data['server']['hostname'], 'risk': assessment['level'],
|
||
'source': self.source_label(data), 'collected_at': data['collected_at']})
|
||
self.records.append((data, assessment))
|
||
self.history_list.addItem(f"{data['server']['hostname']} • {assessment['level'].title()} risk • {self.source_label(data)}")
|
||
self.history_empty.hide()
|
||
self.display_assessment(data, assessment)
|
||
self.nav.setCurrentRow(0)
|
||
incomplete = sum(check['status'] != 'complete' for check in data['checks'].values())
|
||
self.status.setText(f'Assessment report ready. {incomplete} checks need review. Open the Summary and detail tabs to review the report.')
|
||
|
||
def display_assessment(self, data, assessment):
|
||
self.data, self.assessment = data, assessment
|
||
summary = summarize(data, assessment)
|
||
self.result_title.setText(data['server']['hostname'])
|
||
self.banner.setText(self.source_label(data) + ' • ' + data['collected_at'])
|
||
self.retirement_score.setText(f"Retirement risk estimate: {summary['risk_score']}/100 · {summary['risk_level'].title()}")
|
||
self.coverage_note.setText(f"{summary['incomplete']} checks incomplete or missing. This estimate does not establish retirement readiness.")
|
||
self.os_type.setText('OS type: ' + summary['os_type'])
|
||
self.os_version.setText('Version: ' + summary['version'])
|
||
software = summary['software']
|
||
self.important_software.setText('\n'.join(f"• {row['name']} — {row['version']}" for row in software[:6]) +
|
||
(f"\n+ {len(software) - 6} more matches; see Software." if len(software) > 6 else '') if software else
|
||
'No server-software name hints identified in collected evidence. Review the full Software and Services tabs.')
|
||
self.important_software.setToolTip('Name-based workload hints or running application services; not a complete application inventory and not proof of use.')
|
||
jobs = summary['jobs']
|
||
self.scheduled_jobs.setText('\n'.join(f"• {row['name']} — {row['schedule']}" for row in jobs[:5]) +
|
||
(f"\n+ {len(jobs) - 5} more; see Tasks." if len(jobs) > 5 else '') +
|
||
'\nSchedules indicate configured work; execution is not confirmed.' if jobs else
|
||
'No enabled schedules observed in collected evidence. Check Tasks and Collection checks for missing scheduler coverage.')
|
||
self.quick_save.setEnabled(True)
|
||
self.result_tabs.setCurrentIndex(0)
|
||
# Discard tables from the previous assessment. Populate detailed tabs on demand.
|
||
for area in self.detail_areas.values():
|
||
while area.count():
|
||
widget = area.takeAt(0).widget()
|
||
if widget:
|
||
widget.hide()
|
||
widget.deleteLater()
|
||
self.show_details()
|
||
self.report_label.setText('Current assessment: ' + data['server']['hostname'] + '\n' + self.source_label(data))
|
||
for button in self.report_buttons:
|
||
button.setEnabled(True)
|
||
|
||
def open_history(self, item):
|
||
index = self.history_list.row(item)
|
||
if 0 <= index < len(self.records):
|
||
self.display_assessment(*self.records[index])
|
||
self.status.setText('Opened assessment from session history.')
|
||
self.nav.setCurrentRow(0)
|
||
|
||
def show_details(self):
|
||
if self.data is None or self.result_tabs.currentIndex() == 0:
|
||
return
|
||
key = list(self.detail_areas)[self.result_tabs.currentIndex() - 1]
|
||
area = self.detail_areas[key]
|
||
if area.count():
|
||
return
|
||
if key == 'overview':
|
||
rows = [{'property': name.replace('_', ' ').title(), 'value': str(value)} for name, value in self.data['server'].items()]
|
||
elif key == 'checks':
|
||
rows = [{'check': name, **value} for name, value in self.data['checks'].items()]
|
||
elif key in ('risk', 'dependencies'):
|
||
rows = self.assessment['findings']
|
||
else:
|
||
rows = self.data['inventory'][key]
|
||
if key in self.data['checks']:
|
||
check = self.data['checks'][key]
|
||
area.addWidget(self.label(f"{check['status'].replace('_', ' ').title()}: {check['message']}", muted=True))
|
||
area.addWidget(SearchTable(rows))
|
||
|
||
def job_finished(self):
|
||
self.job.deleteLater()
|
||
self.job = None
|
||
self.set_busy(False)
|
||
|
||
def save_report(self, kind):
|
||
if self.data is None:
|
||
return
|
||
path, _ = QFileDialog.getSaveFileName(self, 'Save assessment', f'samsara-assessment.{kind}', f'{kind.upper()} (*.{kind})')
|
||
if not path:
|
||
return
|
||
if not path.lower().endswith('.' + kind):
|
||
path += '.' + kind
|
||
try:
|
||
(export_html if kind == 'html' else export_json)(path, self.data, self.assessment)
|
||
QMessageBox.information(self, 'Report saved', 'Assessment saved locally.')
|
||
except OSError:
|
||
QMessageBox.warning(self, 'Save failed', 'Unable to write the report. Check the destination and file permissions.')
|
||
|
||
def closeEvent(self, event):
|
||
if self.job is not None and self.job.isRunning():
|
||
self.status.setText('Cancel the running assessment before closing.')
|
||
event.ignore()
|
||
else:
|
||
event.accept()
|