Add Server Buddy desktop app with local Linux assessment and reporting

This commit is contained in:
2026-10-30 09:46:01 -07:00
commit 365f97942a
29 changed files with 1789 additions and 0 deletions

318
server_buddy/ui/window.py Normal file
View File

@@ -0,0 +1,318 @@
import platform
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, QTabWidget, QTableView,
QFileDialog, QMessageBox, QComboBox, QHeaderView)
from server_buddy.collectors.demo import collect_demo, DEMO_HOST, LINUX_DEMO_HOST
from server_buddy.collectors.importer import load_inventory
from server_buddy.collectors.linux import collect_local_linux, CollectionCancelled
from server_buddy.core.validation import validate_hostname
from server_buddy.analyzers.assessment import assess
from server_buddy.reporting.export import export_html, export_json
class CollectionJob(QThread):
stage = Signal(str)
result = Signal(dict)
failed = Signal(str)
def __init__(self, hostname, parent=None, inventory_path=None, local_linux=False):
super().__init__(parent)
self.hostname = hostname
self.inventory_path = inventory_path
self.local_linux = local_linux
def run(self):
try:
self.stage.emit('Loading inventory JSON…' if self.inventory_path else 'Loading synthetic inventory…')
if self.local_linux:
data = collect_local_linux(stage=self.stage.emit, cancelled=self.isInterruptionRequested)
else:
data = load_inventory(self.inventory_path) if self.inventory_path else collect_demo(self.hostname)
if self.isInterruptionRequested():
raise CollectionCancelled('Collection cancelled; previous assessment retained.')
self.stage.emit('Evaluating inventory evidence…')
self.result.emit(data)
except CollectionCancelled:
self.failed.emit('Collection cancelled; previous assessment retained.')
except ValueError as exc:
self.failed.emit(str(exc))
except Exception:
self.failed.emit('Collection could not complete. Verify the selected environment, inventory format and file access. No synthetic replacement was used.')
class SearchTable(QWidget):
def __init__(self, rows):
super().__init__()
layout = QVBoxLayout(self)
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('Server Buddy — Retirement Assessment')
self.resize(1280, 820)
self.setMinimumSize(760, 540)
self.job = None
self.data = collect_demo(DEMO_HOST)
self.assessment = assess(self.data)
self.history = [self.history_row()]
self.setStyleSheet('''
QWidget { background:#121b29; color:#e4eaf2; font-size:13px; }
QLabel#title { font-size:24px; font-weight:600; }
QLabel#banner { background:#46391b; color:#ffde8a; padding:12px; }
QPushButton { background:#236a9b; border:0; border-radius:5px; padding:10px 16px; }
QPushButton:hover { background:#3284ba; } QPushButton:disabled { background:#394355; }
QLineEdit,QComboBox { background:#202e40; border:1px solid #40516a; padding:9px; }
QListWidget { background:#182334; border:0; min-width:170px; }
QListWidget::item { padding:16px 10px; } QListWidget::item:selected { background:#235477; }
QTabBar::tab { padding:9px; background:#243348; } QTabBar::tab:selected { background:#236a9b; }
QHeaderView::section { background:#293c53; padding:8px; border:0; }
QTableView { alternate-background-color:#1b293c; gridline-color:#304057; }
''')
central = QWidget()
self.setCentralWidget(central)
layout = QHBoxLayout(central)
self.nav = QListWidget()
self.nav.addItems(['Dashboard', 'Server Analyzer', 'Dependency Explorer', 'Assessment History', 'Reports', 'Settings'])
self.nav.setMaximumWidth(220)
layout.addWidget(self.nav)
right = QVBoxLayout()
layout.addLayout(right, 1)
banner = self.banner = QLabel('SYNTHETIC DEMO • Offline fixtures only • No infrastructure is contacted')
banner.setObjectName('banner')
banner.setWordWrap(True)
right.addWidget(banner)
self.pages = QStackedWidget()
right.addWidget(self.pages)
self.build_pages()
self.nav.currentRowChanged.connect(self.pages.setCurrentIndex)
self.nav.setCurrentRow(0)
def page(self, title):
widget = QWidget()
layout = QVBoxLayout(widget)
label = QLabel(title)
label.setObjectName('title')
layout.addWidget(label)
self.pages.addWidget(widget)
return layout
def history_row(self):
return {'hostname': self.data['server']['hostname'], 'collected_at': self.data['collected_at'],
'risk': self.assessment['level'], 'source': 'Synthetic fixture' if self.data['synthetic'] else self.data['server'].get('collection_source', 'Imported inventory')}
def build_pages(self):
dash = self.page('Retirement assessment dashboard')
self.metrics = QLabel()
self.metrics.setWordWrap(True)
dash.addWidget(self.metrics)
dash.addWidget(QLabel('Recently assessed servers — see Source column'))
self.recent_area = QVBoxLayout()
dash.addLayout(self.recent_area)
analyzer = self.page('Server Analyzer')
analyzer.addWidget(QLabel('Target platform (demo inventory)'))
self.target = QComboBox()
self.target.addItems(['Windows Server 2016 onward', 'RHEL-based Linux'])
self.target.currentIndexChanged.connect(self.target_changed)
analyzer.addWidget(self.target)
analyzer.addWidget(QLabel('Server Hostname or FQDN'))
row = QHBoxLayout()
self.hostname = QLineEdit(DEMO_HOST)
row.addWidget(self.hostname)
self.analyze_button = QPushButton('Analyze Server')
self.analyze_button.clicked.connect(self.start_collection)
self.hostname.returnPressed.connect(self.start_collection)
row.addWidget(self.analyze_button)
analyzer.addLayout(row)
self.local_button = QPushButton('Analyze This Linux PC — read-only')
self.local_button.setEnabled(platform.system() == 'Linux')
self.local_button.clicked.connect(self.start_local_collection)
analyzer.addWidget(self.local_button)
note = QLabel('Local collection assesses the computer running Server Buddy. No remote hostname is used. Mint testing uses dpkg; RHEL-based Linux uses RPM. No sudo required.')
note.setWordWrap(True)
analyzer.addWidget(note)
self.cancel_button = QPushButton('Cancel collection')
self.cancel_button.setEnabled(False)
self.cancel_button.clicked.connect(self.cancel_collection)
analyzer.addWidget(self.cancel_button)
self.import_button = QPushButton('Import Windows / Linux inventory JSON')
self.import_button.clicked.connect(self.import_inventory)
analyzer.addWidget(self.import_button)
self.status = QLabel('Ready. Demo supports only ' + DEMO_HOST)
self.status.setWordWrap(True)
analyzer.addWidget(self.status)
self.tabs = QTabWidget()
self.tabs.setUsesScrollButtons(True)
analyzer.addWidget(self.tabs)
deps = self.page('Dependency Explorer')
deps.addWidget(QLabel('Evidence indicates possible consumers; confirm ownership and activity over time.'))
self.deps_area = QVBoxLayout()
deps.addLayout(self.deps_area)
history = self.page('Assessment History')
history.addWidget(QLabel('Session history only. Source column distinguishes synthetic and imported records.'))
self.history_area = QVBoxLayout()
history.addLayout(self.history_area)
reports = self.page('Reports')
reports.addWidget(QLabel('Export the current assessment with source labels, missing evidence and manual checks. Review inventory for sensitive content before sharing.'))
for label, fmt in [('Save HTML report', 'html'), ('Export JSON', 'json')]:
button = QPushButton(label)
button.clicked.connect(lambda checked=False, kind=fmt: self.save_report(kind))
reports.addWidget(button)
reports.addStretch()
settings = self.page('Settings')
settings.addWidget(QLabel('Execution environment for Analyze Server (JSON import is always offline)'))
self.environment = QComboBox()
self.environment.addItems(['Offline synthetic demo', 'Windows execution host (future phase)', 'This Linux PC — read-only'])
settings.addWidget(self.environment)
info = QLabel(f'Local platform: {platform.system()}\nWindows remoting is not implemented in Phase 1.\nPlanned targets: Windows Server 2016 onward and RHEL-based Linux.\nWindows collection: authorized Windows execution host. Linux collection: local Mint / RHEL-based inventory now available; remote SSH is planned.\nWindows and Linux desktops can import either inventory format.\nNo credentials are requested or stored. No remote configuration is performed.')
info.setWordWrap(True)
settings.addWidget(info)
settings.addStretch()
self.refresh()
def replace_table(self, layout, rows):
while layout.count():
item = layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
layout.addWidget(SearchTable(rows))
def refresh(self):
high = sum(f['severity'] in ('high', 'critical') for f in self.assessment['findings'])
self.metrics.setText(f'Total servers assessed: {len({row["hostname"] for row in self.history})} • Assessments requiring review: {len({row["hostname"] for row in self.history})} • High-risk findings (current assessment): {high}\nHeuristic risk: {self.assessment["level"].upper()} ({self.assessment["score"]}/100). Administrator review required; never an automatic retirement decision.')
self.replace_table(self.recent_area, [self.history[-1]])
self.replace_table(self.history_area, self.history)
self.replace_table(self.deps_area, self.assessment['findings'])
while self.tabs.count():
widget = self.tabs.widget(0)
self.tabs.removeTab(0)
widget.deleteLater()
overview = [{'property': k.replace('_', ' ').title(), 'value': str(v)} for k, v in self.data['server'].items()]
overview += [{'property': 'Collection timestamp (reported by source)', 'value': self.data['collected_at']}]
overview += [{'property': f'Check: {k}', 'value': f'{v["status"]}: {v["message"]}'} for k, v in self.data['checks'].items()]
self.tabs.addTab(SearchTable(overview), 'Overview')
for key, label in [('roles', 'Roles & Workloads'), ('services', 'Services'), ('network', 'Network Connections'), ('tasks', 'Scheduled Tasks / Timers'), ('software', 'Software'), ('storage', 'Storage & Shares'), ('certificates', 'Certificates')]:
self.tabs.addTab(SearchTable(self.data['inventory'][key]), label)
self.tabs.addTab(SearchTable(self.assessment['findings']), 'Dependencies')
self.tabs.addTab(SearchTable(self.assessment['findings']), 'Risk Assessment')
def target_changed(self, index):
self.hostname.setText(LINUX_DEMO_HOST if index else DEMO_HOST)
def import_inventory(self):
if self.job is not None:
return
path, _ = QFileDialog.getOpenFileName(self, 'Import inventory JSON', '', 'JSON (*.json)')
if path:
self.start_job('', path)
def cancel_collection(self):
if self.job is not None:
self.job.requestInterruption()
self.cancel_button.setEnabled(False)
self.status.setText('Cancelling collection…')
def start_local_collection(self):
if self.job is not None:
return
if platform.system() != 'Linux':
self.status.setText('Local Linux collection requires Linux. Windows collection is not implemented yet; use demo or JSON import.')
return
self.start_job('', local_linux=True)
def start_job(self, hostname, inventory_path=None, local_linux=False):
self.analyze_button.setEnabled(False)
self.import_button.setEnabled(False)
self.local_button.setEnabled(False)
self.cancel_button.setEnabled(True)
self.status.setText('Starting local read-only Linux inventory…' if local_linux else 'Loading offline inventory…')
self.job = CollectionJob(hostname, self, inventory_path=inventory_path, local_linux=local_linux)
self.job.stage.connect(self.status.setText)
self.job.result.connect(self.collection_done)
self.job.failed.connect(lambda message: self.status.setText('Collection failed: ' + message))
self.job.finished.connect(self.job_finished)
self.job.start()
def start_collection(self):
if self.job is not None:
return
if self.environment.currentIndex() == 2:
self.start_local_collection()
return
try:
hostname = validate_hostname(self.hostname.text())
if self.environment.currentIndex() != 0:
raise ValueError('Windows execution host collection is not implemented in Phase 1. Select Offline synthetic demo in Settings.')
except ValueError as exc:
self.status.setText(str(exc))
return
self.start_job(hostname)
def collection_done(self, data):
self.data = data
self.assessment = assess(data)
self.history.append(self.history_row())
self.refresh()
local = data['server'].get('collection_source') == 'Local Linux read-only collection'
self.banner.setText('SYNTHETIC DEMO • Offline fixtures only • No infrastructure is contacted' if data['synthetic'] else 'LOCAL LINUX INVENTORY • Read-only snapshot • Review partial and missing checks' if local else 'IMPORTED INVENTORY • Source assertions not independently verified • No remote connection made')
self.status.setText('Complete — synthetic fixture loaded; no real server was assessed.' if data['synthetic'] else 'Complete — local Linux inventory collected. Review partial/failed checks in Overview, then save a report from Reports.' if local else 'Complete — imported inventory analyzed. Collection timestamp and check statuses come from the file.')
def job_finished(self):
self.job.deleteLater()
self.job = None
self.analyze_button.setEnabled(True)
self.import_button.setEnabled(True)
self.local_button.setEnabled(platform.system() == 'Linux')
self.cancel_button.setEnabled(False)
def save_report(self, kind):
path, _ = QFileDialog.getSaveFileName(self, 'Save assessment', f'server-buddy-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('Collection is running. Use Cancel collection, then close when the worker finishes.')
event.ignore()
else:
event.accept()