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:
@@ -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;
|
||||
|
||||
|
||||
634
app/frontend/app/components/DeviceSelector.tsx
Normal file
634
app/frontend/app/components/DeviceSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
app/frontend/app/components/HeaderWidgets.tsx
Normal file
152
app/frontend/app/components/HeaderWidgets.tsx
Normal 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;
|
||||
243
app/frontend/app/components/PowerWidget.tsx
Normal file
243
app/frontend/app/components/PowerWidget.tsx
Normal 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;
|
||||
267
app/frontend/app/components/QRScanner.tsx
Normal file
267
app/frontend/app/components/QRScanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
279
app/frontend/app/hooks/useWebSocket.ts
Normal file
279
app/frontend/app/hooks/useWebSocket.ts
Normal 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;
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
8860
app/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user