Files
pi-pm3/HEADER_WIDGET_SYSTEM.md
michael 4f35df1781 Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:46:22 -08:00

711 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<string, any>; // 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<HeaderWidget[]>([]);
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 (
<div className="header-widgets">
{widgets.map((widget) => (
<div
key={widget.id}
className={`widget widget-${widget.severity}`}
role="alert"
>
{widget.icon && <span className="widget-icon">{widget.icon}</span>}
<span className="widget-message">{widget.message}</span>
{widget.action_url && widget.action_label && (
<Link to={widget.action_url} className="widget-action">
{widget.action_label}
</Link>
)}
{widget.dismissible && (
<button
onClick={() => dismissWidget(widget.id)}
className="widget-dismiss"
aria-label="Dismiss"
title="Dismiss"
>
×
</button>
)}
</div>
))}
</div>
);
}
```
#### 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 (
<>
<header className="header">
<div className="header-content">
{/* Existing header content */}
</div>
</header>
{/* NEW: Header widgets display area */}
<HeaderWidgets />
{/* Desktop Navigation */}
<nav className="nav">
{/* ... existing nav ... */}
</nav>
{/* Main content */}
<main className="main">
<Outlet />
</main>
{/* ... 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)