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('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.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.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('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.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): 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)) 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 = 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. Full schedules and missing scheduler coverage are in Tasks and Collection checks.') 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'): 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)) 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', 'HTML report and separate full inventory JSON saved locally.' if kind == 'html' else 'Assessment and full inventory saved as JSON.') 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()