🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
18 KiB
18 KiB
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
- UPS Hardware Missing: Display warning when UPS HAT is not detected
- Plugin Notifications: Allow plugins to register persistent status indicators
- System Warnings: Battery low, update available, connection issues, etc.
- Device Status: Multi-PM3 device connection status
Architecture
Widget Types
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)
# 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
# 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
# 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
// 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
/* 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
// 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
# 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
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
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
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
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
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
HeaderWidgetdataclass to plugin_manager.py - Add
WidgetSeverityenum - 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/widgetsendpoint - Add
POST /api/system/widgets/{id}/dismissendpoint - Add
WidgetResponsePydantic 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.tsxcomponent - 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
- Widget Limit: Cap at 10 active widgets maximum
- Refresh Rate: Poll every 30 seconds (not real-time)
- Dismissed Storage: Store in localStorage (client-side) or database (server-side)
- 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-labelattributes - Action links are keyboard navigable
- Color is not the only indicator (icons + text)
- 44x44px minimum touch targets for mobile
Security Considerations
- Widget Content: Sanitize all widget messages (XSS prevention)
- Plugin Widgets: Validate plugin-registered widgets
- Dismissal: Store dismissed widget IDs, not widget content
- 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)