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

18 KiB
Raw Blame History

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

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 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)