🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
711 lines
18 KiB
Markdown
711 lines
18 KiB
Markdown
# 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)
|