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

View File

@@ -6,16 +6,19 @@ interface ConnectDialogProps {
ssid: string;
encrypted: boolean;
signal_strength: number;
_scannedPassword?: string; // Pre-filled from QR scan
} | null;
onClose: () => void;
isSubmitting: boolean;
}
export default function ConnectDialog({ network, onClose, isSubmitting }: ConnectDialogProps) {
const [password, setPassword] = useState("");
// Initialize password from QR scan if provided
const [password, setPassword] = useState(network?._scannedPassword || "");
const [showPassword, setShowPassword] = useState(false);
const [hidden, setHidden] = useState(false);
const [customSSID, setCustomSSID] = useState("");
// Start in hidden mode if no network is provided (user wants to enter SSID manually)
const [hidden, setHidden] = useState(!network);
const [customSSID, setCustomSSID] = useState(network?.ssid || "");
if (!network && !hidden) return null;

View File

@@ -0,0 +1,634 @@
import { useState, useEffect } from "react";
import { useWebSocketEvent } from "../hooks/useWebSocket";
interface FirmwareInfo {
bootrom_version: string;
os_version: string;
client_version: string;
compatible: boolean;
needs_upgrade: boolean;
needs_downgrade: boolean;
}
interface Device {
device_id: string;
device_path: string;
serial_number: string | null;
friendly_name: string | null;
usb_vid: string;
usb_pid: string;
status: "connected" | "disconnected" | "in_use" | "error" | "version_mismatch" | "flashing" | "disabled";
firmware_info: FirmwareInfo | null;
session_id: string | null;
last_seen: string;
}
interface FlashProgress {
device_id: string;
status: "starting" | "bootrom" | "fullimage" | "verifying" | "complete" | "error";
progress: number;
message: string;
}
interface DeviceSelectorProps {
selectedDeviceId: string | null;
onDeviceSelect: (deviceId: string) => void;
showIdentifyButton?: boolean;
autoSelectSingle?: boolean;
}
export default function DeviceSelector({
selectedDeviceId,
onDeviceSelect,
showIdentifyButton = true,
autoSelectSingle = true,
}: DeviceSelectorProps) {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [identifying, setIdentifying] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Flash-related state
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null);
const [showFlashConfirm, setShowFlashConfirm] = useState<string | null>(null);
const [flashing, setFlashing] = useState<string | null>(null);
const [flashError, setFlashError] = useState<string | null>(null);
// Subscribe to flash progress events
useWebSocketEvent("pm3_flash_progress", (data) => {
const progress = data as FlashProgress;
setFlashProgress(progress);
// Clear flashing state on complete or error
if (progress.status === "complete" || progress.status === "error") {
setFlashing(null);
if (progress.status === "error") {
setFlashError(progress.message);
}
// Clear progress after a delay
setTimeout(() => setFlashProgress(null), 5000);
}
});
// Fetch devices from API
const fetchDevices = async () => {
try {
const response = await fetch("/api/pm3/devices");
if (!response.ok) {
throw new Error(`Failed to fetch devices: ${response.statusText}`);
}
const data = await response.json();
// API returns {devices: [...]} directly (no success field)
const deviceList = data.devices || [];
setDevices(deviceList);
// Auto-select single device if enabled
if (autoSelectSingle && deviceList.length === 1 && !selectedDeviceId) {
const device = deviceList[0];
if (device.status === "connected") {
onDeviceSelect(device.device_id);
}
}
setError(null);
} catch (err) {
console.error("Error fetching devices:", err);
setError(err instanceof Error ? err.message : "Failed to fetch devices");
setDevices([]);
} finally {
setLoading(false);
}
};
// Fetch devices on mount and set up polling
useEffect(() => {
fetchDevices();
const interval = setInterval(fetchDevices, 5000); // Poll every 5 seconds
return () => clearInterval(interval);
}, []);
// Handle device identification (LED blinking)
const handleIdentify = async (deviceId: string) => {
setIdentifying(deviceId);
try {
const response = await fetch(`/api/pm3/devices/${deviceId}/identify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ duration: 2000 }),
});
if (!response.ok) {
throw new Error("Failed to identify device");
}
// Keep identifying state for duration of LED blink
setTimeout(() => setIdentifying(null), 2100);
} catch (err) {
console.error("Error identifying device:", err);
setIdentifying(null);
}
};
// Handle firmware flash
const handleFlash = async (deviceId: string) => {
setShowFlashConfirm(null);
setFlashing(deviceId);
setFlashError(null);
try {
const response = await fetch(`/api/pm3/devices/${deviceId}/flash`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || "Flash request failed");
}
// Flash started - progress will come via WebSocket
} catch (err) {
console.error("Error flashing device:", err);
setFlashing(null);
setFlashError(err instanceof Error ? err.message : "Flash failed");
}
};
// Get status color
const getStatusColor = (device: Device): string => {
switch (device.status) {
case "connected":
return "var(--color-success)";
case "in_use":
return "var(--color-warning)";
case "version_mismatch":
case "disabled":
return "var(--color-warning)";
case "error":
case "disconnected":
return "var(--color-error)";
case "flashing":
return "var(--color-info)";
default:
return "var(--color-border)";
}
};
// Get status text
const getStatusText = (device: Device): string => {
switch (device.status) {
case "connected":
return "Connected";
case "in_use":
return "In Use";
case "version_mismatch":
return "Version Mismatch";
case "disabled":
return "Disabled";
case "error":
return "Error";
case "disconnected":
return "Disconnected";
case "flashing":
return "Flashing";
default:
return "Unknown";
}
};
// Check if device is selectable
const isDeviceSelectable = (device: Device): boolean => {
return (
device.status === "connected" ||
(device.status === "in_use" && device.device_id === selectedDeviceId)
);
};
// Get display name for device
const getDeviceName = (device: Device): string => {
return device.friendly_name || device.device_path;
};
if (loading) {
return (
<div className="card">
<h3 className="card-title">
<span style={{ color: "var(--color-primary)" }}>📡</span> Proxmark3 Devices
</h3>
<div style={{ textAlign: "center", padding: "var(--space-6)" }}>
<span className="spinner"></span>
<p className="text-muted" style={{ marginTop: "var(--space-3)" }}>
Detecting devices...
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="card" style={{ borderColor: "var(--color-error)" }}>
<h3 className="card-title">
<span style={{ color: "var(--color-error)" }}></span> Device Detection Error
</h3>
<p className="text-muted">{error}</p>
<button
className="btn btn-secondary"
style={{ marginTop: "var(--space-3)" }}
onClick={fetchDevices}
>
Retry
</button>
</div>
);
}
if (devices.length === 0) {
return (
<div className="card">
<h3 className="card-title">
<span style={{ color: "var(--color-primary)" }}>📡</span> Proxmark3 Devices
</h3>
<div style={{ textAlign: "center", padding: "var(--space-6)" }}>
<div style={{ fontSize: "3rem", marginBottom: "var(--space-3)", opacity: 0.5 }}>
🔌
</div>
<p className="text-secondary" style={{ marginBottom: "var(--space-2)" }}>
No Proxmark3 devices detected
</p>
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
Connect a Proxmark3 device via USB to get started
</p>
</div>
</div>
);
}
const confirmDevice = showFlashConfirm ? devices.find(d => d.device_id === showFlashConfirm) : null;
return (
<div className="card">
<h3 className="card-title">
<span style={{ color: "var(--color-primary)" }}>📡</span> Proxmark3 Devices ({devices.length})
</h3>
{/* Flash Error Alert */}
{flashError && (
<div
style={{
marginBottom: "var(--space-3)",
padding: "var(--space-3)",
background: "rgba(239, 68, 68, 0.1)",
borderLeft: "3px solid var(--color-error)",
borderRadius: "var(--radius)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span>
<strong>Flash Error:</strong> {flashError}
</span>
<button
className="btn btn-secondary"
style={{ padding: "var(--space-1) var(--space-2)", fontSize: "0.75rem" }}
onClick={() => setFlashError(null)}
>
Dismiss
</button>
</div>
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
{devices.map((device) => {
const isSelected = selectedDeviceId === device.device_id;
const isSelectable = isDeviceSelectable(device);
const statusColor = getStatusColor(device);
const isFlashing = device.status === "flashing" || flashing === device.device_id;
const deviceFlashProgress = flashProgress?.device_id === device.device_id ? flashProgress : null;
return (
<div
key={device.device_id}
style={{
padding: "var(--space-3)",
border: "2px solid",
borderColor: isSelected ? "var(--color-primary)" : statusColor,
borderRadius: "var(--radius)",
cursor: isSelectable && !isFlashing ? "pointer" : "not-allowed",
opacity: isSelectable || isFlashing ? 1 : 0.6,
transition: "all 150ms ease",
background: isSelected ? "rgba(37, 99, 235, 0.05)" : "transparent",
position: "relative",
}}
onClick={() => {
if (isSelectable && !isSelected && !isFlashing) {
onDeviceSelect(device.device_id);
}
}}
>
{/* Flash Progress Overlay */}
{isFlashing && deviceFlashProgress && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.8)",
borderRadius: "var(--radius)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
zIndex: 10,
padding: "var(--space-4)",
}}
>
<div style={{ color: "var(--color-info)", fontSize: "2rem", marginBottom: "var(--space-2)" }}>
{deviceFlashProgress.status === "complete" ? "✓" : deviceFlashProgress.status === "error" ? "✗" : "⚡"}
</div>
<div style={{ color: "white", fontWeight: 600, marginBottom: "var(--space-2)" }}>
{deviceFlashProgress.status === "complete" ? "Flash Complete" :
deviceFlashProgress.status === "error" ? "Flash Failed" : "Flashing Firmware..."}
</div>
<div style={{ width: "100%", marginBottom: "var(--space-2)" }}>
<div
style={{
height: "8px",
background: "rgba(255,255,255,0.2)",
borderRadius: "4px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${deviceFlashProgress.progress}%`,
background: deviceFlashProgress.status === "error" ? "var(--color-error)" :
deviceFlashProgress.status === "complete" ? "var(--color-success)" : "var(--color-info)",
transition: "width 300ms ease",
}}
/>
</div>
</div>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "0.875rem", textAlign: "center" }}>
{deviceFlashProgress.message}
</div>
<div style={{ color: "rgba(255,255,255,0.5)", fontSize: "0.75rem", marginTop: "var(--space-1)" }}>
{deviceFlashProgress.progress}%
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "var(--space-3)" }}>
{/* Device Info */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Device Name */}
<div style={{ fontWeight: 600, fontSize: "1.1rem", marginBottom: "var(--space-1)" }}>
{isSelected && (
<span style={{ color: "var(--color-primary)", marginRight: "var(--space-2)" }}>
</span>
)}
{getDeviceName(device)}
</div>
{/* Device Path & Serial */}
<div style={{ fontSize: "0.875rem", color: "var(--color-text-muted)", marginBottom: "var(--space-2)" }}>
<span style={{ fontFamily: "var(--font-mono)" }}>{device.device_path}</span>
{device.serial_number && (
<>
{" • "}
<span>SN: {device.serial_number}</span>
</>
)}
</div>
{/* Status & Firmware Version */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-2)", alignItems: "center" }}>
<span
className={`badge ${
device.status === "connected"
? "badge-success"
: device.status === "error" || device.status === "disconnected"
? "badge-error"
: device.status === "flashing"
? "badge-info"
: "badge-warning"
}`}
>
<span aria-hidden="true"></span>
{getStatusText(device)}
</span>
{device.firmware_info && (
<span className="text-muted" style={{ fontSize: "0.75rem" }}>
{device.firmware_info.os_version}
</span>
)}
{device.status === "in_use" && device.device_id !== selectedDeviceId && (
<span className="text-muted" style={{ fontSize: "0.75rem" }}>
🔒 Session Active
</span>
)}
</div>
{/* Version Mismatch Warning with Flash Button */}
{device.firmware_info && !device.firmware_info.compatible && device.status !== "flashing" && (
<div
style={{
marginTop: "var(--space-2)",
padding: "var(--space-2)",
background: "rgba(217, 119, 6, 0.1)",
borderLeft: "3px solid var(--color-warning)",
borderRadius: "var(--radius)",
fontSize: "0.75rem",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: "var(--space-2)" }}>
<div>
<div style={{ fontWeight: 600, marginBottom: "var(--space-1)" }}>
Firmware Version Mismatch
</div>
<div className="text-muted">
Device: {device.firmware_info.os_version}
Expected: {device.firmware_info.client_version}
</div>
</div>
<button
className="btn btn-warning"
style={{
fontSize: "0.75rem",
padding: "var(--space-1) var(--space-2)",
flexShrink: 0,
}}
onClick={(e) => {
e.stopPropagation();
setShowFlashConfirm(device.device_id);
}}
disabled={flashing !== null}
>
Flash Firmware
</button>
</div>
</div>
)}
</div>
{/* Identify Button */}
{showIdentifyButton && device.status === "connected" && !isFlashing && (
<button
className="btn btn-secondary"
style={{
fontSize: "0.875rem",
padding: "var(--space-2) var(--space-3)",
minWidth: "44px",
flexShrink: 0,
}}
onClick={(e) => {
e.stopPropagation();
handleIdentify(device.device_id);
}}
disabled={identifying === device.device_id}
aria-label="Identify device with LED blink"
>
{identifying === device.device_id ? (
<>
<span className="spinner"></span>
<span>Blinking...</span>
</>
) : (
<>
<span>💡</span>
<span>Identify</span>
</>
)}
</button>
)}
</div>
</div>
);
})}
</div>
{/* Selection Info */}
{selectedDeviceId && (
<div
style={{
marginTop: "var(--space-4)",
padding: "var(--space-3)",
background: "var(--color-bg)",
borderRadius: "var(--radius)",
fontSize: "0.875rem",
}}
>
<span style={{ color: "var(--color-success)" }}></span> Selected device will be used for all commands
</div>
)}
{/* Flash Confirmation Dialog */}
{showFlashConfirm && confirmDevice && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.75)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 1000,
}}
onClick={() => setShowFlashConfirm(null)}
>
<div
style={{
background: "var(--color-card)",
borderRadius: "var(--radius)",
padding: "var(--space-6)",
maxWidth: "450px",
width: "90%",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, marginBottom: "var(--space-4)" }}>
<span style={{ color: "var(--color-warning)" }}></span> Flash Firmware
</h3>
<p style={{ marginBottom: "var(--space-4)" }}>
You are about to flash firmware to this device. This process will:
</p>
<ul style={{ marginBottom: "var(--space-4)", paddingLeft: "var(--space-4)" }}>
<li>Update bootrom and fullimage</li>
<li>Make the device temporarily unavailable</li>
<li>Take approximately 1-2 minutes</li>
</ul>
<div
style={{
background: "var(--color-bg)",
borderRadius: "var(--radius)",
padding: "var(--space-3)",
marginBottom: "var(--space-4)",
fontSize: "0.875rem",
}}
>
<div style={{ marginBottom: "var(--space-2)" }}>
<strong>Device:</strong> {getDeviceName(confirmDevice)}
</div>
<div style={{ marginBottom: "var(--space-2)" }}>
<strong>Path:</strong> {confirmDevice.device_path}
</div>
{confirmDevice.firmware_info && (
<>
<div style={{ marginBottom: "var(--space-2)" }}>
<strong>Current:</strong> {confirmDevice.firmware_info.os_version}
</div>
<div>
<strong>Target:</strong> {confirmDevice.firmware_info.client_version}
</div>
</>
)}
</div>
<div
style={{
background: "rgba(239, 68, 68, 0.1)",
borderLeft: "3px solid var(--color-error)",
borderRadius: "var(--radius)",
padding: "var(--space-3)",
marginBottom: "var(--space-4)",
fontSize: "0.875rem",
}}
>
<strong>Warning:</strong> Do not disconnect the device during flashing.
Interruption may require recovery mode.
</div>
<div style={{ display: "flex", gap: "var(--space-3)", justifyContent: "flex-end" }}>
<button
className="btn btn-secondary"
onClick={() => setShowFlashConfirm(null)}
>
Cancel
</button>
<button
className="btn btn-warning"
onClick={() => handleFlash(showFlashConfirm)}
>
Flash Firmware
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
/**
* HeaderWidgets - Display status widgets in the header area
*
* Shows notifications from:
* - System managers (UPS, PM3, Updates)
* - Enabled plugins
*
* Supports:
* - Multiple severity levels (info, warning, error, success)
* - Dismissible widgets
* - Action buttons
* - Auto-expiry
* - Real-time updates via WebSocket
*/
import { useState, useEffect, useCallback } from "react";
import { Link } from "@remix-run/react";
import { useWebSocketEvent } from "~/hooks/useWebSocket";
interface HeaderWidget {
id: string;
source: string;
severity: "info" | "warning" | "error" | "success";
message: string;
dismissible: boolean;
icon?: string;
action_label?: string;
action_url?: string;
created_at: string;
expires_at?: string;
}
interface HeaderWidgetsProps {
apiBase?: string;
}
export function HeaderWidgets({ apiBase = "/api" }: HeaderWidgetsProps) {
const [widgets, setWidgets] = useState<HeaderWidget[]>([]);
const [loading, setLoading] = useState(true);
// Fetch widgets from API
const fetchWidgets = useCallback(async () => {
try {
const response = await fetch(`${apiBase}/system/widgets`);
if (response.ok) {
const data: HeaderWidget[] = await response.json();
setWidgets(data);
}
} catch (error) {
console.error("Failed to load widgets:", error);
} finally {
setLoading(false);
}
}, [apiBase]);
// WebSocket event subscriptions for real-time updates
useWebSocketEvent("widget_added", () => {
// Refresh widgets when a new one is added
fetchWidgets();
});
useWebSocketEvent("widget_removed", (data) => {
// Remove widget from local state
const widgetId = data.widget_id as string;
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
});
// Initial fetch and periodic refresh (30s fallback)
useEffect(() => {
fetchWidgets();
const interval = setInterval(fetchWidgets, 30000);
return () => clearInterval(interval);
}, [fetchWidgets]);
// Dismiss a widget
const dismissWidget = async (widgetId: string) => {
try {
const response = await fetch(`${apiBase}/system/widgets/${encodeURIComponent(widgetId)}/dismiss`, {
method: "POST",
});
if (response.ok) {
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
}
} catch (error) {
console.error("Failed to dismiss widget:", error);
}
};
// Don't render anything if loading or no widgets
if (loading || widgets.length === 0) {
return null;
}
return (
<div className="header-widgets" role="region" aria-label="Notifications">
{widgets.map((widget) => (
<div
key={widget.id}
className={`widget widget-${widget.severity}`}
role="alert"
aria-live={widget.severity === "error" ? "assertive" : "polite"}
>
{widget.icon && (
<span className="widget-icon" aria-hidden="true">
{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: ${widget.message}`}
title="Dismiss"
>
<DismissIcon />
</button>
)}
</div>
))}
</div>
);
}
function DismissIcon() {
return (
<svg
className="dismiss-icon"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M2 2L10 10M10 2L2 10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
export default HeaderWidgets;

View File

@@ -0,0 +1,243 @@
/**
* PowerWidget - Header battery/power status indicator
*
* Displays UPS/battery status in the header with:
* - Battery icon with fill level
* - Percentage display
* - Charging indicator
* - Real-time updates via WebSocket
*/
import { useState, useEffect, useCallback } from "react";
import { useWebSocketEvent } from "~/hooks/useWebSocket";
interface UPSStatus {
battery_percentage: number;
voltage: number;
current: number;
power_source: "ac" | "battery" | "unknown";
battery_status: "charging" | "discharging" | "full" | "critical" | "unknown";
time_remaining: number | null;
temperature: number | null;
last_updated: string | null;
is_available: boolean;
error_message: string | null;
}
interface PowerWidgetProps {
apiBase?: string;
}
export function PowerWidget({ apiBase = "/api" }: PowerWidgetProps) {
const [status, setStatus] = useState<UPSStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch UPS status from API
const fetchStatus = useCallback(async () => {
try {
const response = await fetch(`${apiBase}/system/ups/status`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data: UPSStatus = await response.json();
setStatus(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch");
} finally {
setLoading(false);
}
}, [apiBase]);
// WebSocket event subscriptions for real-time updates
useWebSocketEvent("ups_battery", (data) => {
setStatus((prev) => prev ? {
...prev,
battery_percentage: data.percentage as number,
voltage: data.voltage as number,
} : prev);
});
useWebSocketEvent("ups_warning", () => {
// Trigger a full refresh on warnings
fetchStatus();
});
useWebSocketEvent("ups_critical", () => {
fetchStatus();
});
// Initial fetch and fallback polling (120s since WebSocket is primary)
useEffect(() => {
fetchStatus();
const pollInterval = setInterval(fetchStatus, 120000);
return () => clearInterval(pollInterval);
}, [fetchStatus]);
// Don't render if UPS is not available
if (!loading && (!status || !status.is_available)) {
return null;
}
// Loading state
if (loading) {
return (
<div className="power-widget power-widget--loading" title="Loading power status...">
<div className="power-widget__icon">
<BatteryIcon percentage={50} />
</div>
</div>
);
}
const percentage = Math.round(status?.battery_percentage ?? 0);
const isCharging = status?.power_source === "ac" || status?.battery_status === "charging";
const isCritical = percentage <= 10;
const isLow = percentage <= 20;
const isFull = percentage >= 95 && isCharging;
// Determine status color
let statusClass = "power-widget--normal";
if (isCritical) {
statusClass = "power-widget--critical";
} else if (isLow) {
statusClass = "power-widget--low";
} else if (isFull) {
statusClass = "power-widget--full";
} else if (isCharging) {
statusClass = "power-widget--charging";
}
// Build tooltip
const tooltipParts = [
`Battery: ${percentage}%`,
`Source: ${status?.power_source === "ac" ? "AC Power" : "Battery"}`,
];
if (status?.voltage && status.voltage > 0) {
// Voltage comes in mV from API, convert to V for display
tooltipParts.push(`Voltage: ${(status.voltage / 1000).toFixed(2)}V`);
}
if (status?.current !== undefined && status?.current !== null) {
// Current in Amps - positive = charging, negative = discharging
const absCurrentMa = Math.abs(status.current * 1000).toFixed(0);
const direction = status.current >= 0 ? "charging" : "draw";
tooltipParts.push(`Current: ${absCurrentMa}mA ${direction}`);
}
if (status?.time_remaining) {
// Format time remaining nicely
const mins = status.time_remaining;
if (mins < 60) {
tooltipParts.push(`Est. runtime: ${mins} min`);
} else {
const hours = Math.floor(mins / 60);
const remainMins = mins % 60;
tooltipParts.push(`Est. runtime: ${hours}h ${remainMins}m`);
}
}
const tooltip = tooltipParts.join("\n");
return (
<div
className={`power-widget ${statusClass}`}
title={tooltip}
role="status"
aria-label={`Battery at ${percentage}%${isCharging ? ", plugged in" : ", on battery"}`}
>
<div className="power-widget__icon">
<BatteryIcon percentage={percentage} isCharging={isCharging} />
</div>
<span className="power-widget__percentage">{percentage}%</span>
{isCharging && (
<span className="power-widget__plug-indicator" aria-label="Plugged in" title="AC Power">
<PlugIcon />
</span>
)}
</div>
);
}
interface BatteryIconProps {
percentage: number;
isCharging?: boolean;
}
function BatteryIcon({ percentage, isCharging = false }: BatteryIconProps) {
// Calculate fill width (0-100%)
const fillWidth = Math.max(0, Math.min(100, percentage));
return (
<svg
className="battery-icon"
viewBox="0 0 24 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
{/* Battery body outline */}
<rect
x="0.5"
y="0.5"
width="20"
height="13"
rx="2"
stroke="currentColor"
strokeWidth="1"
fill="none"
/>
{/* Battery terminal */}
<rect
x="21"
y="4"
width="3"
height="6"
rx="1"
fill="currentColor"
/>
{/* Battery fill */}
<rect
className="battery-icon__fill"
x="2"
y="2"
width={Math.max(0, (fillWidth / 100) * 17)}
height="10"
rx="1"
fill="currentColor"
/>
{/* Charging bolt */}
{isCharging && (
<path
className="battery-icon__bolt"
d="M12 1L8 7H11L9 13L14 6H11L12 1Z"
fill="var(--color-bg)"
stroke="currentColor"
strokeWidth="0.5"
/>
)}
</svg>
);
}
function PlugIcon() {
return (
<svg
className="plug-icon"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
{/* Plug prongs */}
<rect x="3" y="0" width="2" height="4" rx="0.5" fill="currentColor" />
<rect x="7" y="0" width="2" height="4" rx="0.5" fill="currentColor" />
{/* Plug body */}
<rect x="2" y="3" width="8" height="5" rx="1" fill="currentColor" />
{/* Cord */}
<rect x="5" y="8" width="2" height="4" rx="0.5" fill="currentColor" />
</svg>
);
}
export default PowerWidget;

View File

@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
interface WifiCredentials {
ssid: string;
password: string;
security: string;
hidden: boolean;
}
interface QRScannerProps {
onScan: (credentials: WifiCredentials) => void;
onClose: () => void;
onError?: (error: string) => void;
}
/**
* Parse WiFi QR code string
* Format: WIFI:T:WPA;S:NetworkName;P:Password;H:true;;
*/
function parseWifiQR(text: string): WifiCredentials | null {
if (!text.startsWith("WIFI:")) {
return null;
}
const result: WifiCredentials = {
ssid: "",
password: "",
security: "WPA",
hidden: false,
};
// Remove WIFI: prefix and trailing ;;
const data = text.slice(5).replace(/;;$/, "");
// Parse key:value pairs separated by ;
const parts = data.split(";");
for (const part of parts) {
const colonIdx = part.indexOf(":");
if (colonIdx === -1) continue;
const key = part.slice(0, colonIdx).toUpperCase();
const value = part.slice(colonIdx + 1);
switch (key) {
case "S":
result.ssid = value;
break;
case "P":
result.password = value;
break;
case "T":
result.security = value.toUpperCase();
break;
case "H":
result.hidden = value.toLowerCase() === "true";
break;
}
}
// Unescape special characters
result.ssid = result.ssid.replace(/\\;/g, ";").replace(/\\:/g, ":").replace(/\\\\/g, "\\");
result.password = result.password.replace(/\\;/g, ";").replace(/\\:/g, ":").replace(/\\\\/g, "\\");
return result.ssid ? result : null;
}
export default function QRScanner({ onScan, onClose, onError }: QRScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cameraReady, setCameraReady] = useState(false);
const scannerRef = useRef<any>(null);
const streamRef = useRef<MediaStream | null>(null);
const isRunningRef = useRef(false);
// Safe stop function that checks scanner state
const safeStopScanner = async () => {
if (scannerRef.current && isRunningRef.current) {
try {
await scannerRef.current.stop();
isRunningRef.current = false;
} catch (err) {
// Ignore stop errors - scanner may already be stopped
console.debug("Scanner stop (already stopped):", err);
}
}
};
useEffect(() => {
let mounted = true;
let animationFrameId: number;
const startScanner = async () => {
try {
// Import html5-qrcode dynamically (client-side only)
const { Html5Qrcode } = await import("html5-qrcode");
if (!mounted) return;
const scanner = new Html5Qrcode("qr-reader");
scannerRef.current = scanner;
await scanner.start(
{ facingMode: "environment" },
{
fps: 10,
qrbox: { width: 250, height: 250 },
},
(decodedText) => {
const credentials = parseWifiQR(decodedText);
if (credentials) {
isRunningRef.current = false;
scanner.stop().catch(console.error);
onScan(credentials);
} else {
setError("Not a valid WiFi QR code");
setTimeout(() => setError(null), 2000);
}
},
() => {} // Ignore scan failures
);
if (mounted) {
isRunningRef.current = true;
setScanning(true);
setCameraReady(true);
}
} catch (err: any) {
console.error("QR Scanner error:", err);
const errorMessage = err.message || "Failed to access camera";
setError(errorMessage);
onError?.(errorMessage);
}
};
startScanner();
return () => {
mounted = false;
safeStopScanner();
};
}, [onScan, onError]);
const handleClose = () => {
safeStopScanner();
onClose();
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(10, 14, 26, 0.95)",
backdropFilter: "blur(8px)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 250,
padding: "var(--space-4)",
}}
>
<div
style={{
maxWidth: "400px",
width: "100%",
textAlign: "center",
}}
>
<h3 style={{ marginBottom: "var(--space-4)", color: "var(--color-text)" }}>
<span style={{ color: "var(--color-primary)" }}>📷</span> Scan WiFi QR Code
</h3>
<p style={{
fontSize: "0.875rem",
color: "var(--color-text-muted)",
marginBottom: "var(--space-4)"
}}>
Point camera at a WiFi QR code to connect automatically
</p>
{/* QR Scanner Container */}
<div
id="qr-reader"
style={{
width: "100%",
maxWidth: "350px",
margin: "0 auto var(--space-4)",
borderRadius: "var(--radius)",
overflow: "hidden",
background: "#000",
minHeight: "300px",
}}
/>
{/* Status Messages */}
{error && (
<div
style={{
padding: "var(--space-3)",
background: "rgba(255, 107, 107, 0.1)",
border: "1px solid var(--color-error)",
borderRadius: "var(--radius)",
marginBottom: "var(--space-4)",
color: "var(--color-error)",
fontSize: "0.875rem",
}}
>
{error}
</div>
)}
{!cameraReady && !error && (
<div
style={{
padding: "var(--space-3)",
marginBottom: "var(--space-4)",
color: "var(--color-text-muted)",
fontSize: "0.875rem",
}}
>
<span className="spinner" style={{ marginRight: "var(--space-2)" }}></span>
Initializing camera...
</div>
)}
{/* Hint */}
<div
style={{
fontSize: "0.75rem",
color: "var(--color-text-muted)",
marginBottom: "var(--space-4)",
padding: "var(--space-3)",
background: "var(--color-bg)",
borderRadius: "var(--radius)",
}}
>
<strong>Tip:</strong> Most routers have a WiFi QR code on a sticker.
You can also generate one at{" "}
<a
href="https://qifi.org"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--color-primary)" }}
>
qifi.org
</a>
</div>
{/* Close Button */}
<button
type="button"
className="btn btn-secondary"
onClick={handleClose}
style={{ width: "100%" }}
>
Cancel
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,279 @@
/**
* WebSocket hook for real-time event subscriptions.
*
* Features:
* - Single shared connection per browser tab
* - Exponential backoff reconnection (1s -> 2s -> 4s -> ... -> 30s max)
* - Component-level event subscriptions
* - Connection state management
*/
import { useEffect, useRef, useState } from "react";
// Connection states
export type ConnectionState = "connecting" | "connected" | "disconnected";
// Event message format from backend
interface WebSocketMessage {
type: "event";
event: string;
data: Record<string, unknown>;
timestamp: string;
}
// Event handler type
type EventHandler = (data: Record<string, unknown>) => void;
// Singleton WebSocket manager
class WebSocketManager {
private static instance: WebSocketManager | null = null;
private ws: WebSocket | null = null;
private subscribers: Map<string, Set<EventHandler>> = new Map();
private stateListeners: Set<(state: ConnectionState) => void> = new Set();
private reconnectAttempts = 0;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private state: ConnectionState = "disconnected";
// Backoff configuration
private readonly INITIAL_DELAY = 1000; // 1 second
private readonly MAX_DELAY = 30000; // 30 seconds
private readonly BACKOFF_MULTIPLIER = 2;
private constructor() {}
static getInstance(): WebSocketManager {
if (!WebSocketManager.instance) {
WebSocketManager.instance = new WebSocketManager();
}
return WebSocketManager.instance;
}
private getWebSocketUrl(): string {
if (typeof window === "undefined") {
return "ws://localhost:8000/ws/events";
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.hostname;
const port = window.location.port || (protocol === "wss:" ? "443" : "80");
return `${protocol}//${host}:${port}/ws/events`;
}
connect(): void {
if (
this.ws?.readyState === WebSocket.OPEN ||
this.ws?.readyState === WebSocket.CONNECTING
) {
return;
}
this.setState("connecting");
const url = this.getWebSocketUrl();
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log("[WS] Connected to", url);
this.reconnectAttempts = 0;
this.setState("connected");
};
this.ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
if (message.type === "event") {
this.notifySubscribers(message.event, message.data);
}
} catch (e) {
console.error("[WS] Failed to parse message:", e);
}
};
this.ws.onclose = (event) => {
console.log(`[WS] Disconnected (code: ${event.code})`);
this.ws = null;
this.setState("disconnected");
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error("[WS] Error:", error);
};
} catch (e) {
console.error("[WS] Connection failed:", e);
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
// Only reconnect if there are still subscribers
if (this.getTotalSubscriberCount() === 0) {
return;
}
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
const delay = Math.min(
this.INITIAL_DELAY *
Math.pow(this.BACKOFF_MULTIPLIER, this.reconnectAttempts),
this.MAX_DELAY
);
console.log(
`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`
);
this.reconnectTimeout = setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
disconnect(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.setState("disconnected");
}
subscribe(eventType: string, handler: EventHandler): () => void {
if (!this.subscribers.has(eventType)) {
this.subscribers.set(eventType, new Set());
}
this.subscribers.get(eventType)!.add(handler);
// Start connection if this is first subscriber
if (this.getTotalSubscriberCount() === 1) {
this.connect();
}
// Return unsubscribe function
return () => {
const handlers = this.subscribers.get(eventType);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.subscribers.delete(eventType);
}
}
// Disconnect if no subscribers left
if (this.getTotalSubscriberCount() === 0) {
this.disconnect();
}
};
}
subscribeToState(listener: (state: ConnectionState) => void): () => void {
this.stateListeners.add(listener);
// Immediately notify of current state
listener(this.state);
return () => {
this.stateListeners.delete(listener);
};
}
private notifySubscribers(
eventType: string,
data: Record<string, unknown>
): void {
const handlers = this.subscribers.get(eventType);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(data);
} catch (e) {
console.error(`[WS] Handler error for ${eventType}:`, e);
}
});
}
}
private setState(newState: ConnectionState): void {
this.state = newState;
this.stateListeners.forEach((listener) => listener(newState));
}
private getTotalSubscriberCount(): number {
let count = 0;
this.subscribers.forEach((handlers) => {
count += handlers.size;
});
return count;
}
getState(): ConnectionState {
return this.state;
}
}
/**
* Hook to subscribe to WebSocket events.
*
* @param eventType - The event type to subscribe to (e.g., "system_stats", "ups_battery")
* @param handler - Callback function called when event is received
*
* @example
* ```tsx
* function MyComponent() {
* useWebSocketEvent("system_stats", (data) => {
* console.log("System stats:", data);
* });
* }
* ```
*/
export function useWebSocketEvent(
eventType: string,
handler: EventHandler
): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const wsManager = WebSocketManager.getInstance();
const wrappedHandler: EventHandler = (data) => {
handlerRef.current(data);
};
const unsubscribe = wsManager.subscribe(eventType, wrappedHandler);
return unsubscribe;
}, [eventType]);
}
/**
* Hook to get current WebSocket connection state.
*
* @returns Current connection state: "connecting" | "connected" | "disconnected"
*
* @example
* ```tsx
* function StatusIndicator() {
* const connectionState = useWebSocketState();
* return <span>{connectionState}</span>;
* }
* ```
*/
export function useWebSocketState(): ConnectionState {
const [state, setState] = useState<ConnectionState>("disconnected");
useEffect(() => {
const wsManager = WebSocketManager.getInstance();
const unsubscribe = wsManager.subscribeToState(setState);
return unsubscribe;
}, []);
return state;
}

