Rename application to Samsara and add assessment summary, detailed tabs and local Linux inventory

This commit is contained in:
2026-10-30 10:25:55 -07:00
parent 365f97942a
commit 967b2bd46a
37 changed files with 985 additions and 541 deletions

465
samsara/ui/window.py Normal file
View File

@@ -0,0 +1,465 @@
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.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()
if detected['platform'] != 'linux':
raise ValueError('Windows live collection is not available yet. Open a saved inventory or a sample assessment.')
check_profile(detected, self.profile)
self.stage.emit('Detected ' + detected['name'] + '. Collecting inventory…')
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 Linux collection is available. Live Windows and remote collection are not available yet. 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()