Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 66 additions & 25 deletions gui/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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) : [];
Expand Down Expand Up @@ -71,7 +92,7 @@ export default function App() {
<span className="title">Magnetics</span>
<span className="sub">3D magnetic-sensor analysis</span>
<span className="spacer" />
<span className="badge">{usingLiveBackend() ? "● live backend" : "○ offline / demo"}</span>
<span className="badge">{badgeText}</span>
<SettingsMenu />
</header>

Expand All @@ -94,7 +115,7 @@ export default function App() {
)}
<div className="rail-section">
<div className="rail-head">
<h3>Shot / machine</h3>
<h3 id="shot-list-label">Shot / machine</h3>
{deletable.length > 0 && (
<button className="rail-clear" title="Delete all fetched shots and their data"
onClick={() => void onClearAll()}>
Expand All @@ -103,34 +124,54 @@ export default function App() {
)}
</div>
{loadingMachines && <div className="placeholder">loading…</div>}
{visibleMachines.map((m) => (
<div
key={m.id}
className={`machine-item${m.id === machine ? " active" : ""}`}
onClick={() => setMachine(m.id)}
>
<div className="machine-main">
<div className="id">{m.label}</div>
{m.note && <div className="note">{m.note}</div>}
<div role="listbox" aria-labelledby="shot-list-label">
{visibleMachines.map((m) => (
// role=option row (not a <button>, so the delete <button> can nest); keyboard-
// reachable via tabIndex + Enter/Space so the a11y listbox pattern still holds.
<div
key={m.id}
role="option"
aria-selected={m.id === machine}
tabIndex={0}
className={`machine-item${m.id === machine ? " active" : ""}`}
onClick={() => setMachine(m.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setMachine(m.id);
}
}}
>
<div className="machine-main">
<div className="id">{m.label}</div>
{m.note && <div className="note">{m.note}</div>}
</div>
{!m.mock && usingLiveBackend() && (
<button className="machine-del" title={`Delete ${m.label} and its data`}
aria-label={`Delete ${m.label}`}
onClick={(e) => { e.stopPropagation(); void onDelete(m.id, m.label); }}>
×
</button>
)}
</div>
{!m.mock && usingLiveBackend() && (
<button className="machine-del" title={`Delete ${m.label} and its data`}
aria-label={`Delete ${m.label}`}
onClick={(e) => { e.stopPropagation(); void onDelete(m.id, m.label); }}>
×
</button>
)}
</div>
))}
))}
</div>
</div>
</aside>

