feat(desktop): v0.4.3 - USB device discovery and data source toggle
## Changes - Auto-scan serial ports on Discovery page load (not just Serial tab) - Show USB device hint when no network nodes found but USB devices detected - Add "Flash →" button in Serial Ports table for quick navigation - Fix server stop: proper SIGTERM/SIGKILL with process group handling - Add data source selector on Sensing page (simulate/auto/wifi/esp32) - Fix log viewer scroll (use containerRef.scrollTop instead of scrollIntoView) - Add fallback serial port scanning for macOS when tokio_serial fails ## Fixes - ESP32 USB devices now visible immediately on Discovery page - Server processes properly terminated on stop - Log viewer no longer scrolls entire page Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
a28a875594
commit
21aba2df8d
|
|
@ -276,11 +276,24 @@ fn parse_beacon_response(data: &[u8], addr: SocketAddr) -> Option<DiscoveredNode
|
|||
/// Filters for known ESP32 USB-to-serial chips (CP2102, CH340, FTDI).
|
||||
#[tauri::command]
|
||||
pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
|
||||
let ports = available_ports().map_err(|e| format!("Failed to enumerate ports: {}", e))?;
|
||||
tracing::info!("list_serial_ports called");
|
||||
|
||||
let ports = match available_ports() {
|
||||
Ok(p) => {
|
||||
tracing::info!("Found {} ports from tokio_serial", p.len());
|
||||
p
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to enumerate ports: {}", e);
|
||||
// Fallback: try to list /dev/cu.usb* manually on macOS
|
||||
return list_serial_ports_fallback();
|
||||
}
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for port in ports {
|
||||
tracing::debug!("Processing port: {}", port.port_name);
|
||||
let info = match port.port_type {
|
||||
tokio_serial::SerialPortType::UsbPort(usb_info) => {
|
||||
SerialPortInfo {
|
||||
|
|
@ -294,12 +307,13 @@ pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
|
|||
}
|
||||
_ => {
|
||||
SerialPortInfo {
|
||||
name: port.port_name,
|
||||
name: port.port_name.clone(),
|
||||
vid: None,
|
||||
pid: None,
|
||||
manufacturer: None,
|
||||
serial_number: None,
|
||||
is_esp32_compatible: false,
|
||||
// Mark /dev/cu.usb* ports as potentially compatible
|
||||
is_esp32_compatible: port.port_name.contains("usb"),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -307,9 +321,72 @@ pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
|
|||
result.push(info);
|
||||
}
|
||||
|
||||
// If no ports found via tokio_serial, try fallback
|
||||
if result.is_empty() {
|
||||
tracing::warn!("No ports from tokio_serial, trying fallback");
|
||||
return list_serial_ports_fallback();
|
||||
}
|
||||
|
||||
// Sort ESP32-compatible ports first
|
||||
result.sort_by(|a, b| b.is_esp32_compatible.cmp(&a.is_esp32_compatible));
|
||||
|
||||
tracing::info!("Returning {} serial ports", result.len());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Fallback serial port listing for macOS when tokio_serial fails
|
||||
fn list_serial_ports_fallback() -> Result<Vec<SerialPortInfo>, String> {
|
||||
tracing::info!("Using fallback serial port listing");
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
// List /dev/cu.usb* devices on macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::fs;
|
||||
if let Ok(entries) = fs::read_dir("/dev") {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("cu.usb") {
|
||||
let path = format!("/dev/{}", name);
|
||||
tracing::info!("Fallback found port: {}", path);
|
||||
result.push(SerialPortInfo {
|
||||
name: path,
|
||||
vid: None,
|
||||
pid: None,
|
||||
manufacturer: Some("USB Serial".to_string()),
|
||||
serial_number: None,
|
||||
is_esp32_compatible: true, // Assume USB serial is ESP32
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Linux fallback
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::fs;
|
||||
if let Ok(entries) = fs::read_dir("/dev") {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("ttyUSB") || name.starts_with("ttyACM") {
|
||||
let path = format!("/dev/{}", name);
|
||||
tracing::info!("Fallback found port: {}", path);
|
||||
result.push(SerialPortInfo {
|
||||
name: path,
|
||||
vid: None,
|
||||
pid: None,
|
||||
manufacturer: Some("USB Serial".to_string()),
|
||||
serial_number: None,
|
||||
is_esp32_compatible: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Fallback found {} ports", result.len());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -148,14 +148,15 @@ pub async fn start_server(
|
|||
/// First attempts graceful termination (SIGTERM), then SIGKILL after timeout.
|
||||
#[tauri::command]
|
||||
pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
|
||||
// Extract child process ID and take ownership of child for killing
|
||||
// This releases the lock before any await points
|
||||
let child_id = {
|
||||
let srv = state.server.lock().map_err(|e| e.to_string())?;
|
||||
// Extract child process and take ownership for killing
|
||||
let (child_id, mut child_process) = {
|
||||
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
|
||||
if !srv.running {
|
||||
return Err("Server is not running".into());
|
||||
}
|
||||
srv.pid
|
||||
let pid = srv.pid;
|
||||
let child = srv.child.take(); // Take ownership of child
|
||||
(pid, child)
|
||||
};
|
||||
|
||||
let child_id = match child_id {
|
||||
|
|
@ -163,46 +164,60 @@ pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
|
|||
None => return Err("No server process found".into()),
|
||||
};
|
||||
|
||||
// First try graceful termination
|
||||
tracing::info!("Stopping sensing server with PID {}", child_id);
|
||||
|
||||
// First try graceful termination via SIGTERM
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
libc::kill(child_id as i32, libc::SIGTERM);
|
||||
// Kill the process group (negative PID) to kill all children too
|
||||
let _ = libc::kill(-(child_id as i32), libc::SIGTERM);
|
||||
// Also kill the main process directly
|
||||
let _ = libc::kill(child_id as i32, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait briefly for graceful shutdown (async operation - no lock held)
|
||||
let wait_result: Result<Result<bool, _>, _> = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::task::spawn_blocking({
|
||||
move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
// Check if process is still alive
|
||||
let mut sys = System::new();
|
||||
let pid = Pid::from_u32(child_id);
|
||||
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
|
||||
sys.process(pid).is_some()
|
||||
}
|
||||
})
|
||||
).await;
|
||||
// Wait briefly for graceful shutdown
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Force kill if still running - re-acquire lock
|
||||
let still_running = match wait_result {
|
||||
Ok(Ok(running)) => running,
|
||||
_ => true,
|
||||
// Check if still running
|
||||
let still_running = {
|
||||
let mut sys = System::new();
|
||||
let pid = Pid::from_u32(child_id);
|
||||
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
|
||||
sys.process(pid).is_some()
|
||||
};
|
||||
|
||||
{
|
||||
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
|
||||
// Force kill if still running
|
||||
if still_running {
|
||||
tracing::warn!("Server still running after SIGTERM, sending SIGKILL");
|
||||
|
||||
if still_running {
|
||||
if let Some(ref mut child) = srv.child {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
// SIGKILL the process group and main process
|
||||
let _ = libc::kill(-(child_id as i32), libc::SIGKILL);
|
||||
let _ = libc::kill(child_id as i32, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear state
|
||||
// Also use the child handle if available
|
||||
if let Some(ref mut child) = child_process {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process to actually terminate
|
||||
if let Some(ref mut child) = child_process {
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
// Final verification and cleanup
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Clear state
|
||||
{
|
||||
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
|
||||
srv.running = false;
|
||||
srv.pid = None;
|
||||
srv.http_port = None;
|
||||
|
|
@ -211,6 +226,19 @@ pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
|
|||
srv.child = None;
|
||||
}
|
||||
|
||||
// Verify process is dead
|
||||
let still_alive = {
|
||||
let mut sys = System::new();
|
||||
let pid = Pid::from_u32(child_id);
|
||||
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
|
||||
sys.process(pid).is_some()
|
||||
};
|
||||
|
||||
if still_alive {
|
||||
tracing::error!("Failed to kill server process {}", child_id);
|
||||
return Err(format!("Failed to stop server process {}", child_id));
|
||||
}
|
||||
|
||||
tracing::info!("Stopped sensing server");
|
||||
|
||||
Ok(())
|
||||
|
|
@ -366,6 +394,7 @@ mod tests {
|
|||
log_level: None,
|
||||
bind_address: None,
|
||||
server_path: None,
|
||||
source: Some("simulate".to_string()),
|
||||
};
|
||||
|
||||
assert_eq!(config.http_port, Some(8080));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "RuView Desktop",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"identifier": "net.ruv.ruview",
|
||||
"build": {
|
||||
"frontendDist": "ui/dist",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "ruview-desktop-ui",
|
||||
"private": true,
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ const App: React.FC = () => {
|
|||
const renderPage = () => {
|
||||
switch (activePage) {
|
||||
case "dashboard": return <Dashboard onNavigate={navigateTo} />;
|
||||
case "discovery": return <NetworkDiscovery />;
|
||||
case "discovery": return <NetworkDiscovery onNavigate={navigateTo} />;
|
||||
case "nodes": return <Nodes />;
|
||||
case "flash": return <FlashFirmware />;
|
||||
case "ota": return <OtaUpdate />;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import { invoke } from "@tauri-apps/api/core";
|
|||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import type { HealthStatus, Chip, MeshRole, DiscoveryMethod } from "../types";
|
||||
|
||||
type Page = "dashboard" | "discovery" | "nodes" | "flash" | "ota" | "wasm" | "sensing" | "mesh" | "settings";
|
||||
|
||||
interface NetworkDiscoveryProps {
|
||||
onNavigate?: (page: Page) => void;
|
||||
}
|
||||
|
||||
interface DiscoveredNode {
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
|
|
@ -34,7 +40,7 @@ interface SerialPortInfo {
|
|||
|
||||
type DiscoveryTab = "network" | "serial" | "manual";
|
||||
|
||||
const NetworkDiscovery: React.FC = () => {
|
||||
const NetworkDiscovery: React.FC<NetworkDiscoveryProps> = ({ onNavigate }) => {
|
||||
const [activeTab, setActiveTab] = useState<DiscoveryTab>("network");
|
||||
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
|
||||
const [serialPorts, setSerialPorts] = useState<SerialPortInfo[]>([]);
|
||||
|
|
@ -112,16 +118,22 @@ const NetworkDiscovery: React.FC = () => {
|
|||
}
|
||||
}, [manualIp, manualMac]);
|
||||
|
||||
// Scan both network and serial ports on mount
|
||||
useEffect(() => {
|
||||
scanNetwork();
|
||||
scanSerialPorts();
|
||||
}, []);
|
||||
|
||||
// Also refresh serial ports when switching to that tab
|
||||
useEffect(() => {
|
||||
if (activeTab === "serial") {
|
||||
scanSerialPorts();
|
||||
}
|
||||
}, [activeTab, scanSerialPorts]);
|
||||
|
||||
// Count ESP32-compatible serial ports
|
||||
const esp32SerialCount = serialPorts.filter((p) => p.is_esp32_compatible).length;
|
||||
|
||||
const filteredNodes = nodes.filter((node) => {
|
||||
if (filterOnline && node.health !== "online") return false;
|
||||
if (searchQuery) {
|
||||
|
|
@ -302,21 +314,61 @@ const NetworkDiscovery: React.FC = () => {
|
|||
<div className="card empty-state">
|
||||
<div className="empty-state-icon">{"◉"}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-secondary)" }}>
|
||||
{isScanning ? "Scanning for nodes..." : "No nodes discovered"}
|
||||
{isScanning ? "Scanning for nodes..." : "No network nodes found"}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-muted)",
|
||||
maxWidth: 300,
|
||||
maxWidth: 340,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{isScanning
|
||||
? "Please wait while we search for ESP32 devices on your network."
|
||||
: "Click 'Scan Network' to discover ESP32 devices using mDNS and UDP broadcast."}
|
||||
: "Network discovery uses mDNS/UDP to find ESP32 devices running firmware on WiFi."}
|
||||
</div>
|
||||
|
||||
{/* USB device hint */}
|
||||
{!isScanning && esp32SerialCount > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "var(--space-4)",
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
background: "rgba(56, 139, 253, 0.1)",
|
||||
border: "1px solid rgba(56, 139, 253, 0.3)",
|
||||
borderRadius: 8,
|
||||
maxWidth: 340,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 16 }}>🔌</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--accent)" }}>
|
||||
{esp32SerialCount} USB device{esp32SerialCount > 1 ? "s" : ""} detected!
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 10 }}>
|
||||
Your ESP32 is connected via USB. To flash firmware or configure it:
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab("serial")}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--accent)",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
View Serial Ports →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
|
@ -384,6 +436,7 @@ const NetworkDiscovery: React.FC = () => {
|
|||
<Th>Manufacturer</Th>
|
||||
<Th>VID:PID</Th>
|
||||
<Th>Compatible</Th>
|
||||
<Th>Actions</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -417,6 +470,25 @@ const NetworkDiscovery: React.FC = () => {
|
|||
<span style={{ color: "var(--text-muted)" }}>--</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{port.is_esp32_compatible && onNavigate && (
|
||||
<button
|
||||
onClick={() => onNavigate("flash")}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
background: "var(--accent)",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Flash →
|
||||
</button>
|
||||
)}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useServer } from "../hooks/useServer";
|
||||
import type { SensingUpdate } from "../types";
|
||||
import type { SensingUpdate, DataSource } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log entry model
|
||||
|
|
@ -176,11 +176,12 @@ function LogViewer({
|
|||
paused: boolean;
|
||||
onTogglePause: () => void;
|
||||
}) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paused && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
// Scroll to bottom within the container only (not the page)
|
||||
if (!paused && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [entries, paused]);
|
||||
|
||||
|
|
@ -254,6 +255,7 @@ function LogViewer({
|
|||
|
||||
{/* Log entries */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: 320,
|
||||
overflowY: "auto",
|
||||
|
|
@ -286,7 +288,6 @@ function LogViewer({
|
|||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -301,6 +302,9 @@ export const Sensing: React.FC = () => {
|
|||
const [starting, setStarting] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
// Data source selection
|
||||
const [dataSource, setDataSource] = useState<DataSource>("simulate");
|
||||
|
||||
// Log viewer state
|
||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
|
@ -430,7 +434,7 @@ export const Sensing: React.FC = () => {
|
|||
const handleStart = async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
await start();
|
||||
await start({ source: dataSource });
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
|
|
@ -524,24 +528,61 @@ export const Sensing: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: action button */}
|
||||
<button
|
||||
onClick={isRunning ? handleStop : handleStart}
|
||||
disabled={starting || stopping}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: starting || stopping ? "not-allowed" : "pointer",
|
||||
border: "none",
|
||||
background: isRunning ? "var(--status-error)" : "var(--accent)",
|
||||
color: "#fff",
|
||||
opacity: starting || stopping ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{starting ? "Starting..." : stopping ? "Stopping..." : isRunning ? "Stop Server" : "Start Server"}
|
||||
</button>
|
||||
{/* Right: data source + action button */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
|
||||
{/* Data source selector */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Source:
|
||||
</label>
|
||||
<select
|
||||
value={dataSource}
|
||||
onChange={(e) => setDataSource(e.target.value as DataSource)}
|
||||
disabled={isRunning}
|
||||
style={{
|
||||
padding: "var(--space-1) var(--space-2)",
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
border: "1px solid var(--border)",
|
||||
background: isRunning ? "var(--bg-hover)" : "var(--bg-surface)",
|
||||
color: "var(--text-primary)",
|
||||
cursor: isRunning ? "not-allowed" : "pointer",
|
||||
opacity: isRunning ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<option value="simulate">Simulate</option>
|
||||
<option value="esp32">ESP32 (Real)</option>
|
||||
<option value="wifi">WiFi (RSSI)</option>
|
||||
<option value="auto">Auto Detect</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<button
|
||||
onClick={isRunning ? handleStop : handleStart}
|
||||
disabled={starting || stopping}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: starting || stopping ? "not-allowed" : "pointer",
|
||||
border: "none",
|
||||
background: isRunning ? "var(--status-error)" : "var(--accent)",
|
||||
color: "#fff",
|
||||
opacity: starting || stopping ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{starting ? "Starting..." : stopping ? "Stopping..." : isRunning ? "Stop Server" : "Start Server"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
// Application version - single source of truth
|
||||
export const APP_VERSION = "0.4.2";
|
||||
export const APP_VERSION = "0.4.3";
|
||||
|
|
|
|||
Loading…
Reference in New Issue