319 lines
17 KiB
Python
319 lines
17 KiB
Python
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()
|