diff --git a/gui/web/src/App.tsx b/gui/web/src/App.tsx
index b5f4aff..0c2cda9 100644
--- a/gui/web/src/App.tsx
+++ b/gui/web/src/App.tsx
@@ -22,8 +22,18 @@ const TABS: { id: TabId; label: string }[] = [
];
export default function App() {
- const { machines, machine, devices, device, tab, loadingMachines, init, setMachine, setDevice, setTab } =
- useStore();
+ // Per-field selectors: subscribing to the whole store re-rendered the entire
+ // shell on every cursor scrub (setCursorMs); App doesn't read cursorMs.
+ const machines = useStore((s) => s.machines);
+ const machine = useStore((s) => s.machine);
+ const devices = useStore((s) => s.devices);
+ const device = useStore((s) => s.device);
+ const tab = useStore((s) => s.tab);
+ const loadingMachines = useStore((s) => s.loadingMachines);
+ const init = useStore((s) => s.init);
+ const setMachine = useStore((s) => s.setMachine);
+ const setDevice = useStore((s) => s.setDevice);
+ const setTab = useStore((s) => s.setTab);
const removeMachine = useStore((s) => s.removeMachine);
const clearMachines = useStore((s) => s.clearMachines);
@@ -42,6 +52,17 @@ export default function App() {
}
}, [device, machines, devices]); // eslint-disable-line react-hooks/exhaustive-deps
+ // Honest data-source badge: a live backend with zero fetched shots still serves
+ // the mock machines, so key off the SELECTED machine's `mock` flag (from the
+ // backend), falling back to usingLiveBackend only when the flag is absent.
+ const selected = machines.find((m) => m.id === machine);
+ const mock = selected?.mock ?? !usingLiveBackend();
+ const badgeText = !mock
+ ? "● live backend"
+ : usingLiveBackend()
+ ? "○ demo data (no shots fetched)"
+ : "○ offline / demo";
+
// Deletable = real fetched shots (mock demo machines have nothing on disk), scoped
// to the visible device. Only offer delete controls against a live backend.
const deletable = usingLiveBackend() ? visibleMachines.filter((m) => !m.mock) : [];
@@ -71,7 +92,7 @@ export default function App() {
Magnetics
3D magnetic-sensor analysis
- {usingLiveBackend() ? "● live backend" : "○ offline / demo"}
+ {badgeText}
@@ -94,7 +115,7 @@ export default function App() {
)}
-
Shot / machine
+ Shot / machine
{deletable.length > 0 && (
{loadingMachines &&
loading…
}
- {visibleMachines.map((m) => (
-
setMachine(m.id)}
- >
-
-
{m.label}
- {m.note &&
{m.note}
}
+
+ {visibleMachines.map((m) => (
+ // role=option row (not a
- ))}
+ ))}
+
-
+
{TABS.map((t) => (
-
setTab(t.id)}>
+ setTab(t.id)}
+ >
{t.label}
-
+
))}
{!machine ? (
diff --git a/gui/web/src/components/ErrorBoundary.test.tsx b/gui/web/src/components/ErrorBoundary.test.tsx
index 42529e0..c893c34 100644
--- a/gui/web/src/components/ErrorBoundary.test.tsx
+++ b/gui/web/src/components/ErrorBoundary.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import ErrorBoundary from "./ErrorBoundary";
@@ -28,3 +28,23 @@ test("renders children unchanged when nothing throws", () => {
);
expect(screen.getByText("healthy panel")).toBeInTheDocument();
});
+
+test("Retry clears the error and recovers in place when the child stops throwing", () => {
+ let shouldThrow = true;
+ function Flaky(): React.ReactElement {
+ if (shouldThrow) throw new Error("transient payload");
+ return
recovered panel
;
+ }
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText(/failed to render/i)).toBeInTheDocument();
+
+ // The underlying condition is fixed; Retry should re-render children in place.
+ shouldThrow = false;
+ fireEvent.click(screen.getByRole("button", { name: /retry/i }));
+ expect(screen.getByText("recovered panel")).toBeInTheDocument();
+ expect(screen.queryByText(/failed to render/i)).not.toBeInTheDocument();
+});
diff --git a/gui/web/src/components/ErrorBoundary.tsx b/gui/web/src/components/ErrorBoundary.tsx
index 7b26287..00d03b0 100644
--- a/gui/web/src/components/ErrorBoundary.tsx
+++ b/gui/web/src/components/ErrorBoundary.tsx
@@ -48,6 +48,19 @@ export default class ErrorBoundary extends Component
{
Try a different shot or reload; other tabs are unaffected.
+
+ this.setState({ error: null })}
+ style={{
+ marginTop: 10, padding: "4px 12px", cursor: "pointer",
+ background: "var(--panel-2)", color: "var(--text)",
+ border: "1px solid var(--border-2)", borderRadius: 4,
+ fontFamily: "inherit", fontSize: "inherit",
+ }}
+ >
+ Retry
+
);
}
diff --git a/gui/web/src/components/PullControl.tsx b/gui/web/src/components/PullControl.tsx
index 31664e4..c1124f6 100644
--- a/gui/web/src/components/PullControl.tsx
+++ b/gui/web/src/components/PullControl.tsx
@@ -83,6 +83,10 @@ export default function PullControl() {
if (!usingLiveBackend()) return null;
const needsCreds = backend === "remote" || backend === "mdsthin";
const num = (s: string) => (s.trim() === "" ? undefined : Number(s));
+ // Gate the Pull button: clearing the field or typing non-digits would otherwise
+ // POST shot 0/NaN to /api/fetch.
+ const shotNum = Number(shot);
+ const shotValid = Number.isFinite(shotNum) && shotNum > 0;
async function pull() {
setBusy(true);
@@ -153,7 +157,8 @@ export default function PullControl() {
))}
)}
- setShot(e.target.value)} placeholder="shot number" />