Files
Samsara/samsara/ui/window.py
oceans2alaska b4c0cf12ca Retain infrastructure names and share/mount roots behind policy switches
Add a `retain` block to privacy_policy.json (all on by default) so assessments
show software, services, processes, tasks, shares/mounts and private peer IPs.
Values pass an identifier-pattern filter (digit runs, SSN/phone, e-mail, charset,
length, path depth). Accounts, command lines, child paths, filenames and public
peer IPs stay omitted. Linux collector and Windows PowerShell collectors updated;
tests cover retention, withholding and all-switches-off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:03:49 -08:00

551 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from samsara.core.privacy import minimize_inventory, load_privacy_policy
from samsara.core.storage import report_directory
from uuid import uuid4
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.analyzers.policy import load_policy, policy_id
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('Operating system detected. Collecting minimized 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…')
data = minimize_inventory(data, trusted_source=data.get('server',{}).get('collection_source'))
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.scoring_policy = load_policy()
self.active_policy = self.scoring_policy
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 workload concerns. Historical usage validation and administrator approval are always 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.clear_history_button = QPushButton('Clear session assessments')
self.clear_history_button.clicked.connect(self.clear_history)
history.addWidget(self.clear_history_button)
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.addWidget(self.label('Privacy defaults: software, service, process, task, share and mount names plus share/mount root paths are kept unless switched off in privacy_policy.json; values that look like identifiers are withheld. Accounts, command lines, child paths, filenames, public peer IPs, certificate identities and SMB open-file details are never collected. Exports use a configured private local folder. Review docs/PRIVACY_HARDENING.md before real assessments.', muted=True))
settings.addWidget(self.label('Retirement scoring policy'))
self.policy_label = self.label('Default policy · ' + policy_id(self.scoring_policy)[:12], muted=True)
settings.addWidget(self.policy_label)
self.policy_button = QPushButton('Load scoring policy JSON…')
self.policy_button.clicked.connect(self.choose_policy)
settings.addWidget(self.policy_button)
self.reset_policy_button = QPushButton('Restore default scoring policy')
self.reset_policy_button.clicked.connect(self.reset_policy)
settings.addWidget(self.reset_policy_button)
settings.addWidget(self.label('Changes apply to new assessments. History keeps the original score and policy. Scores use the strongest workload, never an inventory count.', muted=True))
settings.addStretch()
def choose_policy(self):
path, _ = QFileDialog.getOpenFileName(self, 'Load scoring policy', '', 'Scoring policy (*.json)')
if not path:
return
try:
self.scoring_policy = load_policy(path)
self.policy_label.setText(self.scoring_policy['name'] + ' · ' + policy_id(self.scoring_policy)[:12])
except ValueError as exc:
QMessageBox.warning(self, 'Invalid scoring policy', str(exc))
def reset_policy(self):
self.scoring_policy = load_policy()
self.policy_label.setText('Default policy · ' + policy_id(self.scoring_policy)[:12])
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.active_policy = load_policy(self.scoring_policy)
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.clear_history_button.setEnabled(not busy)
self.policy_button.setEnabled(not busy)
self.reset_policy_button.setEnabled(not 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):
try:
data = minimize_inventory(data, trusted_source=data.get('server',{}).get('collection_source'))
except ValueError:
self.status.setText('Privacy validation failed; assessment withheld.')
return
assessment = assess(data, policy=self.active_policy)
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))
while len(self.records)>load_privacy_policy()['max_session_assessments']:
self.records.pop(0)
self.history.pop(0)
self.history_list.takeItem(0)
self.history_list.addItem(f"{data['collected_at']} • {data['server']['hostname']} • {assessment['level'].title()} risk")
self.history_empty.hide()
self.display_assessment(data, assessment)
self.nav.setCurrentRow(0)
incomplete = len(assessment['coverage']['incomplete_checks'])
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"Evidence coverage: {summary['coverage_percent']}% · Confidence: {summary['confidence'].title()} · {summary['incomplete']} checks need review. Coverage gaps do not add risk points.")
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 nonroutine enabled schedules identified. Schedule detail is omitted; review Tasks and Collection checks before concluding no work exists.')
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 clear_history(self):
self.history.clear()
self.records.clear()
self.history_list.clear()
self.history_empty.show()
self.data = self.assessment = None
self.result_title.setText('No assessment loaded')
self.banner.clear()
for label in (self.retirement_score,self.coverage_note,self.os_type,self.os_version,self.important_software,self.scheduled_jobs):
label.clear()
for area in self.detail_areas.values():
while area.count():
item=area.takeAt(0)
if item.widget(): item.widget().deleteLater()
area.addWidget(self.label('No assessment loaded.',muted=True))
self.report_label.setText('Analyze a computer or open an inventory first.')
for button in self.report_buttons: button.setEnabled(False)
self.quick_save.setEnabled(False)
self.status.setText('Session assessment references cleared. This is not secure memory erasure; exported files remain.')
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'):
findings = self.assessment['findings']
if key == 'dependencies':
findings = [row for row in findings if row['classification'] in ('Confirmed workload dependency','Potential workload dependency')]
rows = [{name: row[name] for name in ('category','classification','severity','points','determines_final_score','evidence','scoring_reason')} for row in findings]
else:
rows = self.data['inventory'][key]
if key == 'risk':
area.addWidget(self.label(self.assessment['scoring']['formula'] + '\n' + self.assessment['scoring']['explanation'] + '\nPolicy: ' + self.assessment['scoring']['policy']['name'] + ' · ' + self.assessment['scoring']['policy_id'][:12], muted=True))
if key == 'network':
observations = self.assessment['network_observations']
rows = [{**row, 'assessed_direction': observations[index]['direction'], 'direction_basis': observations[index]['direction_basis'], 'activity_classification': observations[index]['classification']} for index,row in enumerate(rows)]
if key in self.data['checks']:
check = self.data['checks'][key]
area.addWidget(self.label(f"{check['status'].replace('_', ' ').title()}: {check['message']}", muted=True))
for error in check.get('errors', []):
area.addWidget(self.label(f"{error['scope']} [{error['code']}]: {error['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
try:
suggested = str(report_directory() / f'samsara-{uuid4().hex[:12]}.{kind}')
except (OSError, ValueError):
QMessageBox.warning(self, 'Save blocked', 'Provision the private report directory described in docs/PRIVACY_HARDENING.md.')
return
path, _ = QFileDialog.getSaveFileName(self, 'Save minimized assessment', suggested, 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', 'Minimized assessment saved. No raw inventory sidecar was created.')
except (OSError, ValueError):
QMessageBox.warning(self, 'Save failed', 'Use a new filename directly in the approved private report directory. Check its 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()