# Header Widget System - Design Specification **Date**: 2025-11-26 **Status**: Design Phase **Priority**: MEDIUM --- ## Overview A plugin-aware header widget system that allows plugins and core managers to display status indicators, warnings, and notifications in the web interface header. ### Primary Use Cases 1. **UPS Hardware Missing**: Display warning when UPS HAT is not detected 2. **Plugin Notifications**: Allow plugins to register persistent status indicators 3. **System Warnings**: Battery low, update available, connection issues, etc. 4. **Device Status**: Multi-PM3 device connection status --- ## Architecture ### Widget Types ```typescript type WidgetSeverity = "info" | "warning" | "error" | "success"; interface HeaderWidget { id: string; // Unique identifier (e.g., "ups.missing", "plugin.hello_world.status") source: string; // Source identifier (e.g., "ups_manager", "plugin:hello_world") severity: WidgetSeverity; // Visual severity level icon?: string; // Optional emoji/icon message: string; // Display message dismissible: boolean; // Can user dismiss? action?: { // Optional action button label: string; url: string; }; metadata?: Record; // Additional data created_at: string; // ISO timestamp expires_at?: string; // Optional expiry (ISO timestamp) } ``` ### Backend Components #### 1. Widget Registry (Plugin Manager Extension) ```python # app/backend/managers/plugin_manager.py class WidgetSeverity(str, Enum): """Widget severity levels.""" INFO = "info" WARNING = "warning" ERROR = "error" SUCCESS = "success" @dataclass class HeaderWidget: """Header widget data.""" id: str source: str severity: WidgetSeverity message: str dismissible: bool = True icon: Optional[str] = None action_label: Optional[str] = None action_url: Optional[str] = None metadata: Optional[Dict[str, Any]] = None created_at: Optional[str] = None expires_at: Optional[str] = None class PluginManager: def __init__(self): # Existing code... self._header_widgets: Dict[str, HeaderWidget] = {} self._dismissed_widgets: Set[str] = set() # User-dismissed widgets def register_widget(self, widget: HeaderWidget) -> bool: """Register a header widget. Args: widget: HeaderWidget to register Returns: True if registered successfully """ if widget.id in self._dismissed_widgets: return False # User dismissed this widget self._header_widgets[widget.id] = widget return True def unregister_widget(self, widget_id: str): """Remove a widget from the registry.""" self._header_widgets.pop(widget_id, None) def get_active_widgets(self) -> List[HeaderWidget]: """Get all active, non-expired widgets.""" now = datetime.now() active = [] for widget in self._header_widgets.values(): # Check if expired if widget.expires_at: expiry = datetime.fromisoformat(widget.expires_at) if now > expiry: continue active.append(widget) return active def dismiss_widget(self, widget_id: str): """Mark widget as dismissed by user.""" self._dismissed_widgets.add(widget_id) self.unregister_widget(widget_id) ``` #### 2. UPS Manager Integration ```python # app/backend/managers/ups_manager.py class UPSManager: def __init__(self): # Existing code... self._plugin_manager = None # Injected on startup def set_plugin_manager(self, plugin_manager): """Inject plugin manager for widget registration.""" self._plugin_manager = plugin_manager async def initialize(self) -> bool: """Initialize I2C connection to UPS.""" success = await self._original_initialize() # Register widget if hardware not available if not success and self._plugin_manager: widget = HeaderWidget( id="ups.hardware_missing", source="ups_manager", severity=WidgetSeverity.WARNING, icon="⚠️", message="UPS hardware not detected. Battery monitoring unavailable.", dismissible=True, action_label="Learn More", action_url="/settings#ups", created_at=datetime.now().isoformat() ) self._plugin_manager.register_widget(widget) return success ``` #### 3. API Endpoints ```python # app/backend/api/system.py (add to existing router) class WidgetResponse(BaseModel): """Header widget response model.""" id: str source: str severity: str message: str dismissible: bool icon: Optional[str] = None action_label: Optional[str] = None action_url: Optional[str] = None created_at: str expires_at: Optional[str] = None @router.get("/widgets", response_model=List[WidgetResponse]) async def get_header_widgets(): """Get all active header widgets. Returns: List of active widgets """ try: plugin_manager = get_plugin_manager() widgets = plugin_manager.get_active_widgets() return [ WidgetResponse( id=w.id, source=w.source, severity=w.severity.value, message=w.message, dismissible=w.dismissible, icon=w.icon, action_label=w.action_label, action_url=w.action_url, created_at=w.created_at or datetime.now().isoformat(), expires_at=w.expires_at ) for w in widgets ] except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/widgets/{widget_id}/dismiss") async def dismiss_widget(widget_id: str): """Dismiss a header widget. Args: widget_id: ID of widget to dismiss Returns: Success status """ try: plugin_manager = get_plugin_manager() plugin_manager.dismiss_widget(widget_id) return {"success": True, "widget_id": widget_id} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) ``` ### Frontend Components #### 1. Header Widget Component ```typescript // app/frontend/app/components/HeaderWidgets.tsx import { useState, useEffect } from "react"; import { Link } from "@remix-run/react"; interface HeaderWidget { id: string; source: string; severity: "info" | "warning" | "error" | "success"; message: string; dismissible: boolean; icon?: string; action_label?: string; action_url?: string; } export function HeaderWidgets() { const [widgets, setWidgets] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { loadWidgets(); // Refresh every 30 seconds const interval = setInterval(loadWidgets, 30000); return () => clearInterval(interval); }, []); const loadWidgets = async () => { try { const response = await fetch("/api/system/widgets"); if (response.ok) { const data = await response.json(); setWidgets(data); } } catch (error) { console.error("Failed to load widgets:", error); } finally { setLoading(false); } }; const dismissWidget = async (widgetId: string) => { try { const response = await fetch(`/api/system/widgets/${widgetId}/dismiss`, { method: "POST", }); if (response.ok) { setWidgets(widgets.filter(w => w.id !== widgetId)); } } catch (error) { console.error("Failed to dismiss widget:", error); } }; if (loading || widgets.length === 0) { return null; } return (
{widgets.map((widget) => (
{widget.icon && {widget.icon}} {widget.message} {widget.action_url && widget.action_label && ( {widget.action_label} )} {widget.dismissible && ( )}
))}
); } ``` #### 2. CSS Styles ```css /* app/frontend/app/styles.css - Add to existing file */ /* Header Widgets */ .header-widgets { display: flex; flex-direction: column; gap: 0.5rem; padding: 0.5rem 1rem; background: var(--color-bg-secondary); border-bottom: 1px solid var(--color-border); } .widget { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border-radius: var(--border-radius); font-size: 0.9rem; animation: slideDown 0.3s ease-out; } @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } .widget-info { background: rgba(0, 200, 255, 0.1); border-left: 3px solid var(--color-primary); color: var(--color-primary); } .widget-warning { background: rgba(255, 200, 0, 0.1); border-left: 3px solid #ffc800; color: #ffc800; } .widget-error { background: rgba(255, 0, 100, 0.1); border-left: 3px solid #ff0064; color: #ff0064; } .widget-success { background: rgba(0, 255, 136, 0.1); border-left: 3px solid var(--color-accent); color: var(--color-accent); } .widget-icon { font-size: 1.2rem; flex-shrink: 0; } .widget-message { flex: 1; line-height: 1.4; } .widget-action { padding: 0.4rem 0.8rem; background: rgba(255, 255, 255, 0.1); border-radius: var(--border-radius); text-decoration: none; color: inherit; font-size: 0.85rem; font-weight: 500; transition: background 0.2s; white-space: nowrap; } .widget-action:hover { background: rgba(255, 255, 255, 0.2); } .widget-dismiss { background: none; border: none; color: inherit; font-size: 1.5rem; line-height: 1; cursor: pointer; padding: 0.25rem 0.5rem; opacity: 0.6; transition: opacity 0.2s; flex-shrink: 0; } .widget-dismiss:hover { opacity: 1; } /* Mobile optimizations */ @media (max-width: 768px) { .header-widgets { padding: 0.5rem; } .widget { font-size: 0.85rem; padding: 0.6rem; } .widget-action { padding: 0.3rem 0.6rem; font-size: 0.8rem; } } ``` #### 3. Integration into root.tsx ```tsx // app/frontend/app/root.tsx - Update App component import { HeaderWidgets } from "./components/HeaderWidgets"; export default function App() { // ... existing code ... return ( <>
{/* Existing header content */}
{/* NEW: Header widgets display area */} {/* Desktop Navigation */} {/* Main content */}
{/* ... rest of app ... */} ); } ``` --- ## Plugin Integration ### Example: Hello World Plugin with Widget ```python # app/plugins/hello_world/main.py from datetime import datetime, timedelta from app.backend.managers.plugin_manager import ( PluginBase, HeaderWidget, WidgetSeverity ) class HelloWorldPlugin(PluginBase): async def on_enable(self): """Called when plugin is enabled.""" # Get plugin manager instance from app.backend.managers.plugin_manager import get_plugin_manager plugin_manager = get_plugin_manager() # Register a demo widget widget = HeaderWidget( id="plugin.hello_world.demo", source="plugin:hello_world", severity=WidgetSeverity.INFO, icon="👋", message="Hello World plugin is active!", dismissible=True, action_label="Settings", action_url="/settings#plugins", created_at=datetime.now().isoformat(), expires_at=(datetime.now() + timedelta(hours=1)).isoformat() ) plugin_manager.register_widget(widget) async def on_disable(self): """Called when plugin is disabled.""" from app.backend.managers.plugin_manager import get_plugin_manager plugin_manager = get_plugin_manager() # Clean up widget plugin_manager.unregister_widget("plugin.hello_world.demo") ``` --- ## Common Widget Use Cases ### 1. UPS Hardware Missing ```python HeaderWidget( id="ups.hardware_missing", source="ups_manager", severity=WidgetSeverity.WARNING, icon="⚠️", message="UPS hardware not detected. Battery monitoring unavailable.", dismissible=True, action_label="Learn More", action_url="/settings#ups" ) ``` ### 2. Low Battery Warning ```python HeaderWidget( id="ups.battery_low", source="ups_manager", severity=WidgetSeverity.ERROR, icon="🔋", message="Battery critically low (5%). Connect to power immediately.", dismissible=False, # Critical - don't allow dismissal action_label="Details", action_url="/settings#ups" ) ``` ### 3. Update Available ```python HeaderWidget( id="updates.available", source="update_manager", severity=WidgetSeverity.INFO, icon="🔄", message="Dangerous Pi v1.2.0 is available. You have v1.1.0.", dismissible=True, action_label="Update Now", action_url="/updates" ) ``` ### 4. No PM3 Devices ```python HeaderWidget( id="pm3.no_devices", source="pm3_device_manager", severity=WidgetSeverity.WARNING, icon="📡", message="No Proxmark3 devices detected. Please connect a device.", dismissible=False, action_label="Help", action_url="/help#pm3-connection" ) ``` ### 5. Plugin Update Available ```python HeaderWidget( id="plugin.custom_plugin.update", source="plugin:custom_plugin", severity=WidgetSeverity.INFO, icon="🔌", message="Custom Plugin v2.0 is available.", dismissible=True, action_label="View", action_url="/settings#plugins", expires_at=(datetime.now() + timedelta(days=7)).isoformat() ) ``` --- ## Implementation Checklist ### Phase 1: Backend Infrastructure - [ ] Add `HeaderWidget` dataclass to plugin_manager.py - [ ] Add `WidgetSeverity` enum - [ ] Implement `register_widget()` method - [ ] Implement `unregister_widget()` method - [ ] Implement `get_active_widgets()` method - [ ] Implement `dismiss_widget()` method - [ ] Add dismissed widgets persistence (optional) ### Phase 2: API Endpoints - [ ] Add `GET /api/system/widgets` endpoint - [ ] Add `POST /api/system/widgets/{id}/dismiss` endpoint - [ ] Add `WidgetResponse` Pydantic model - [ ] Test endpoints with curl ### Phase 3: UPS Manager Integration - [ ] Inject plugin_manager into UPSManager - [ ] Register widget on initialization failure - [ ] Register widget on critical battery - [ ] Unregister widget when hardware becomes available ### Phase 4: Frontend Component - [ ] Create `HeaderWidgets.tsx` component - [ ] Add CSS styles for widget display - [ ] Implement auto-refresh (30s interval) - [ ] Implement dismiss functionality - [ ] Add to root.tsx layout ### Phase 5: Additional Integrations - [ ] Update Manager: Register widget for updates - [ ] PM3 Device Manager: Register widget when no devices - [ ] Plugin Manager: Allow plugins to register widgets - [ ] BLE Manager: Register widget when BLE unavailable (optional) ### Phase 6: Testing & Polish - [ ] Test with UPS hardware disconnected - [ ] Test dismissal persistence across sessions (optional) - [ ] Test multiple widgets display - [ ] Test mobile responsiveness - [ ] Test widget expiry - [ ] Add unit tests --- ## Future Enhancements ### Priority 1: Persistence - Store dismissed widgets in database - Restore dismissed state across sessions - Add "Restore dismissed widgets" button in settings ### Priority 2: Advanced Features - Widget priority/ordering - Collapsible widget groups - Widget categories (system, plugins, updates, etc.) - Toast notifications for transient widgets - Click-to-expand for long messages ### Priority 3: Plugin Capabilities - Allow plugins to update widgets dynamically - Widget templates for common patterns - Widget analytics (how often dismissed, clicked) --- ## Performance Considerations 1. **Widget Limit**: Cap at 10 active widgets maximum 2. **Refresh Rate**: Poll every 30 seconds (not real-time) 3. **Dismissed Storage**: Store in localStorage (client-side) or database (server-side) 4. **SSE Integration**: Consider using existing SSE for real-time widget updates (optional) --- ## Accessibility - All widgets have `role="alert"` for screen readers - Dismiss buttons have `aria-label` attributes - Action links are keyboard navigable - Color is not the only indicator (icons + text) - 44x44px minimum touch targets for mobile --- ## Security Considerations 1. **Widget Content**: Sanitize all widget messages (XSS prevention) 2. **Plugin Widgets**: Validate plugin-registered widgets 3. **Dismissal**: Store dismissed widget IDs, not widget content 4. **Rate Limiting**: Limit widget registration frequency from plugins --- **Status**: Ready for implementation **Estimated Effort**: 4-6 hours for Phase 1-4 (core functionality) **Dependencies**: None (extends existing plugin system)