import { useState, useCallback } from "react"; import { invoke } from "@tauri-apps/api/core"; import type { Node, OtaStrategy, BatchNodeState, OtaResult, } from "../types"; type Mode = "single" | "batch"; interface DiscoveredNode { ip: string; mac: string | null; hostname: string | null; node_id: number; firmware_version: string | null; health: string; last_seen: string; } const STRATEGY_LABELS: Record = { sequential: "Sequential", tdm_safe: "TDM-Safe", parallel: "Parallel", }; const STATE_CONFIG: Record = { queued: { label: "Queued", color: "var(--text-muted)" }, uploading: { label: "Uploading", color: "var(--status-info)" }, rebooting: { label: "Rebooting", color: "var(--status-warning)" }, verifying: { label: "Verifying", color: "var(--status-info)" }, done: { label: "Done", color: "var(--status-online)" }, failed: { label: "Failed", color: "var(--status-error)" }, skipped: { label: "Skipped", color: "var(--text-muted)" }, }; export function OtaUpdate() { const [mode, setMode] = useState("single"); const [nodes, setNodes] = useState([]); const [isDiscovering, setIsDiscovering] = useState(false); const [firmwarePath, setFirmwarePath] = useState(""); const [psk, setPsk] = useState(""); const [error, setError] = useState(null); // Single mode state const [selectedNodeIp, setSelectedNodeIp] = useState(""); const [isSingleUpdating, setIsSingleUpdating] = useState(false); const [singleResult, setSingleResult] = useState(null); // Batch mode state const [selectedBatchIps, setSelectedBatchIps] = useState>(new Set()); const [strategy, setStrategy] = useState("sequential"); const [isBatchUpdating, setIsBatchUpdating] = useState(false); const [batchResults, setBatchResults] = useState([]); const [batchNodeStates, setBatchNodeStates] = useState>(new Map()); const discoverNodes = useCallback(async () => { setIsDiscovering(true); setError(null); try { const result = await invoke("discover_nodes", { timeoutMs: 5000 }); setNodes(result); if (result.length === 0) { setError("No nodes discovered. Ensure ESP32 nodes are online and reachable."); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setIsDiscovering(false); } }, []); const pickFirmware = async () => { try { const { open } = await import("@tauri-apps/plugin-dialog"); const selected = await open({ multiple: false, filters: [ { name: "Firmware Binary", extensions: ["bin"] }, { name: "All Files", extensions: ["*"] }, ], }); if (selected && typeof selected === "string") setFirmwarePath(selected); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }; const startSingleOta = async () => { if (!selectedNodeIp || !firmwarePath) return; setIsSingleUpdating(true); setSingleResult(null); setError(null); try { const result = await invoke("ota_update", { nodeIp: selectedNodeIp, firmwarePath, psk: psk || null, }); setSingleResult(result); } catch (err) { setSingleResult({ node_ip: selectedNodeIp, success: false, previous_version: null, new_version: null, duration_ms: 0, error: err instanceof Error ? err.message : String(err), }); } finally { setIsSingleUpdating(false); } }; const startBatchOta = async () => { const ips = Array.from(selectedBatchIps); if (ips.length === 0 || !firmwarePath) return; setIsBatchUpdating(true); setBatchResults([]); setError(null); // Initialize all nodes as queued const initialStates = new Map(); ips.forEach((ip) => initialStates.set(ip, "queued")); setBatchNodeStates(new Map(initialStates)); // Mark all as uploading while the batch runs ips.forEach((ip) => initialStates.set(ip, "uploading")); setBatchNodeStates(new Map(initialStates)); try { const results = await invoke("batch_ota_update", { nodeIps: ips, firmwarePath, psk: psk || null, }); setBatchResults(results); // Update per-node states from results const finalStates = new Map(); results.forEach((r) => { finalStates.set(r.node_ip, r.success ? "done" : "failed"); }); setBatchNodeStates(finalStates); } catch (err) { setError(err instanceof Error ? err.message : String(err)); // Mark all as failed on total failure const failStates = new Map(); ips.forEach((ip) => failStates.set(ip, "failed")); setBatchNodeStates(failStates); } finally { setIsBatchUpdating(false); } }; const toggleBatchNode = (ip: string) => { setSelectedBatchIps((prev) => { const next = new Set(prev); if (next.has(ip)) next.delete(ip); else next.add(ip); return next; }); }; const toggleAll = () => { if (selectedBatchIps.size === nodes.length) { setSelectedBatchIps(new Set()); } else { setSelectedBatchIps(new Set(nodes.map((n) => n.ip))); } }; const nodeLabel = (n: DiscoveredNode) => { const parts = [n.ip]; if (n.hostname) parts.push(n.hostname); if (n.firmware_version) parts.push(`v${n.firmware_version}`); return parts.join(" - "); }; const canStartSingle = selectedNodeIp !== "" && firmwarePath !== "" && !isSingleUpdating; const canStartBatch = selectedBatchIps.size > 0 && firmwarePath !== "" && !isBatchUpdating; return (

OTA Update

Push firmware updates to ESP32 nodes over the network

{/* Mode Tabs */}
setMode("single")} side="left" /> setMode("batch")} side="right" />
{error &&
{error}
} {/* Node Discovery Section */}

