You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vguz_v2/database/database_manager_widget.py

776 lines
27 KiB

#!/usr/bin/env python3
"""
Database Manager Widget - vzug-e-hinge
=======================================
Comprehensive database management interface for inspecting and managing sessions.
Features:
- Session browser with sort/filter/search
- Session details with editable notes
- Rename, delete, and export sessions
- Delete individual runs
- Session statistics
Author: Kynsight
Version: 1.0.0
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
QTableWidget, QTableWidgetItem, QHeaderView,
QLabel, QPushButton, QLineEdit, QTextEdit,
QGroupBox, QMessageBox, QInputDialog, QFileDialog,
QCheckBox, QListWidget, QListWidgetItem
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont
from datetime import datetime
import sqlite3
class DatabaseManagerWidget(QWidget):
"""
Database Manager Widget.
Provides comprehensive session management:
- Browse all sessions
- View session details and runs
- Rename, delete, export sessions
- Add notes to sessions
- Delete individual runs
"""
def __init__(self, db_manager):
"""
Initialize Database Manager Widget.
Args:
db_manager: DatabaseManager instance
"""
super().__init__()
self.db_manager = db_manager
self.selected_session_id = None
self._ensure_notes_column()
self._init_ui()
self._load_sessions()
def _ensure_notes_column(self):
"""Ensure notes column exists in sessions table (migration)."""
try:
conn = self.db_manager.get_connection()
# Check if notes column exists
cursor = conn.execute("PRAGMA table_info(sessions)")
columns = [row[1] for row in cursor.fetchall()]
if 'notes' not in columns:
# Add notes column
conn.execute("ALTER TABLE sessions ADD COLUMN notes TEXT DEFAULT ''")
conn.commit()
print("[INFO] Added 'notes' column to sessions table")
except Exception as e:
print(f"[WARN] Failed to add notes column: {e}")
def _init_ui(self):
"""Initialize user interface."""
layout = QVBoxLayout()
self.setLayout(layout)
# Main splitter (left: session browser, right: details)
splitter = QSplitter(Qt.Orientation.Horizontal)
layout.addWidget(splitter)
# Left panel: Session Browser
left_panel = self._create_session_browser()
splitter.addWidget(left_panel)
# Right panel: Session Details
right_panel = self._create_session_details()
splitter.addWidget(right_panel)
# Set splitter ratio (40% left, 60% right)
splitter.setStretchFactor(0, 4)
splitter.setStretchFactor(1, 6)
def _create_session_browser(self):
"""Create session browser panel (left side)."""
panel = QWidget()
layout = QVBoxLayout()
panel.setLayout(layout)
# Search and filter
search_layout = QHBoxLayout()
search_layout.addWidget(QLabel("Search:"))
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Filter sessions by name...")
self.search_input.textChanged.connect(self._filter_sessions)
search_layout.addWidget(self.search_input)
layout.addLayout(search_layout)
# Session table
self.session_table = QTableWidget()
self.session_table.setColumnCount(5)
self.session_table.setHorizontalHeaderLabels([
"Select", "Session Name", "Date", "Runs", "Size (MB)"
])
# Configure table
header = self.session_table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
self.session_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.session_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self.session_table.itemSelectionChanged.connect(self._on_session_selected)
self.session_table.setSortingEnabled(True)
layout.addWidget(self.session_table)
# Batch action buttons
btn_layout = QHBoxLayout()
self.btn_select_all = QPushButton("Select All")
self.btn_select_all.clicked.connect(self._select_all_sessions)
btn_layout.addWidget(self.btn_select_all)
self.btn_clear_selection = QPushButton("Clear")
self.btn_clear_selection.clicked.connect(self._clear_selection)
btn_layout.addWidget(self.btn_clear_selection)
btn_layout.addStretch()
self.btn_delete_selected = QPushButton("Delete Selected")
self.btn_delete_selected.clicked.connect(self._delete_selected_sessions)
btn_layout.addWidget(self.btn_delete_selected)
self.btn_refresh = QPushButton("Refresh")
self.btn_refresh.clicked.connect(self._load_sessions)
btn_layout.addWidget(self.btn_refresh)
layout.addLayout(btn_layout)
return panel
def _create_session_details(self):
"""Create session details panel (right side)."""
panel = QWidget()
layout = QVBoxLayout()
panel.setLayout(layout)
# Session info group
info_group = QGroupBox("Session Information")
info_layout = QVBoxLayout()
info_group.setLayout(info_layout)
# Session ID
self.label_session_id = QLabel("No session selected")
self.label_session_id.setFont(QFont("Arial", 10, QFont.Weight.Bold))
info_layout.addWidget(self.label_session_id)
# Created date
self.label_created = QLabel("")
info_layout.addWidget(self.label_created)
# Notes
notes_layout = QHBoxLayout()
notes_layout.addWidget(QLabel("Notes:"))
self.text_notes = QTextEdit()
self.text_notes.setMaximumHeight(80)
self.text_notes.setPlaceholderText("Add notes or description for this session...")
self.text_notes.textChanged.connect(self._on_notes_changed)
notes_layout.addWidget(self.text_notes)
info_layout.addLayout(notes_layout)
# Session actions
action_layout = QHBoxLayout()
self.btn_rename = QPushButton("Rename Session")
self.btn_rename.clicked.connect(self._rename_session)
action_layout.addWidget(self.btn_rename)
self.btn_delete = QPushButton("Delete Session")
self.btn_delete.clicked.connect(self._delete_session)
action_layout.addWidget(self.btn_delete)
self.btn_export = QPushButton("Export CSV")
self.btn_export.clicked.connect(self._export_session)
action_layout.addWidget(self.btn_export)
action_layout.addStretch()
info_layout.addLayout(action_layout)
layout.addWidget(info_group)
# Runs group
runs_group = QGroupBox("Runs in Session")
runs_layout = QVBoxLayout()
runs_group.setLayout(runs_layout)
self.runs_list = QListWidget()
runs_layout.addWidget(self.runs_list)
# Run actions
run_action_layout = QHBoxLayout()
self.btn_delete_runs = QPushButton("Delete Selected Runs")
self.btn_delete_runs.clicked.connect(self._delete_selected_runs)
run_action_layout.addWidget(self.btn_delete_runs)
run_action_layout.addStretch()
runs_layout.addLayout(run_action_layout)
layout.addWidget(runs_group)
# Statistics group
stats_group = QGroupBox("Session Statistics")
stats_layout = QVBoxLayout()
stats_group.setLayout(stats_layout)
self.label_stats = QLabel("Select a session to view statistics")
self.label_stats.setFont(QFont("Monospace", 9))
stats_layout.addWidget(self.label_stats)
layout.addWidget(stats_group)
# Disable all controls initially
self._set_details_enabled(False)
return panel
def _set_details_enabled(self, enabled: bool):
"""Enable or disable detail panel controls."""
self.text_notes.setEnabled(enabled)
self.btn_rename.setEnabled(enabled)
self.btn_delete.setEnabled(enabled)
self.btn_export.setEnabled(enabled)
self.btn_delete_runs.setEnabled(enabled)
def _load_sessions(self):
"""Load all sessions from database."""
try:
conn = self.db_manager.get_connection()
cursor = conn.execute("""
SELECT
session_id,
session_name,
created_at,
total_runs,
notes,
(SELECT COUNT(*) FROM telemetry_decoded WHERE telemetry_decoded.session_id = sessions.session_id) as data_points
FROM sessions
ORDER BY created_at DESC
""")
sessions = cursor.fetchall()
# Store all sessions for filtering
self.all_sessions = sessions
# Display sessions
self._display_sessions(sessions)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to load sessions:\n{str(e)}")
def _display_sessions(self, sessions):
"""Display sessions in table."""
self.session_table.setSortingEnabled(False)
self.session_table.setRowCount(0)
for row_idx, session in enumerate(sessions):
session_id, session_name, created_at, total_runs, notes, data_points = session
self.session_table.insertRow(row_idx)
# Checkbox
checkbox = QCheckBox()
checkbox_widget = QWidget()
checkbox_layout = QHBoxLayout(checkbox_widget)
checkbox_layout.addWidget(checkbox)
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox_layout.setContentsMargins(0, 0, 0, 0)
self.session_table.setCellWidget(row_idx, 0, checkbox_widget)
# Session name
name_item = QTableWidgetItem(session_name)
name_item.setData(Qt.ItemDataRole.UserRole, session_id)
self.session_table.setItem(row_idx, 1, name_item)
# Date
date_item = QTableWidgetItem(created_at)
self.session_table.setItem(row_idx, 2, date_item)
# Runs
runs_item = QTableWidgetItem(str(total_runs))
runs_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.session_table.setItem(row_idx, 3, runs_item)
# Size (estimate: ~100 bytes per data point)
size_mb = (data_points * 100) / (1024 * 1024)
size_item = QTableWidgetItem(f"{size_mb:.2f}")
size_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.session_table.setItem(row_idx, 4, size_item)
self.session_table.setSortingEnabled(True)
def _filter_sessions(self):
"""Filter sessions based on search text."""
search_text = self.search_input.text().lower()
if not search_text:
# Show all sessions
self._display_sessions(self.all_sessions)
else:
# Filter sessions
filtered = [s for s in self.all_sessions if search_text in s[1].lower()]
self._display_sessions(filtered)
def _on_session_selected(self):
"""Handle session selection."""
selected_items = self.session_table.selectedItems()
if not selected_items:
self._set_details_enabled(False)
self.selected_session_id = None
self.label_session_id.setText("No session selected")
self.label_created.setText("")
self.text_notes.clear()
self.runs_list.clear()
self.label_stats.setText("Select a session to view statistics")
return
# Get session ID from selected row
row = selected_items[0].row()
session_id = self.session_table.item(row, 1).data(Qt.ItemDataRole.UserRole)
self.selected_session_id = session_id
self._load_session_details(session_id)
self._set_details_enabled(True)
def _load_session_details(self, session_id: str):
"""Load details for selected session."""
try:
conn = self.db_manager.get_connection()
# Get session info
cursor = conn.execute("""
SELECT session_id, session_name, created_at, total_runs, notes
FROM sessions
WHERE session_id = ?
""", (session_id,))
session = cursor.fetchone()
if not session:
return
session_id, session_name, created_at, total_runs, notes = session
# Update UI
self.label_session_id.setText(f"Session: {session_name}")
self.label_created.setText(f"Created: {created_at}")
# Block signals while setting notes to avoid triggering save
self.text_notes.blockSignals(True)
self.text_notes.setPlainText(notes or "")
self.text_notes.blockSignals(False)
# Load runs
self._load_runs(session_id)
# Load statistics
self._load_statistics(session_id)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to load session details:\n{str(e)}")
def _load_runs(self, session_id: str):
"""Load runs for selected session."""
try:
conn = self.db_manager.get_connection()
cursor = conn.execute("""
SELECT DISTINCT
t.run_no,
COALESCE(u.command_name, i.command_name, 'Unknown') as command_name,
COUNT(*) as sample_count
FROM telemetry_decoded t
LEFT JOIN uart_commands u ON t.run_command_id = u.command_id
LEFT JOIN i2c_commands i ON t.run_command_id = i.command_id
WHERE t.session_id = ?
GROUP BY t.run_no
ORDER BY t.run_no
""", (session_id,))
runs = cursor.fetchall()
self.runs_list.clear()
for run_no, command_name, sample_count in runs:
item = QListWidgetItem(f"Run {run_no}: {command_name} ({sample_count:,} samples)")
item.setData(Qt.ItemDataRole.UserRole, run_no)
item.setCheckState(Qt.CheckState.Unchecked)
self.runs_list.addItem(item)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to load runs:\n{str(e)}")
def _load_statistics(self, session_id: str):
"""Load statistics for selected session."""
try:
conn = self.db_manager.get_connection()
# Get statistics
cursor = conn.execute("""
SELECT
COUNT(DISTINCT run_no) as total_runs,
COUNT(*) as total_samples,
MIN(t_ns) as start_time,
MAX(t_ns) as end_time
FROM telemetry_decoded
WHERE session_id = ?
""", (session_id,))
stats = cursor.fetchone()
if stats and stats[0] > 0:
total_runs, total_samples, start_time, end_time = stats
# Calculate duration
duration_ns = end_time - start_time
duration_s = duration_ns / 1_000_000_000
# Format statistics
stats_text = f"• Total Runs: {total_runs}\n"
stats_text += f"• Total Samples: {total_samples:,}\n"
stats_text += f"• Avg Samples/Run: {total_samples // total_runs:,}\n"
stats_text += f"• Duration: {duration_s:.1f} seconds\n"
self.label_stats.setText(stats_text)
else:
self.label_stats.setText("No data available for this session")
except Exception as e:
self.label_stats.setText(f"Error loading statistics: {str(e)}")
def _on_notes_changed(self):
"""Handle notes text change - auto-save."""
if not self.selected_session_id:
return
notes = self.text_notes.toPlainText()
try:
conn = self.db_manager.get_connection()
conn.execute("""
UPDATE sessions
SET notes = ?
WHERE session_id = ?
""", (notes, self.selected_session_id))
conn.commit()
except Exception as e:
print(f"Failed to save notes: {e}")
def _rename_session(self):
"""Rename selected session."""
if not self.selected_session_id:
return
# Get current name
current_name = self.label_session_id.text().replace("Session: ", "")
# Ask for new name
new_name, ok = QInputDialog.getText(
self,
"Rename Session",
"Enter new session name:",
text=current_name
)
if not ok or not new_name.strip():
return
try:
conn = self.db_manager.get_connection()
# Update session name in sessions table
conn.execute("""
UPDATE sessions
SET session_name = ?
WHERE session_id = ?
""", (new_name.strip(), self.selected_session_id))
# Update session name in telemetry_raw table
conn.execute("""
UPDATE telemetry_raw
SET session_name = ?
WHERE session_id = ?
""", (new_name.strip(), self.selected_session_id))
# Update session name in telemetry_decoded table
conn.execute("""
UPDATE telemetry_decoded
SET session_name = ?
WHERE session_id = ?
""", (new_name.strip(), self.selected_session_id))
conn.commit()
# Reload sessions
self._load_sessions()
# Reload details
self._load_session_details(self.selected_session_id)
QMessageBox.information(self, "Success", "Session renamed successfully!")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to rename session:\n{str(e)}")
def _delete_session(self):
"""Delete selected session."""
if not self.selected_session_id:
return
# Confirm
reply = QMessageBox.question(
self,
"Confirm Delete",
f"Delete session '{self.selected_session_id}'?\n\n"
"This will delete all runs and data for this session.\n"
"This action cannot be undone!",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
try:
conn = self.db_manager.get_connection()
conn.execute("DELETE FROM telemetry_decoded WHERE session_id = ?", (self.selected_session_id,))
conn.execute("DELETE FROM telemetry_raw WHERE session_id = ?", (self.selected_session_id,))
conn.execute("DELETE FROM sessions WHERE session_id = ?", (self.selected_session_id,))
conn.commit()
self.selected_session_id = None
self._load_sessions()
self._set_details_enabled(False)
QMessageBox.information(self, "Success", "Session deleted successfully!")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to delete session:\n{str(e)}")
def _export_session(self):
"""Export selected session to CSV."""
if not self.selected_session_id:
return
# Get save location
filename = f"{self.selected_session_id}_export.csv"
filepath, _ = QFileDialog.getSaveFileName(
self,
"Export Session",
filename,
"CSV Files (*.csv);;All Files (*)"
)
if not filepath:
return
try:
import csv
conn = self.db_manager.get_connection()
# Get all data for session
cursor = conn.execute("""
SELECT
session_id, session_name, run_no, t_ns, time_ms,
motor_current, encoder_value, relative_encoder_value,
v24_pec_diff, pwm,
i2c_raw14, i2c_zero_raw14, i2c_delta_raw14,
i2c_angle_deg, i2c_zero_angle_deg,
angular_velocity, angular_acceleration
FROM telemetry_decoded
WHERE session_id = ?
ORDER BY run_no, t_ns
""", (self.selected_session_id,))
rows = cursor.fetchall()
# Write to CSV
with open(filepath, 'w', newline='') as f:
writer = csv.writer(f)
# Header
writer.writerow([
'session_id', 'session_name', 'run_no', 't_ns', 'time_ms',
'motor_current', 'encoder_value', 'relative_encoder_value',
'v24_pec_diff', 'pwm',
'i2c_raw14', 'i2c_zero_raw14', 'i2c_delta_raw14',
'i2c_angle_deg', 'i2c_zero_angle_deg',
'angular_velocity', 'angular_acceleration'
])
# Data
writer.writerows(rows)
QMessageBox.information(
self,
"Export Complete",
f"Session exported successfully!\n\n"
f"File: {filepath}\n"
f"Rows: {len(rows):,}"
)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to export session:\n{str(e)}")
def _delete_selected_runs(self):
"""Delete selected runs from session."""
if not self.selected_session_id:
return
# Get checked runs
selected_runs = []
for i in range(self.runs_list.count()):
item = self.runs_list.item(i)
if item.checkState() == Qt.CheckState.Checked:
run_no = item.data(Qt.ItemDataRole.UserRole)
selected_runs.append(run_no)
if not selected_runs:
QMessageBox.warning(self, "No Selection", "Please select runs to delete.")
return
# Confirm
reply = QMessageBox.question(
self,
"Confirm Delete",
f"Delete {len(selected_runs)} run(s) from session?\n\n"
"This will permanently delete all data for these runs.\n"
"This action cannot be undone!",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
try:
conn = self.db_manager.get_connection()
for run_no in selected_runs:
conn.execute("""
DELETE FROM telemetry_decoded
WHERE session_id = ? AND run_no = ?
""", (self.selected_session_id, run_no))
conn.execute("""
DELETE FROM telemetry_raw
WHERE session_id = ? AND run_no = ?
""", (self.selected_session_id, run_no))
# Update total_runs in sessions table
cursor = conn.execute("""
SELECT COUNT(DISTINCT run_no)
FROM telemetry_decoded
WHERE session_id = ?
""", (self.selected_session_id,))
new_run_count = cursor.fetchone()[0]
conn.execute("""
UPDATE sessions
SET total_runs = ?
WHERE session_id = ?
""", (new_run_count, self.selected_session_id))
conn.commit()
# Reload details
self._load_session_details(self.selected_session_id)
self._load_sessions()
QMessageBox.information(self, "Success", f"Deleted {len(selected_runs)} run(s) successfully!")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to delete runs:\n{str(e)}")
def _select_all_sessions(self):
"""Select all checkboxes in session table."""
for row in range(self.session_table.rowCount()):
checkbox_widget = self.session_table.cellWidget(row, 0)
checkbox = checkbox_widget.findChild(QCheckBox)
if checkbox:
checkbox.setChecked(True)
def _clear_selection(self):
"""Clear all checkboxes in session table."""
for row in range(self.session_table.rowCount()):
checkbox_widget = self.session_table.cellWidget(row, 0)
checkbox = checkbox_widget.findChild(QCheckBox)
if checkbox:
checkbox.setChecked(False)
def _delete_selected_sessions(self):
"""Delete all checked sessions."""
# Get checked sessions
selected_sessions = []
for row in range(self.session_table.rowCount()):
checkbox_widget = self.session_table.cellWidget(row, 0)
checkbox = checkbox_widget.findChild(QCheckBox)
if checkbox and checkbox.isChecked():
session_id = self.session_table.item(row, 1).data(Qt.ItemDataRole.UserRole)
session_name = self.session_table.item(row, 1).text()
selected_sessions.append((session_id, session_name))
if not selected_sessions:
QMessageBox.warning(self, "No Selection", "Please select sessions to delete.")
return
# Confirm
session_list = "\n".join([f"{name}" for _, name in selected_sessions])
reply = QMessageBox.question(
self,
"Confirm Delete",
f"Delete {len(selected_sessions)} session(s)?\n\n{session_list}\n\n"
"This will permanently delete all data for these sessions.\n"
"This action cannot be undone!",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
try:
conn = self.db_manager.get_connection()
for session_id, _ in selected_sessions:
conn.execute("DELETE FROM telemetry_decoded WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM telemetry_raw WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
conn.commit()
# Vacuum database
self.db_manager.vacuum()
self.selected_session_id = None
self._load_sessions()
self._set_details_enabled(False)
QMessageBox.information(
self,
"Success",
f"Deleted {len(selected_sessions)} session(s) successfully!\n\n"
"Database vacuumed."
)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to delete sessions:\n{str(e)}")