<main className="main">
<div className="tabbar">
<div className="tabbar" role="tablist" aria-label="Analysis views">
{TABS.map((t) => (
<div key={t.id} className={`tab${t.id === tab ? " active" : ""}`} onClick={() => setTab(t.id)}>
<button
key={t.id}
type="button"
role="tab"
aria-selected={t.id === tab}
className={`tab${t.id === tab ? " active" : ""}`}
onClick={() => setTab(t.id)}
>
{t.label}
</div>
</button>
))}
</div>
{!machine ? (
Expand Down
22 changes: 21 additions & 1 deletion gui/web/src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 <div>recovered panel</div>;
}
render(
<ErrorBoundary label="The test view">
<Flaky />
</ErrorBoundary>,
);
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();
});
13 changes: 13 additions & 0 deletions gui/web/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ export default class ErrorBoundary extends Component<Props, State> {
<span style={{ opacity: 0.7 }}>
Try a different shot or reload; other tabs are unaffected.
</span>
<br />
<button
type="button"
onClick={() => 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
</button>
</div>
);
}
Expand Down
20 changes: 16 additions & 4 deletions gui/web/src/components/PullControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -153,7 +157,8 @@ export default function PullControl() {
))}
</select>
)}
<input className="pull-input" value={shot}
<input className="pull-input" value={shot} inputMode="numeric"
aria-label="shot number" aria-invalid={!shotValid}
onChange={(e) => setShot(e.target.value)} placeholder="shot number" />
<select className="pull-input" value={analysis} disabled={!!sensorSet}
onChange={(e) => setAnalysis(e.target.value)}>
Expand Down Expand Up @@ -266,15 +271,22 @@ export default function PullControl() {
placeholder={`${dev.name} SSH password`} autoComplete="current-password" />
</>
)}
<button className="pull-btn" disabled={busy} onClick={pull}>
<button className="pull-btn" disabled={busy || !shotValid} onClick={pull}>
{busy ? `pulling… ${Math.round(frac * 100)}%` : "Pull"}
</button>
{busy && (
<div className="pull-bar">
<div
className="pull-bar"
role="progressbar"
aria-label="pull progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(frac * 100)}
>
<div className="pull-bar-fill" style={{ width: `${frac * 100}%` }} />
</div>
)}
{msg && <div className="note">{msg}</div>}
{msg && <div className="note" role="status" aria-live="polite">{msg}</div>}
</div>
);
}
24 changes: 17 additions & 7 deletions gui/web/src/components/tabs/QuasiStationaryTab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Quasi-stationary view — OWNED BY TEAMMATE A.
// Branch: gui-quasistationary — build here, PR into `gui`.
// VISION §4.1, §7. Summaries: 04_SLCONTOUR_summary2019, 08_Slcontour_II_2023.
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type Plotly from "plotly.js-dist-min";
import { useStore } from "../../store";
import { useNode } from "../../lib/useNode";
Expand Down Expand Up @@ -111,17 +111,21 @@ function CollapseHeader({
open, onToggle, children,
}: { open: boolean; onToggle: () => void; children: React.ReactNode }) {
return (
<div
<button
type="button"
onClick={onToggle}
aria-expanded={open}
style={{
display: "flex", alignItems: "center", gap: 6, cursor: "pointer",
userSelect: "none", marginBottom: open ? 6 : 0,
width: "100%", textAlign: "left",
background: "none", border: "none", padding: 0, fontFamily: "inherit",
}}
className="metrics-title"
>
<span style={{ fontSize: "calc(9px * var(--font-scale))" }}>{open ? "▼" : "▶"}</span>
{children}
</div>
</button>
);
}

Expand Down Expand Up @@ -271,15 +275,20 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) {

// ── Channel checkboxes for signal conditioning ────────────────────
const [enabledChannels, setEnabledChannels] = useState<Set<string>>(new Set());
// Signature of the channel list we last initialized from. Re-seed the enabled set
// only when the list genuinely CHANGES (e.g. switching arrays) — NOT whenever the
// node ref changes with size===0, which used to silently re-check every channel the
// moment the user unchecked them all and re-plotted the same array.
const channelInitRef = useRef<string | null>(null);
useEffect(() => {
if (!signalNode) return;
const pairs = signalNode.meta?.pairs as { channel: string }[] | undefined;
if (pairs && enabledChannels.size === 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time init of the channel set when the node first loads
if (!pairs) return;
const sig = pairs.map(p => p.channel).join("|");
if (channelInitRef.current !== sig) {
channelInitRef.current = sig;
setEnabledChannels(new Set(pairs.map(p => p.channel)));
}
// Only populate when channel list first loads.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signalNode]);

const toggleChannel = useCallback((ch: string) => {
Expand Down Expand Up @@ -871,6 +880,7 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) {
<div className="metrics-title">δB<sub>p</sub>(φ, t) · Contour — θ = 0°</div>
{(["rdbu", "cividis", "viridis"] as const).map(cm => (
<button key={cm} onClick={() => setColormapChoice(cm)}
aria-pressed={colormapChoice === cm}
style={{
fontSize: "calc(10px * var(--font-scale))", padding: "1px 6px", borderRadius: 3, cursor: "pointer",
background: colormapChoice === cm ? "var(--accent)" : "var(--panel)",
Expand Down
Loading
Loading