Discovered Nodes

{nodes.length === 0 && !isDiscovering && (

No nodes discovered yet. Click Discover Nodes to scan the network.

)} {nodes.length > 0 && mode === "single" && (
)} {nodes.length > 0 && mode === "batch" && (
{nodes.map((n) => ( ))}

{selectedBatchIps.size} of {nodes.length} nodes selected

)}
{/* Firmware & Config Section */}

Firmware & Configuration

setPsk(e.target.value)} placeholder="Leave blank if none" style={{ width: "100%" }} />
{mode === "batch" && (

{strategy === "sequential" && "Updates nodes one at a time."} {strategy === "tdm_safe" && "Respects TDM slots to avoid overlapping transmissions."} {strategy === "parallel" && "Updates all nodes simultaneously (fastest, highest network load)."}

)}
{/* Action */}
{mode === "single" ? ( ) : ( )}
{/* Single Result */} {mode === "single" && singleResult && (

Result

{singleResult.success ? "Update Successful" : "Update Failed"}
Node: {singleResult.node_ip} {singleResult.previous_version && ` | Previous: v${singleResult.previous_version}`} {singleResult.new_version && ` | New: v${singleResult.new_version}`} {singleResult.duration_ms > 0 && ` | Duration: ${(singleResult.duration_ms / 1000).toFixed(1)}s`}
{singleResult.error && (
{singleResult.error}
)}
)} {/* Batch Progress & Results */} {mode === "batch" && batchNodeStates.size > 0 && (

{isBatchUpdating ? "Update Progress" : "Results"}

{/* Table header */}
Node IP Status Version Duration
{/* Table rows */} {Array.from(batchNodeStates.entries()).map(([ip, state]) => { const result = batchResults.find((r) => r.node_ip === ip); const cfg = STATE_CONFIG[state]; return (
{ip} {result?.previous_version && result?.new_version ? `v${result.previous_version} -> v${result.new_version}` : result?.error ? {result.error} : "--"} {result && result.duration_ms > 0 ? `${(result.duration_ms / 1000).toFixed(1)}s` : "--"}
); })}
{/* Summary */} {!isBatchUpdating && batchResults.length > 0 && (
{batchResults.filter((r) => r.success).length} succeeded {batchResults.filter((r) => !r.success).length} failed {batchResults.length} total
)}
)}
); } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function TabButton({ label, active, onClick, side }: { label: string; active: boolean; onClick: () => void; side: "left" | "right" }) { return ( ); } function StatusDot({ health }: { health: string }) { const color = health === "online" ? "var(--status-online)" : health === "degraded" ? "var(--status-warning)" : health === "offline" ? "var(--status-error)" : "var(--text-muted)"; return ( ); } function NodeStateBadge({ state }: { state: BatchNodeState }) { const cfg = STATE_CONFIG[state]; const isAnimating = state === "uploading" || state === "rebooting" || state === "verifying"; return ( {cfg.label} ); } // --------------------------------------------------------------------------- // Shared styles // --------------------------------------------------------------------------- function bannerStyle(color: string): React.CSSProperties { return { background: `color-mix(in srgb, ${color} 10%, transparent)`, border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`, borderRadius: 6, padding: "var(--space-3) var(--space-4)", marginBottom: "var(--space-4)", fontSize: 13, color, }; } const cardStyle: React.CSSProperties = { background: "var(--bg-surface)", border: "1px solid var(--border)", borderRadius: 8, padding: "var(--space-5)", }; const sectionTitleStyle: React.CSSProperties = { fontSize: 14, fontWeight: 600, color: "var(--text-primary)", margin: 0, fontFamily: "var(--font-sans)", }; const labelStyle: React.CSSProperties = { display: "block", fontSize: 12, fontWeight: 600, color: "var(--text-secondary)", marginBottom: 6, fontFamily: "var(--font-sans)", }; const primaryBtn: React.CSSProperties = { padding: "var(--space-2) 20px", borderRadius: 6, background: "var(--accent)", color: "#fff", fontSize: 13, fontWeight: 600, cursor: "pointer", }; const secondaryBtn: React.CSSProperties = { padding: "var(--space-2) var(--space-4)", border: "1px solid var(--border)", borderRadius: 6, background: "transparent", color: "var(--text-secondary)", fontSize: 13, fontWeight: 500, cursor: "pointer", }; const disabledBtn: React.CSSProperties = { ...primaryBtn, background: "var(--bg-active)", color: "var(--text-muted)", cursor: "not-allowed", }; const linkBtn: React.CSSProperties = { background: "none", border: "none", color: "var(--accent)", cursor: "pointer", padding: 0, fontWeight: 500, }; const tableHeaderRow: React.CSSProperties = { display: "flex", padding: "var(--space-2) var(--space-3)", background: "var(--bg-base)", borderBottom: "1px solid var(--border)", fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.05em", }; const tableRow: React.CSSProperties = { display: "flex", padding: "var(--space-2) var(--space-3)", borderBottom: "1px solid var(--border)", alignItems: "center", }; const tableCell: React.CSSProperties = { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontSize: 13, color: "var(--text-primary)", };