Initial commit - Phase 3/4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
michael
2026-01-06 13:45:29 -08:00
parent 1da6730735
commit 4f35df1781
323 changed files with 98287 additions and 1195 deletions

710
HEADER_WIDGET_SYSTEM.md Normal file
View File

@@ -0,0 +1,710 @@
# 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)