View File

@@ -9,6 +9,8 @@ import {
} from "@remix-run/react";
import { useState, useEffect } from "react";
import styles from "./styles.css?url";
import { PowerWidget } from "./components/PowerWidget";
import { HeaderWidgets } from "./components/HeaderWidgets";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
@@ -79,7 +81,8 @@ export default function App() {
<span>Dangerous Pi</span>
</a>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<PowerWidget />
<button
onClick={toggleTheme}
className="btn-secondary"
@@ -93,6 +96,9 @@ export default function App() {
</div>
</header>
{/* Header Widgets - Notifications from system and plugins */}
<HeaderWidgets />
{/* Desktop Navigation - Hidden on mobile */}
<nav className="nav" style={{ display: "none" }}>
{navLinks.map((link) => (

View File

@@ -1,60 +1,117 @@
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useState, useEffect } from "react";
import { useState } from "react";
import { useWebSocketEvent } from "~/hooks/useWebSocket";
export async function loader() {
try {
// Fetch system info from backend
const [healthRes, statusRes, systemRes] = await Promise.all([
const [healthRes, devicesRes, systemRes] = await Promise.all([
fetch("http://localhost:8000/api/health"),
fetch("http://localhost:8000/api/pm3/status"),
fetch("http://localhost:8000/api/pm3/devices"),
fetch("http://localhost:8000/api/system/info"),
]);
const health = await healthRes.json();
const pm3Status = await statusRes.json();
const systemInfo = await systemRes.json();
const devicesData = await devicesRes.json();
const rawSystemInfo = await systemRes.json();
return json({ health, pm3Status, systemInfo });
// Extract devices array from response (API returns {devices: [...]} directly)
const devices = devicesData.devices || [];
// Flatten system info for easier access in UI
const systemInfo = {
hostname: rawSystemInfo.hostname,
cpu_temp: rawSystemInfo.cpu_temp,
cpu_per_core: rawSystemInfo.cpu?.per_core || [],
load_average: rawSystemInfo.cpu?.load_average || null,
memory_used: rawSystemInfo.memory_used,
memory_total: rawSystemInfo.memory_total,
disk_used: rawSystemInfo.disk_used,
disk_total: rawSystemInfo.disk_total,
};
return json({ health, devices, systemInfo });
} catch (error) {
// Return mock data if backend is not available
return json({
health: { status: "unknown", version: "0.1.0" },
pm3Status: { connected: false, device: "/dev/ttyACM0", session_active: false },
systemInfo: { hostname: "dangerous-pi", cpu_temp: null, memory_used: 0, memory_total: 0 },
devices: [],
systemInfo: {
hostname: "dangerous-pi",
cpu_temp: null,
cpu_per_core: [],
load_average: null,
memory_used: 0,
memory_total: 0,
disk_used: 0,
disk_total: 0,
},
});
}
}
interface SystemStats {
cpu_percent: number;
cpu_per_core: { core_id: number; percent: number; online?: boolean }[];
load_average: number[];
memory_percent: number;
temperature: number | null;
}
interface PM3DeviceData {
device_id: string;
device_path: string;
status: string;
friendly_name: string;
firmware_info?: { os_version?: string };
}
export default function Dashboard() {
const data = useLoaderData<typeof loader>();
const loaderData = useLoaderData<typeof loader>();
const [eventMessage, setEventMessage] = useState<string | null>(null);
// Connect to SSE for real-time updates
useEffect(() => {
const eventSource = new EventSource("/sse/events");
// Real-time state from WebSocket
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [pm3Devices, setPm3Devices] = useState<PM3DeviceData[] | null>(null);
eventSource.addEventListener("connected", (e) => {
console.log("SSE connected:", e.data);
});
// Merge real-time data with loader data
const data = {
...loaderData,
systemInfo: {
...loaderData.systemInfo,
...(systemStats && {
cpu_per_core: systemStats.cpu_per_core,
load_average: systemStats.load_average,
cpu_temp: systemStats.temperature ?? loaderData.systemInfo.cpu_temp,
}),
},
devices: pm3Devices ?? loaderData.devices,
};
eventSource.addEventListener("pm3_status", (e) => {
const data = JSON.parse(e.data);
setEventMessage(`PM3: ${data.message}`);
setTimeout(() => setEventMessage(null), 5000);
});
// WebSocket event subscriptions for real-time updates
useWebSocketEvent("connected", (data) => {
console.log("WebSocket connected:", data);
});
eventSource.addEventListener("update_available", (e) => {
const data = JSON.parse(e.data);
setEventMessage(`Update available: ${data.version}`);
});
useWebSocketEvent("pm3_status", (data) => {
setEventMessage(`PM3: ${data.message}`);
setTimeout(() => setEventMessage(null), 5000);
});
eventSource.onerror = () => {
console.log("SSE connection error, will retry...");
};
// PM3 devices - real-time on connect/disconnect
useWebSocketEvent("pm3_devices", (data) => {
setPm3Devices(data.devices as PM3DeviceData[]);
});
return () => eventSource.close();
}, []);
// System stats - real-time every 5s from backend
useWebSocketEvent("system_stats", (data) => {
setSystemStats(data as SystemStats);
});
useWebSocketEvent("update_available", (data) => {
setEventMessage(`Update available: ${data.version}`);
});
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
@@ -113,6 +170,70 @@ export default function Dashboard() {
</span>
</div>
)}
{/* CPU Load per Core */}
{data.systemInfo.cpu_per_core && data.systemInfo.cpu_per_core.length > 0 && (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-2)" }}>
<span className="text-secondary">CPU Cores</span>
{data.systemInfo.load_average && (
<span className="text-muted" style={{ fontSize: "0.75rem" }}>
Load: {data.systemInfo.load_average.map((l: number) => l.toFixed(2)).join(" / ")}
</span>
)}
</div>
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
{data.systemInfo.cpu_per_core.map((core: { core_id: number; percent: number; online?: boolean }) => {
const isOnline = core.online !== false;
return (
<div
key={core.core_id}
className="cpu-core-bar"
style={{
flex: "1 1 auto",
minWidth: "50px",
textAlign: "center",
opacity: isOnline ? 1 : 0.4,
}}
title={isOnline ? `Core ${core.core_id}: ${core.percent.toFixed(0)}%` : `Core ${core.core_id}: Disabled`}
>
<div
style={{
height: "4px",
background: "var(--color-border)",
borderRadius: "2px",
overflow: "hidden",
marginBottom: "var(--space-1)",
}}
>
<div
style={{
height: "100%",
width: isOnline ? `${Math.min(100, core.percent)}%` : "0%",
background: !isOnline
? "var(--color-text-muted)"
: core.percent > 80
? "var(--color-error)"
: core.percent > 50
? "var(--color-warning)"
: "var(--color-accent)",
transition: "width 0.3s ease",
}}
/>
</div>
<span style={{
fontSize: "0.7rem",
fontFamily: "var(--font-mono)",
color: isOnline ? "inherit" : "var(--color-text-muted)",
textDecoration: isOnline ? "none" : "line-through",
}}>
C{core.core_id}: {isOnline ? `${core.percent.toFixed(0)}%` : "OFF"}
</span>
</div>
);
})}
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">Memory</span>
<span className="text-primary">
@@ -128,43 +249,74 @@ export default function Dashboard() {
</div>
</div>
{/* PM3 Status */}
{/* PM3 Devices */}
<div className="card">
<h3 className="card-title">
<span style={{ color: "var(--color-accent)" }}></span> Proxmark3
<span style={{ color: "var(--color-accent)" }}></span> Proxmark3 Devices ({data.devices.length})
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">Status</span>
<span className={`badge ${data.pm3Status.connected ? "badge-success" : "badge-error"} badge-pulse`}>
<span aria-hidden="true"></span>
{data.pm3Status.connected ? "Connected" : "Disconnected"}
</span>
{data.devices.length === 0 ? (
<div style={{ textAlign: "center", padding: "var(--space-4)" }}>
<div style={{ fontSize: "2rem", marginBottom: "var(--space-2)", opacity: 0.5 }}>🔌</div>
<p className="text-secondary" style={{ marginBottom: "var(--space-1)" }}>
No devices detected
</p>
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
Connect a Proxmark3 via USB
</p>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">Device</span>
<span className="text-primary" style={{ fontFamily: "var(--font-mono)", fontSize: "0.875rem" }}>
{data.pm3Status.device}
</span>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
{data.devices.map((device: any) => {
const isConnected = device.status === "connected";
const isInUse = device.status === "in_use";
const deviceName = device.friendly_name || device.device_path;
return (
<div
key={device.device_id}
style={{
padding: "var(--space-3)",
background: "var(--color-bg)",
borderRadius: "var(--radius)",
border: "1px solid var(--color-border)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-2)" }}>
<span style={{ fontWeight: 600, fontSize: "1rem" }}>{deviceName}</span>
<span
className={`badge ${
isConnected
? "badge-success"
: isInUse
? "badge-warning"
: "badge-error"
} badge-pulse`}
>
<span aria-hidden="true"></span>
{isConnected ? "Connected" : isInUse ? "In Use" : device.status}
</span>
</div>
<div style={{ fontSize: "0.875rem", color: "var(--color-text-muted)" }}>
<div style={{ marginBottom: "var(--space-1)" }}>
<span style={{ fontFamily: "var(--font-mono)" }}>{device.device_path}</span>
</div>
{device.firmware_info && (
<div>
{device.firmware_info.os_version}
</div>
)}
</div>
</div>
);
})}
</div>
{data.pm3Status.version && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">Version</span>
<span className="text-primary" style={{ fontSize: "0.875rem" }}>
{data.pm3Status.version}
</span>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">Session</span>
<span className="text-primary">
{data.pm3Status.session_active ? "Active" : "Idle"}
</span>
</div>
</div>
)}
<div style={{ marginTop: "var(--space-4)", display: "flex", gap: "var(--space-2)" }}>
<a href="/commands" className="btn btn-primary" style={{ flex: 1 }}>
Start Session
{data.devices.length > 0 ? "Select Device & Start" : "Waiting for Device..."}
</a>
</div>
</div>

View File

@@ -1,17 +1,23 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation, useSearchParams } from "@remix-run/react";
import { useState, useEffect, useRef } from "react";
import DeviceSelector from "../components/DeviceSelector";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const command = formData.get("command") as string;
const sessionId = formData.get("sessionId") as string;
const deviceId = formData.get("deviceId") as string;
try {
const response = await fetch("http://localhost:8000/api/pm3/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command, session_id: sessionId }),
body: JSON.stringify({
command,
session_id: sessionId,
device_id: deviceId,
}),
});
const result = await response.json();
@@ -25,6 +31,41 @@ export async function action({ request }: ActionFunctionArgs) {
}
}
// Helper to get/set session from localStorage
const SESSION_STORAGE_KEY = "pm3_sessions";
function getStoredSession(deviceId: string): string | null {
if (typeof window === "undefined") return null;
try {
const sessions = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY) || "{}");
return sessions[deviceId] || null;
} catch {
return null;
}
}
function storeSession(deviceId: string, sessionId: string): void {
if (typeof window === "undefined") return;
try {
const sessions = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY) || "{}");
sessions[deviceId] = sessionId;
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessions));
} catch {
// Ignore storage errors
}
}
function clearStoredSession(deviceId: string): void {
if (typeof window === "undefined") return;
try {
const sessions = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY) || "{}");
delete sessions[deviceId];
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessions));
} catch {
// Ignore storage errors
}
}
export default function Commands() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
@@ -32,30 +73,61 @@ export default function Commands() {
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [sessionId, setSessionId] = useState<string | null>(null);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
const [sessionError, setSessionError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const outputRef = useRef<HTMLDivElement>(null);
const isSubmitting = navigation.state === "submitting";
// Create session on mount
// Create or reuse session when device is selected
useEffect(() => {
async function createSession() {
if (!selectedDeviceId) {
setSessionId(null);
setSessionError(null);
return;
}
async function ensureSession() {
// First, check if we have a stored session for this device
const storedSessionId = getStoredSession(selectedDeviceId);
if (storedSessionId) {
// Try to verify the stored session is still valid by using it
// We'll set it and if commands fail, we'll create a new one
setSessionId(storedSessionId);
setSessionError(null);
return;
}
// No stored session, create a new one
try {
const response = await fetch("/api/system/session/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force_takeover: false }),
body: JSON.stringify({
device_id: selectedDeviceId,
force_takeover: true, // Take over any stale sessions
}),
});
const data = await response.json();
if (data.success) {
setSessionId(data.session_id);
storeSession(selectedDeviceId, data.session_id);
setSessionError(null);
} else {
setSessionId(null);
clearStoredSession(selectedDeviceId);
setSessionError(data.error || "Failed to create session");
}
} catch (error) {
console.error("Failed to create session:", error);
setSessionId(null);
setSessionError("Network error while creating session");
}
}
createSession();
}, []);
ensureSession();
}, [selectedDeviceId]);
// Pre-fill command from URL param
const prefilledCommand = searchParams.get("cmd")?.replace(/\+/g, " ") || "";
@@ -122,14 +194,39 @@ export default function Commands() {
<span style={{ color: "var(--color-accent)" }}></span> PM3 Commands
</h1>
{!sessionId && (
{/* Device Selector */}
<div style={{ marginBottom: "var(--space-4)" }}>
<DeviceSelector
selectedDeviceId={selectedDeviceId}
onDeviceSelect={setSelectedDeviceId}
showIdentifyButton={true}
autoSelectSingle={true}
/>
</div>
{/* Session Error/Warning */}
{selectedDeviceId && sessionError && (
<div className="card" style={{ marginBottom: "var(--space-4)", borderColor: "var(--color-error)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
<span style={{ fontSize: "1.5rem" }}></span>
<div>
<strong>Session Error</strong>
<p className="text-secondary" style={{ marginTop: "var(--space-1)", marginBottom: 0 }}>
{sessionError}
</p>
</div>
</div>
</div>
)}
{selectedDeviceId && !sessionId && !sessionError && (
<div className="card" style={{ marginBottom: "var(--space-4)", borderColor: "var(--color-warning)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
<span style={{ fontSize: "1.5rem" }}></span>
<div>
<strong>Session Required</strong>
<strong>Creating Session...</strong>
<p className="text-secondary" style={{ marginTop: "var(--space-1)", marginBottom: 0 }}>
Creating session...
Establishing connection to device...
</p>
</div>
</div>
@@ -163,6 +260,7 @@ export default function Commands() {
<div className="card" style={{ marginBottom: "var(--space-4)" }}>
<Form method="post">
<input type="hidden" name="sessionId" value={sessionId || ""} />
<input type="hidden" name="deviceId" value={selectedDeviceId || ""} />
<div className="form-group">
<label htmlFor="command" className="label">
Command
@@ -174,18 +272,24 @@ export default function Commands() {
id="command"
name="command"
className="input"
placeholder="Enter PM3 command (e.g., hw status)"
placeholder={
!selectedDeviceId
? "Select a device first..."
: !sessionId
? "Creating session..."
: "Enter PM3 command (e.g., hw status)"
}
defaultValue={prefilledCommand}
onKeyDown={handleKeyDown}
disabled={!sessionId || isSubmitting}
disabled={!selectedDeviceId || !sessionId || isSubmitting}
autoComplete="off"
autoFocus
autoFocus={!!sessionId}
style={{ flex: 1 }}
/>
<button
type="submit"
className="btn btn-primary"
disabled={!sessionId || isSubmitting}
disabled={!selectedDeviceId || !sessionId || isSubmitting}
style={{ minWidth: "100px" }}
>
{isSubmitting ? (
@@ -202,7 +306,11 @@ export default function Commands() {
</button>
</div>
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)", marginBottom: 0 }}>
Use / arrow keys to navigate command history
{selectedDeviceId && sessionId
? "Use ↑/↓ arrow keys to navigate command history"
: !selectedDeviceId
? "Select a Proxmark3 device above to start executing commands"
: "Waiting for session..."}
</p>
</div>
</Form>

View File

@@ -1,21 +1,34 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation, useFetcher } from "@remix-run/react";
import { useState, useEffect } from "react";
import { useState, useEffect, lazy, Suspense } from "react";
import ConnectDialog from "~/components/ConnectDialog";
// Lazy load QR Scanner
const QRScanner = lazy(() => import("~/components/QRScanner"));
export async function loader() {
try {
const [configRes, wifiStatusRes, savedNetworksRes] = await Promise.all([
const [configRes, wifiStatusRes, savedNetworksRes, cpuCoresRes, pluginsRes] = await Promise.all([
fetch("http://localhost:8000/api/system/config"),
fetch("http://localhost:8000/api/wifi/status"),
fetch("http://localhost:8000/api/wifi/saved"),
fetch("http://localhost:8000/api/system/cpu/cores"),
fetch("http://localhost:8000/api/plugins/list"),
]);
const config = await configRes.json();
const wifiStatus = await wifiStatusRes.json();
const savedNetworksData = await savedNetworksRes.json();
const cpuCores = await cpuCoresRes.json();
const plugins = pluginsRes.ok ? await pluginsRes.json() : [];
return json({ config, wifiStatus, savedNetworks: savedNetworksData.networks || [] });
return json({
config,
wifiStatus,
savedNetworks: savedNetworksData.networks || [],
cpuCores,
plugins
});
} catch (error) {
return json({
config: {
@@ -36,6 +49,14 @@ export async function loader() {
ap_ip: "10.3.141.1",
},
savedNetworks: [],
cpuCores: {
total_cores: 4,
online_cores: 4,
configured_cores: null,
configurable_cores: [1, 2, 3],
pi_model: null
},
plugins: [],
});
}
}
@@ -131,20 +152,107 @@ export async function action({ request }: ActionFunctionArgs) {
}
}
if (action === "set_cpu_cores") {
const numCores = parseInt(formData.get("num_cores") as string, 10);
try {
const response = await fetch("http://localhost:8000/api/system/cpu/cores", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
num_cores: numCores,
persist: true,
reboot: true
}),
});
const result = await response.json();
if (result.success) {
return json({
success: true,
message: result.rebooting
? `CPU cores saved to ${numCores}. Rebooting...`
: `CPU cores set to ${result.actual || numCores}`
});
} else {
return json({ success: false, error: "Failed to set CPU cores" });
}
} catch (error) {
return json({ success: false, error: "Failed to set CPU cores" });
}
}
if (action === "discover_plugins") {
try {
const response = await fetch("http://localhost:8000/api/plugins/discover");
const result = await response.json();
return json({ success: true, message: `Discovered ${result.count || 0} plugins` });
} catch (error) {
return json({ success: false, error: "Failed to discover plugins" });
}
}
if (action === "enable_plugin") {
const plugin_id = formData.get("plugin_id") as string;
try {
const response = await fetch("http://localhost:8000/api/plugins/enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plugin_id }),
});
if (response.ok) {
return json({ success: true, message: `Plugin ${plugin_id} enabled` });
} else {
return json({ success: false, error: "Failed to enable plugin" });
}
} catch (error) {
return json({ success: false, error: "Failed to enable plugin" });
}
}
if (action === "disable_plugin") {
const plugin_id = formData.get("plugin_id") as string;
try {
const response = await fetch("http://localhost:8000/api/plugins/disable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plugin_id }),
});
if (response.ok) {
return json({ success: true, message: `Plugin ${plugin_id} disabled` });
} else {
return json({ success: false, error: "Failed to disable plugin" });
}
} catch (error) {
return json({ success: false, error: "Failed to disable plugin" });
}
}
return json({ success: false, error: "Unknown action" });
}
export default function Settings() {
const { config, wifiStatus, savedNetworks } = useLoaderData<typeof loader>();
const { config, wifiStatus, savedNetworks, cpuCores, plugins } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [activeSection, setActiveSection] = useState<"general" | "wifi" | "advanced" | "system">("general");
const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system">("general");
const [scannedNetworks, setScannedNetworks] = useState<any[]>([]);
const [selectedNetwork, setSelectedNetwork] = useState<any>(null);
const [showConnectDialog, setShowConnectDialog] = useState(false);
const [showQRScanner, setShowQRScanner] = useState(false);
const [selectedCores, setSelectedCores] = useState<number | null>(null);
const isSubmitting = navigation.state === "submitting";
// Reset selectedCores when loader data changes (after reboot)
// Use configured_cores if available, otherwise online_cores
const effectiveCores = cpuCores.configured_cores ?? cpuCores.online_cores;
useEffect(() => {
setSelectedCores(null);
}, [effectiveCores]);
useEffect(() => {
if (actionData && "networks" in actionData) {
setScannedNetworks(actionData.networks);
@@ -169,9 +277,22 @@ export default function Settings() {
setShowConnectDialog(true);
};
const handleQRScan = (credentials: { ssid: string; password: string; security: string; hidden: boolean }) => {
setShowQRScanner(false);
// Pre-fill the connect dialog with scanned credentials
setSelectedNetwork({
ssid: credentials.ssid,
encrypted: credentials.security !== "nopass",
signal_strength: 0,
_scannedPassword: credentials.password, // Pass password to dialog
});
setShowConnectDialog(true);
};
const sections = [
{ id: "general", label: "General", icon: "⚙" },
{ id: "wifi", label: "Wi-Fi", icon: "📡" },
{ id: "plugins", label: "Plugins", icon: "🔌" },
{ id: "advanced", label: "Advanced", icon: "◈" },
{ id: "system", label: "System", icon: "⚡" },
];
@@ -493,14 +614,22 @@ export default function Settings() {
</div>
)}
<div style={{ marginTop: "var(--space-4)", textAlign: "center" }}>
<div style={{ marginTop: "var(--space-4)", display: "flex", gap: "var(--space-2)", justifyContent: "center", flexWrap: "wrap" }}>
<button
type="button"
className="btn btn-primary"
onClick={() => setShowQRScanner(true)}
>
<span>📷</span>
<span>Scan WiFi QR</span>
</button>
<button
type="button"
className="btn btn-secondary"
onClick={handleConnectHidden}
>
<span>🔍</span>
<span>Connect to Hidden Network</span>
<span></span>
<span>Add Network</span>
</button>
</div>
@@ -573,6 +702,156 @@ export default function Settings() {
/>
)}
{/* QR Scanner Modal */}
{showQRScanner && (
<Suspense
fallback={
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(10, 14, 26, 0.95)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 250,
}}
>
<div style={{ textAlign: "center", color: "var(--color-text)" }}>
<span className="spinner" style={{ marginBottom: "var(--space-2)" }}></span>
<div>Loading scanner...</div>
</div>
</div>
}
>
<QRScanner
onScan={handleQRScan}
onClose={() => setShowQRScanner(false)}
onError={(err) => {
console.error("QR Scanner error:", err);
// Don't close immediately - let user see the error and dismiss manually
}}
/>
</Suspense>
)}
{/* Plugins Settings */}
{activeSection === "plugins" && (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
{/* Discover Plugins */}
<div className="card">
<h3 className="card-title">Installed Plugins</h3>
<p className="text-muted" style={{ fontSize: "0.875rem", marginBottom: "var(--space-4)" }}>
Manage plugins to extend Dangerous Pi functionality
</p>
<Form method="post">
<input type="hidden" name="_action" value="discover_plugins" />
<button type="submit" className="btn btn-secondary" disabled={isSubmitting}>
{isSubmitting ? (
<>
<span className="spinner"></span>
<span>Discovering...</span>
</>
) : (
<>
<span>🔍</span>
<span>Discover Plugins</span>
</>
)}
</button>
</Form>
</div>
{/* Plugin List */}
{plugins.length > 0 ? (
plugins.map((plugin: any) => (
<div className="card" key={plugin.metadata.id}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: "var(--space-3)" }}>
<div style={{ flex: 1, minWidth: "200px" }}>
<h4 style={{ margin: 0, marginBottom: "var(--space-2)" }}>
{plugin.metadata.name}
<span
className={`badge ${plugin.status === "loaded" ? "badge-success" : plugin.status === "error" ? "badge-error" : "badge-info"}`}
style={{ marginLeft: "var(--space-2)", fontSize: "0.75rem" }}
>
{plugin.status}
</span>
</h4>
<p className="text-muted" style={{ fontSize: "0.875rem", marginBottom: "var(--space-2)" }}>
{plugin.metadata.description}
</p>
<div style={{ fontSize: "0.75rem", color: "var(--color-text-muted)", marginBottom: "var(--space-2)" }}>
<span>v{plugin.metadata.version}</span>
<span style={{ margin: "0 var(--space-2)" }}></span>
<span>by {plugin.metadata.author}</span>
</div>
{/* Permissions badges */}
{plugin.metadata.permissions && plugin.metadata.permissions.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-1)", marginTop: "var(--space-2)" }}>
{plugin.metadata.permissions.map((perm: string) => (
<span
key={perm}
className="badge badge-info"
style={{ fontSize: "0.625rem", padding: "2px 6px" }}
>
{perm}
</span>
))}
</div>
)}
{/* Error message */}
{plugin.error_message && (
<div
style={{
marginTop: "var(--space-2)",
padding: "var(--space-2)",
background: "var(--color-error-bg)",
borderRadius: "var(--radius)",
fontSize: "0.75rem",
color: "var(--color-error)",
}}
>
{plugin.error_message}
</div>
)}
</div>
{/* Enable/Disable Toggle */}
<Form method="post">
<input type="hidden" name="_action" value={plugin.enabled ? "disable_plugin" : "enable_plugin"} />
<input type="hidden" name="plugin_id" value={plugin.metadata.id} />
<button
type="submit"
className={`btn ${plugin.enabled ? "btn-success" : "btn-secondary"}`}
disabled={isSubmitting}
style={{ minWidth: "100px" }}
>
{plugin.enabled ? "✓ Enabled" : "Disabled"}
</button>
</Form>
</div>
</div>
))
) : (
<div className="card">
<div style={{ textAlign: "center", padding: "var(--space-6)" }}>
<div style={{ fontSize: "3rem", marginBottom: "var(--space-3)" }}>🔌</div>
<h4 style={{ margin: 0, marginBottom: "var(--space-2)" }}>No Plugins Found</h4>
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
Place plugins in the <code style={{ fontFamily: "var(--font-mono)", background: "var(--color-surface)", padding: "2px 6px", borderRadius: "4px" }}>app/plugins/</code> directory
</p>
</div>
</div>
)}
</div>
)}
{/* Advanced Settings */}
{activeSection === "advanced" && (
<div className="card">
@@ -626,11 +905,111 @@ export default function Settings() {
{/* System Actions */}
{activeSection === "system" && (
<div className="card">
<h3 className="card-title">System Actions</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
{/* CPU Cores Configuration */}
<div className="card">
<h3 className="card-title">CPU Cores</h3>
<div className="form-group">
<label className="label">Restart Backend</label>
{cpuCores.pi_model && (
<div style={{ marginBottom: "var(--space-4)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-2)" }}>
<span className="text-secondary">Device</span>
<span className="text-primary">{cpuCores.pi_model.model_short}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">Recommended</span>
<span className="badge badge-info">{cpuCores.pi_model.default_active_cores} cores</span>
</div>
</div>
)}
<div className="form-group">
<label className="label">Active Cores</label>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", marginBottom: "var(--space-3)" }}>
<span style={{ fontSize: "1.5rem", fontWeight: 600, color: "var(--color-primary)", fontFamily: "var(--font-mono)" }}>
{cpuCores.online_cores}
</span>
<span className="text-muted">of {cpuCores.total_cores} cores active</span>
{cpuCores.configured_cores && cpuCores.configured_cores !== cpuCores.online_cores && (
<span className="badge badge-warning">
{cpuCores.configured_cores} configured (reboot pending)
</span>
)}
</div>
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", marginBottom: "var(--space-3)" }}>
{Array.from({ length: cpuCores.total_cores }, (_, i) => i + 1).map((num) => {
const isConfigured = effectiveCores === num;
const isSelected = selectedCores === num;
const showAsSelected = isSelected || (selectedCores === null && isConfigured);
return (
<button
key={num}
type="button"
onClick={() => setSelectedCores(num === effectiveCores ? null : num)}
className={`btn ${showAsSelected ? "btn-primary" : "btn-secondary"}`}
style={{
minWidth: "3rem",
fontFamily: "var(--font-mono)",
}}
>
{num}
</button>
);
})}
</div>
{selectedCores !== null && selectedCores !== effectiveCores && (
<Form method="post">
<input type="hidden" name="_action" value="set_cpu_cores" />
<input type="hidden" name="num_cores" value={selectedCores} />
<button
type="submit"
className="btn btn-warning"
disabled={isSubmitting}
onClick={(e) => {
if (!confirm(`Set CPU to ${selectedCores} cores and reboot the system?`)) {
e.preventDefault();
}
}}
style={{ width: "100%" }}
>
{isSubmitting ? (
<>
<span className="spinner"></span>
<span>Saving...</span>
</>
) : (
<>
<span>🔄</span>
<span>Save and Reboot ({selectedCores} cores)</span>
</>
)}
</button>
</Form>
)}
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-3)" }}>
Fewer active cores = lower power consumption and heat.
{cpuCores.pi_model?.model_short?.includes("Zero 2") && (
<> Pi Zero 2 W works best with 2 cores for thermal management.</>
)}
{selectedCores !== null && selectedCores !== effectiveCores && (
<strong style={{ display: "block", marginTop: "var(--space-2)", color: "var(--color-warning)" }}>
System will reboot to apply this change.
</strong>
)}
</p>
</div>
</div>
{/* System Actions */}
<div className="card">
<h3 className="card-title">System Actions</h3>
<div className="form-group">
<label className="label">Restart Backend</label>
<Form method="post">
<input type="hidden" name="_action" value="restart" />
<button
@@ -687,6 +1066,7 @@ export default function Settings() {
</p>
</div>
</div>
</div>
)}
</div>
);

View File

@@ -560,3 +560,268 @@ a:hover {
padding: var(--space-4) var(--space-4);
}
}
/* Power Widget */
.power-widget {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius);
background: var(--color-surface);
border: 1px solid var(--color-border);
cursor: default;
transition: all var(--transition-fast);
position: relative;
}
.power-widget:hover {
border-color: var(--color-primary);
}
.power-widget__icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 14px;
}
.power-widget__percentage {
font-size: 0.75rem;
font-weight: 600;
font-family: var(--font-mono);
min-width: 2.5em;
text-align: right;
}
.power-widget__plug-indicator {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-accent);
animation: plug-pulse 2s ease-in-out infinite;
}
.plug-icon {
width: 12px;
height: 12px;
}
@keyframes plug-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
/* Battery Icon */
.battery-icon {
width: 24px;
height: 14px;
color: currentColor;
}
.battery-icon__fill {
transition: width var(--transition);
}
.battery-icon__bolt {
animation: bolt-flash 1s ease-in-out infinite;
}
@keyframes bolt-flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Power Widget States */
.power-widget--loading {
opacity: 0.6;
}
.power-widget--loading .battery-icon__fill {
animation: loading-fill 1.5s ease-in-out infinite;
}
@keyframes loading-fill {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.8; }
}
.power-widget--normal {
color: var(--color-accent);
}
.power-widget--charging {
color: var(--color-accent);
border-color: rgba(0, 255, 136, 0.3);
}
.power-widget--full {
color: var(--color-accent);
border-color: var(--color-accent);
}
.power-widget--low {
color: var(--color-warning);
border-color: rgba(255, 170, 0, 0.3);
}
.power-widget--critical {
color: var(--color-error);
border-color: rgba(255, 0, 85, 0.3);
animation: critical-pulse 1s ease-in-out infinite;
}
@keyframes critical-pulse {
0%, 100% {
border-color: rgba(255, 0, 85, 0.3);
box-shadow: none;
}
50% {
border-color: var(--color-error);
box-shadow: 0 0 10px rgba(255, 0, 85, 0.4);
}
}
/* Mobile adjustments */
@media (max-width: 768px) {
.power-widget {
padding: var(--space-1) var(--space-2);
}
.power-widget__percentage {
font-size: 0.7rem;
}
}
/* ============================================================================
Header Widgets - Notification banners below header
============================================================================ */
.header-widgets {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.widget {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius);
font-size: 0.9rem;
animation: widget-slide-in 0.3s ease-out;
}
@keyframes widget-slide-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.widget-info {
background: rgba(0, 255, 255, 0.1);
border-left: 3px solid var(--color-info);
color: var(--color-info);
}
.widget-warning {
background: rgba(255, 170, 0, 0.1);
border-left: 3px solid var(--color-warning);
color: var(--color-warning);
}
.widget-error {
background: rgba(255, 0, 85, 0.1);
border-left: 3px solid var(--color-error);
color: var(--color-error);
}
.widget-success {
background: rgba(0, 255, 136, 0.1);
border-left: 3px solid var(--color-success);
color: var(--color-success);
}
.widget-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.widget-message {
flex: 1;
line-height: 1.4;
color: var(--color-text-primary);
}
.widget-action {
padding: var(--space-1) var(--space-3);
background: rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
text-decoration: none;
color: inherit;
font-size: 0.85rem;
font-weight: 500;
transition: background var(--transition-fast);
white-space: nowrap;
}
.widget-action:hover {
background: rgba(255, 255, 255, 0.2);
}
.widget-dismiss {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: var(--space-1);
opacity: 0.6;
transition: opacity var(--transition-fast);
flex-shrink: 0;
width: 24px;
height: 24px;
}
.widget-dismiss:hover {
opacity: 1;
}
.dismiss-icon {
width: 12px;
height: 12px;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.header-widgets {
padding: var(--space-2);
}
.widget {
font-size: 0.85rem;
padding: var(--space-2);
gap: var(--space-2);
}
.widget-action {
padding: var(--space-1) var(--space-2);
font-size: 0.8rem;
}
}

8860
app/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"@remix-run/node": "^2.14.0",
"@remix-run/react": "^2.14.0",
"@remix-run/serve": "^2.14.0",
"html5-qrcode": "^2.3.8",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},

View File

@@ -1,7 +1,13 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"~": path.resolve(__dirname, "./app"),
},
},
plugins: [
remix({
future: {
@@ -19,9 +25,10 @@ export default defineConfig({
target: 'http://localhost:8000',
changeOrigin: true,
},
'/sse': {
'/ws': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true, // Enable WebSocket proxying
},
},
},