Add Server Buddy desktop app with local Linux assessment and reporting
This commit is contained in:
318
server_buddy/ui/window.py
Normal file
318
server_buddy/ui/window.py
Normal 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()
|
||||
Reference in New Issue
Block a user