feat(FN-3329): refine dashboard TUI system panel and setup wizard UX
- Expand dashboard TUI state/controller wiring for system panel interactions and mouse toggle behavior - Update dashboard TUI app rendering and tests to cover the new panel controls - Improve SetupWizardModal layout and interaction behavior with matching CSS and test updates - Restore docs/research.md wording and add a changeset for the published CLI package Fusion-Task-Id: FN-3329
This commit is contained in:
11
.changeset/dashboard-tui-system-panel-and-mouse-toggle.md
Normal file
11
.changeset/dashboard-tui-system-panel-and-mouse-toggle.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve dashboard TUI System panel discoverability and panel navigation:
|
||||||
|
|
||||||
|
- Default the focused panel to **System** on launch so `Enter` immediately opens the dashboard URL in the browser. Adds an inline hint row (`[Enter] open URL · [c] copy token · [M] mouse on/off`) that is only visible while System is focused.
|
||||||
|
- Add `[c]` shortcut (when System is focused) to copy the auth token to the clipboard, with the same flash + log-line feedback used by the Logs `[c]` copy. Mouse mode normally blocks click-drag selection of the token, so this gives users a keyboard path.
|
||||||
|
- Add `[M]` global shortcut to toggle xterm mouse reporting at runtime. Off → click-drag does native text selection (the only path that works under tmux's `mouse on`, where `Shift+drag` is intercepted by tmux before reaching the terminal). On → wheel scrolling on Logs/Files/Git list panels works as before.
|
||||||
|
- Fix `←`/`→` panel cycling order: `SECTION_ORDER` was `[system, logs, utilities, stats, settings]`, which didn't match the visual layout. Changed to `[system, logs, stats, utilities, settings]` so left/right now matches both the on-screen left-to-right card order and the Tab/Shift+Tab cycle (`PANEL_ORDER`). From Logs going right now lands on Stats; from Settings going left now lands on Utilities.
|
||||||
|
- Updated the help overlay with the new shortcuts.
|
||||||
@@ -208,7 +208,7 @@ See [CLI Reference → `fn research`](./cli-reference.md) for the full command r
|
|||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
All research endpoints are under `/api/research`. The router is registered in `packages/dashboard/src/routes/register-integrated-routers.ts`.
|
All research endpoints are under `/api/research`. The router is registered in `packages/dashboard/src/routes/register-integrated-routes.ts`.
|
||||||
|
|
||||||
### Runs
|
### Runs
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
|||||||
import { render } from "ink-testing-library";
|
import { render } from "ink-testing-library";
|
||||||
import { DashboardApp } from "../app.js";
|
import { DashboardApp } from "../app.js";
|
||||||
import { DashboardTUI } from "../controller.js";
|
import { DashboardTUI } from "../controller.js";
|
||||||
|
import { createInitialState } from "../state.js";
|
||||||
import type { ProjectItem, TaskItem, AgentItem, AgentDetailItem, ModelItem, SettingsValues, TaskDetailData } from "../state.js";
|
import type { ProjectItem, TaskItem, AgentItem, AgentDetailItem, ModelItem, SettingsValues, TaskDetailData } from "../state.js";
|
||||||
|
|
||||||
function newController(): DashboardTUI {
|
function newController(): DashboardTUI {
|
||||||
@@ -233,6 +234,23 @@ describe("DashboardApp smoke", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Default active section", () => {
|
||||||
|
it("createInitialState defaults to system panel", () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
expect(state.activeSection).toBe("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DashboardTUI controller defaults to system panel", () => {
|
||||||
|
const controller = newController();
|
||||||
|
expect(controller.getSnapshot().activeSection).toBe("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createInitialState defaults to mouseEnabled = true", () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
expect(state.mouseEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("DashboardTUI snapshot stability", () => {
|
describe("DashboardTUI snapshot stability", () => {
|
||||||
it("returns the same snapshot reference across reads when state has not changed", () => {
|
it("returns the same snapshot reference across reads when state has not changed", () => {
|
||||||
const controller = newController();
|
const controller = newController();
|
||||||
|
|||||||
@@ -267,6 +267,24 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
|
|||||||
<Text dimColor>Uptime</Text>
|
<Text dimColor>Uptime</Text>
|
||||||
<Text>{formatUptime(Date.now() - info.startTimeMs)}</Text>
|
<Text>{formatUptime(Date.now() - info.startTimeMs)}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
{isFocused && (
|
||||||
|
// Inline hint row — only shown when the System panel is focused.
|
||||||
|
// Discoverability for Enter / [c] / [M] since mouse-mode blocks
|
||||||
|
// click-drag selection of the token. Single <Text wrap="truncate-end">
|
||||||
|
// so on narrow terminals the hint clips cleanly to one row instead
|
||||||
|
// of wrapping into 2-3 lines and overflowing the SYSTEM_HEIGHT
|
||||||
|
// budget. Disappears when the user moves focus.
|
||||||
|
<Box flexShrink={0}>
|
||||||
|
<Text dimColor wrap="truncate-end">
|
||||||
|
<Text color="cyanBright">[Enter]</Text> open URL
|
||||||
|
{info.authToken ? (
|
||||||
|
<Text> · <Text color="cyanBright">[c]</Text> copy token</Text>
|
||||||
|
) : null}
|
||||||
|
{" · "}
|
||||||
|
<Text color="cyanBright">[M]</Text> mouse {state.mouseEnabled ? "on" : "off (drag to select)"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -683,6 +701,10 @@ function HelpOverlay() {
|
|||||||
["[k]", "Kill all vitest processes (Utilities)"],
|
["[k]", "Kill all vitest processes (Utilities)"],
|
||||||
["[v]", "Toggle auto-kill vitest on memory pressure (Utilities)"],
|
["[v]", "Toggle auto-kill vitest on memory pressure (Utilities)"],
|
||||||
["[+/-]", "Adjust vitest kill memory threshold (Utilities)"],
|
["[+/-]", "Adjust vitest kill memory threshold (Utilities)"],
|
||||||
|
["[Enter]", "Open dashboard URL in browser (System)"],
|
||||||
|
["[c]", "Copy auth token to clipboard (System)"],
|
||||||
|
["[M]", "Toggle mouse mode (off → click-drag selects text; works under tmux)"],
|
||||||
|
["[Shift+drag]", "Bypass mouse mode to select text (most native terminals; not tmux)"],
|
||||||
["[↑/↓/k/j]", "Navigate list / log entries"],
|
["[↑/↓/k/j]", "Navigate list / log entries"],
|
||||||
["[Home / G]", "First / last log entry (Logs)"],
|
["[Home / G]", "First / last log entry (Logs)"],
|
||||||
["[Enter/Space]", "Expand log entry (Logs)"],
|
["[Enter/Space]", "Expand log entry (Logs)"],
|
||||||
@@ -748,7 +770,10 @@ function StatusModeGrid({
|
|||||||
// System panel is normally 4 rows (border 2 + 2 content rows so chips wrap to
|
// System panel is normally 4 rows (border 2 + 2 content rows so chips wrap to
|
||||||
// a second line). When auth is on, the Token chip is long enough that it
|
// a second line). When auth is on, the Token chip is long enough that it
|
||||||
// routinely wraps to a third row; bump to 5 so it isn't clipped.
|
// routinely wraps to a third row; bump to 5 so it isn't clipped.
|
||||||
const SYSTEM_HEIGHT = state.systemInfo?.authToken ? 5 : 4;
|
// Add one extra row when the System panel is focused so the inline
|
||||||
|
// [Enter]/[c]/[M] hint isn't clipped against Logs.
|
||||||
|
const systemFocused = focused === "system";
|
||||||
|
const SYSTEM_HEIGHT = (state.systemInfo?.authToken ? 5 : 4) + (systemFocused ? 1 : 0);
|
||||||
const bottomShare = Math.min(10, Math.max(6, Math.floor(middleHeight * 0.35)));
|
const bottomShare = Math.min(10, Math.max(6, Math.floor(middleHeight * 0.35)));
|
||||||
const logsShare = Math.max(1, middleHeight - SYSTEM_HEIGHT - bottomShare);
|
const logsShare = Math.max(1, middleHeight - SYSTEM_HEIGHT - bottomShare);
|
||||||
// LogsPanel chrome: border 2 + title 1 + filter 1 = 4.
|
// LogsPanel chrome: border 2 + title 1 + filter 1 = 4.
|
||||||
@@ -4098,7 +4123,7 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Number keys 1-5 always jump to a status-mode section (matching the
|
// Number keys 1-5 always jump to a status-mode section (matching the
|
||||||
// [1]System [2]Logs [3]Utilities [4]Stats [5]Settings tabs in the
|
// [1]System [2]Logs [3]Stats [4]Utilities [5]Settings tabs in the
|
||||||
// MainHeader). They switch back from interactive mode if needed —
|
// MainHeader). They switch back from interactive mode if needed —
|
||||||
// the interactive views still have letter shortcuts (b/a/g/t).
|
// the interactive views still have letter shortcuts (b/a/g/t).
|
||||||
const sectionForNumber: Record<string, SectionId | undefined> = {
|
const sectionForNumber: Record<string, SectionId | undefined> = {
|
||||||
@@ -4135,6 +4160,44 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System panel: [c] copies the auth token to the clipboard. Mouse mode
|
||||||
|
// is on for log scrolling, which blocks normal click-drag selection of
|
||||||
|
// the token text in the panel — this gives users a keyboard path.
|
||||||
|
if (
|
||||||
|
(input === "c" || input === "C") &&
|
||||||
|
state.activeSection === "system" &&
|
||||||
|
state.systemInfo?.authToken
|
||||||
|
) {
|
||||||
|
const token = state.systemInfo.authToken;
|
||||||
|
void copyToClipboard(token).then((ok) => {
|
||||||
|
controller.flashClipboard(ok);
|
||||||
|
if (ok) {
|
||||||
|
controller.log("Auth token copied to clipboard.", "clipboard");
|
||||||
|
} else {
|
||||||
|
controller.warn(
|
||||||
|
"Clipboard copy failed (no pbcopy/xclip/wl-copy/clip available).",
|
||||||
|
"clipboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [M] toggles mouse reporting. With it off, native click-drag selection
|
||||||
|
// works (the only path that works under tmux's `mouse on`); cost is
|
||||||
|
// wheel-scroll on Logs/Files/Git stops working until re-enabled.
|
||||||
|
if (input === "M") {
|
||||||
|
const next = !state.mouseEnabled;
|
||||||
|
controller.setMouseEnabled(next);
|
||||||
|
controller.log(
|
||||||
|
next
|
||||||
|
? "Mouse mode ON — wheel scrolls panels."
|
||||||
|
: "Mouse mode OFF — click-drag to select text; press M to restore wheel.",
|
||||||
|
"mouse",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Tab / Shift+Tab cycle focused panel
|
// Tab / Shift+Tab cycle focused panel
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
const shift = key.shift;
|
const shift = key.shift;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import { SECTION_ORDER } from "./state.js";
|
|||||||
|
|
||||||
export class DashboardTUI {
|
export class DashboardTUI {
|
||||||
// State fields mirror the original private layout so tests can access them.
|
// State fields mirror the original private layout so tests can access them.
|
||||||
activeSection: SectionId = "logs";
|
activeSection: SectionId = "system";
|
||||||
// Named `logBuffer` to match what captureConsole tests access via
|
// Named `logBuffer` to match what captureConsole tests access via
|
||||||
// `(tui as unknown as { logBuffer: LogRingBuffer }).logBuffer`.
|
// `(tui as unknown as { logBuffer: LogRingBuffer }).logBuffer`.
|
||||||
logBuffer: LogRingBuffer;
|
logBuffer: LogRingBuffer;
|
||||||
@@ -144,6 +144,11 @@ export class DashboardTUI {
|
|||||||
// requested. (See ink#222 / @zenobius/ink-mouse for prior art.)
|
// requested. (See ink#222 / @zenobius/ink-mouse for prior art.)
|
||||||
private wheelHandlers: Set<(direction: "up" | "down") => void> = new Set();
|
private wheelHandlers: Set<(direction: "up" | "down") => void> = new Set();
|
||||||
private mouseStdinListener: ((chunk: Buffer | string) => void) | null = null;
|
private mouseStdinListener: ((chunk: Buffer | string) => void) | null = null;
|
||||||
|
// Tracks the desired mouse-reporting state. Toggled at runtime by the
|
||||||
|
// user (e.g. to allow native click-drag selection under tmux, where
|
||||||
|
// Shift-bypass is intercepted by tmux itself before reaching the
|
||||||
|
// terminal). Default true; only flipped via setMouseEnabled().
|
||||||
|
mouseEnabled: boolean = true;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.logBuffer = new LogRingBuffer();
|
this.logBuffer = new LogRingBuffer();
|
||||||
@@ -192,6 +197,7 @@ export class DashboardTUI {
|
|||||||
updateStatus: this.updateStatus,
|
updateStatus: this.updateStatus,
|
||||||
clipboardFlash: this.clipboardFlash,
|
clipboardFlash: this.clipboardFlash,
|
||||||
remoteStatus: this.remoteStatus,
|
remoteStatus: this.remoteStatus,
|
||||||
|
mouseEnabled: this.mouseEnabled,
|
||||||
};
|
};
|
||||||
return this.cachedSnapshot;
|
return this.cachedSnapshot;
|
||||||
}
|
}
|
||||||
@@ -503,6 +509,25 @@ export class DashboardTUI {
|
|||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle xterm mouse reporting at runtime. When disabled, the terminal
|
||||||
|
// owns the mouse — click-drag does native text selection (the only path
|
||||||
|
// that works under tmux, where Shift-bypass is intercepted by tmux).
|
||||||
|
// Re-enable to restore wheel-driven log/list scrolling.
|
||||||
|
setMouseEnabled(enabled: boolean): void {
|
||||||
|
if (this.mouseEnabled === enabled) return;
|
||||||
|
this.mouseEnabled = enabled;
|
||||||
|
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
|
||||||
|
if (enabled) {
|
||||||
|
process.stdout.write("\x1b[?1000h\x1b[?1006h");
|
||||||
|
this.installMouseListener();
|
||||||
|
} else {
|
||||||
|
this.uninstallMouseListener();
|
||||||
|
process.stdout.write("\x1b[?1006l\x1b[?1000l");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
setLogsWrapEnabled(enabled: boolean): void {
|
setLogsWrapEnabled(enabled: boolean): void {
|
||||||
this.logsWrapEnabled = enabled;
|
this.logsWrapEnabled = enabled;
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|||||||
@@ -358,13 +358,22 @@ export interface DashboardState {
|
|||||||
// `interactiveData.remote` is available. Used to surface tunnel state
|
// `interactiveData.remote` is available. Used to surface tunnel state
|
||||||
// (state/url) globally in the TUI header.
|
// (state/url) globally in the TUI header.
|
||||||
remoteStatus: RemoteStatus | null;
|
remoteStatus: RemoteStatus | null;
|
||||||
|
// Whether xterm mouse reporting is currently enabled. When true, the
|
||||||
|
// controller decodes wheel events into log/list scrolling. When false,
|
||||||
|
// the terminal owns the mouse — needed for native click-drag selection
|
||||||
|
// under tmux, where Shift-bypass is intercepted by tmux itself.
|
||||||
|
mouseEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
|
// Order matches the visual layout in StatusModeGrid: System (top), Logs
|
||||||
|
// (middle), then the bottom row left-to-right (Stats, Utilities, Settings).
|
||||||
|
// Both Tab/Shift+Tab (PANEL_ORDER in app.tsx) and ←/→ (cycleSection) use
|
||||||
|
// this same order so panel navigation matches what the user sees.
|
||||||
|
export const SECTION_ORDER: SectionId[] = ["system", "logs", "stats", "utilities", "settings"];
|
||||||
|
|
||||||
export function createInitialState(): DashboardState {
|
export function createInitialState(): DashboardState {
|
||||||
return {
|
return {
|
||||||
activeSection: "logs",
|
activeSection: "system",
|
||||||
logEntries: [],
|
logEntries: [],
|
||||||
systemInfo: null,
|
systemInfo: null,
|
||||||
taskStats: null,
|
taskStats: null,
|
||||||
@@ -387,5 +396,6 @@ export function createInitialState(): DashboardState {
|
|||||||
updateStatus: null,
|
updateStatus: null,
|
||||||
clipboardFlash: null,
|
clipboardFlash: null,
|
||||||
remoteStatus: null,
|
remoteStatus: null,
|
||||||
|
mouseEnabled: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,6 +292,28 @@
|
|||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setup-wizard-auth-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-wizard-auth-step-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-wizard-auth-step code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.setup-wizard-isolation-option {
|
.setup-wizard-isolation-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface SetupWizardModalProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WizardStep = "manual" | "complete";
|
type WizardStep = "auth" | "manual" | "complete";
|
||||||
type ManualSetupMode = "existing" | "clone";
|
type ManualSetupMode = "existing" | "clone";
|
||||||
|
|
||||||
interface WizardState {
|
interface WizardState {
|
||||||
@@ -42,8 +42,8 @@ export function SetupWizardModal({
|
|||||||
}: SetupWizardModalProps) {
|
}: SetupWizardModalProps) {
|
||||||
const helpUrl = "https://github.com/runfusion/fusion/discussions";
|
const helpUrl = "https://github.com/runfusion/fusion/discussions";
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
const [state, setState] = useState<WizardState>({
|
const [state, setState] = useState<WizardState>(() => ({
|
||||||
step: "manual",
|
step: getAuthToken() ? "manual" : "auth",
|
||||||
manualMode: "existing",
|
manualMode: "existing",
|
||||||
manualPath: "",
|
manualPath: "",
|
||||||
manualCloneUrl: "",
|
manualCloneUrl: "",
|
||||||
@@ -52,7 +52,7 @@ export function SetupWizardModal({
|
|||||||
manualNodeId: "",
|
manualNodeId: "",
|
||||||
isRegistering: false,
|
isRegistering: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
}));
|
||||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||||
const [authTokenInput, setAuthTokenInput] = useState("");
|
const [authTokenInput, setAuthTokenInput] = useState("");
|
||||||
const [storedAuthToken, setStoredAuthToken] = useState(() => getAuthToken());
|
const [storedAuthToken, setStoredAuthToken] = useState(() => getAuthToken());
|
||||||
@@ -116,14 +116,20 @@ export function SetupWizardModal({
|
|||||||
const token = authTokenInput.trim();
|
const token = authTokenInput.trim();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setAuthToken(token);
|
setAuthToken(token);
|
||||||
window.location.reload();
|
setStoredAuthToken(token);
|
||||||
|
setAuthTokenInput("");
|
||||||
|
// If we're on the auth step, advance to the manual step
|
||||||
|
setState((prev) => prev.step === "auth" ? { ...prev, step: "manual" } : prev);
|
||||||
}, [authTokenInput]);
|
}, [authTokenInput]);
|
||||||
|
|
||||||
const handleResetAuthToken = useCallback(() => {
|
const handleResetAuthToken = useCallback(() => {
|
||||||
clearAuthToken();
|
clearAuthToken();
|
||||||
setStoredAuthToken(undefined);
|
setStoredAuthToken(undefined);
|
||||||
setAuthTokenInput("");
|
setAuthTokenInput("");
|
||||||
window.location.reload();
|
}, []);
|
||||||
|
|
||||||
|
const handleSkipAuth = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, step: "manual" }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
@@ -169,6 +175,7 @@ export function SetupWizardModal({
|
|||||||
<span className="setup-wizard-brand-name">Fusion</span>
|
<span className="setup-wizard-brand-name">Fusion</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 id="wizard-title" className="setup-wizard-title">
|
<h2 id="wizard-title" className="setup-wizard-title">
|
||||||
|
{state.step === "auth" && "Set Auth Token"}
|
||||||
{state.step === "manual" && "Welcome to Fusion"}
|
{state.step === "manual" && "Welcome to Fusion"}
|
||||||
{state.step === "complete" && "Setup Complete!"}
|
{state.step === "complete" && "Setup Complete!"}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -186,6 +193,37 @@ export function SetupWizardModal({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="setup-wizard-content">
|
<div className="setup-wizard-content">
|
||||||
|
{/* Auth Step */}
|
||||||
|
{state.step === "auth" && (
|
||||||
|
<div className="setup-wizard-auth-step">
|
||||||
|
<p className="setup-wizard-auth-step-description">
|
||||||
|
This dashboard requires an auth token to communicate with the Fusion daemon.
|
||||||
|
Paste the token below to continue.
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="setup-auth-token">Auth Token</label>
|
||||||
|
<input
|
||||||
|
id="setup-auth-token"
|
||||||
|
type="password"
|
||||||
|
value={authTokenInput}
|
||||||
|
onChange={(e) => setAuthTokenInput(e.target.value)}
|
||||||
|
placeholder="Paste the daemon auth token"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="form-hint">
|
||||||
|
The token was set via the <code>FUSION_DAEMON_TOKEN</code> environment variable when starting the dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{state.error && (
|
||||||
|
<div className="wizard-error" role="alert">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Manual Step */}
|
{/* Manual Step */}
|
||||||
{state.step === "manual" && (
|
{state.step === "manual" && (
|
||||||
<div className="setup-wizard-manual">
|
<div className="setup-wizard-manual">
|
||||||
@@ -339,10 +377,10 @@ export function SetupWizardModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setup-auth-token">Browser Auth Token</label>
|
<label htmlFor="advanced-auth-token">Browser Auth Token</label>
|
||||||
<div className="setup-wizard-auth-token">
|
<div className="setup-wizard-auth-token">
|
||||||
<input
|
<input
|
||||||
id="setup-auth-token"
|
id="advanced-auth-token"
|
||||||
type="password"
|
type="password"
|
||||||
value={authTokenInput}
|
value={authTokenInput}
|
||||||
onChange={(e) => setAuthTokenInput(e.target.value)}
|
onChange={(e) => setAuthTokenInput(e.target.value)}
|
||||||
@@ -372,8 +410,8 @@ export function SetupWizardModal({
|
|||||||
</div>
|
</div>
|
||||||
<p className="form-hint">
|
<p className="form-hint">
|
||||||
{storedAuthToken
|
{storedAuthToken
|
||||||
? "A token is already stored in this browser. Updating or resetting it will reload the page."
|
? "A token is already stored in this browser. You can update or reset it below."
|
||||||
: "Store a token in this browser for authenticated dashboard requests, then reload the page."}
|
: "No token is stored. Use the auth prompt at the top of the wizard, or set one here."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,6 +451,23 @@ export function SetupWizardModal({
|
|||||||
>
|
>
|
||||||
Need help?
|
Need help?
|
||||||
</a>
|
</a>
|
||||||
|
{state.step === "auth" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={handleSkipAuth}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSetAuthToken}
|
||||||
|
disabled={authTokenInput.trim().length === 0}
|
||||||
|
>
|
||||||
|
<span>Set Token & Continue</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{state.step === "manual" && (
|
{state.step === "manual" && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|||||||
@@ -66,12 +66,36 @@ describe("SetupWizardModal", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGetAuthToken.mockReturnValue(undefined);
|
// Default: auth token is stored so wizard starts on the project form step.
|
||||||
|
// Tests for the auth step explicitly unset this.
|
||||||
|
mockGetAuthToken.mockReturnValue("stored-token");
|
||||||
reloadMock = vi.fn();
|
reloadMock = vi.fn();
|
||||||
vi.stubGlobal("location", { ...window.location, reload: reloadMock });
|
vi.stubGlobal("location", { ...window.location, reload: reloadMock });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders with welcome message", () => {
|
it("renders with auth step when no token is stored", () => {
|
||||||
|
mockGetAuthToken.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SetupWizardModal
|
||||||
|
onProjectRegistered={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// No token stored → shows auth step first
|
||||||
|
expect(screen.getByText("Set Auth Token")).toBeDefined();
|
||||||
|
expect(screen.getByText("Skip")).toBeDefined();
|
||||||
|
expect(screen.getByText("Set Token & Continue")).toBeDefined();
|
||||||
|
expect(screen.getByRole("link", { name: "Need help?" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://github.com/runfusion/fusion/discussions"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips auth step and shows project form when auth token is already stored", () => {
|
||||||
|
mockGetAuthToken.mockReturnValue("stored-token");
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<SetupWizardModal
|
<SetupWizardModal
|
||||||
onProjectRegistered={vi.fn()}
|
onProjectRegistered={vi.fn()}
|
||||||
@@ -83,10 +107,46 @@ describe("SetupWizardModal", () => {
|
|||||||
expect(screen.getByText("Project Name")).toBeDefined();
|
expect(screen.getByText("Project Name")).toBeDefined();
|
||||||
expect(screen.getByLabelText("Fusion logo")).toBeDefined();
|
expect(screen.getByLabelText("Fusion logo")).toBeDefined();
|
||||||
expect(screen.getByText("Advanced settings")).toBeDefined();
|
expect(screen.getByText("Advanced settings")).toBeDefined();
|
||||||
expect(screen.getByRole("link", { name: "Need help?" })).toHaveAttribute(
|
});
|
||||||
"href",
|
|
||||||
"https://github.com/runfusion/fusion/discussions"
|
it("auth step advances to project form after setting token", () => {
|
||||||
|
mockGetAuthToken.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SetupWizardModal
|
||||||
|
onProjectRegistered={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// On auth step initially
|
||||||
|
expect(screen.getByText("Set Auth Token")).toBeDefined();
|
||||||
|
|
||||||
|
// Set a token
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Paste the daemon auth token"), {
|
||||||
|
target: { value: "my-daemon-token" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText("Set Token & Continue"));
|
||||||
|
|
||||||
|
expect(mockSetAuthToken).toHaveBeenCalledWith("my-daemon-token");
|
||||||
|
// Should now show the project form
|
||||||
|
expect(screen.getByText("Welcome to Fusion")).toBeDefined();
|
||||||
|
expect(screen.getByText("Project Name")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auth step can be skipped", () => {
|
||||||
|
mockGetAuthToken.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SetupWizardModal
|
||||||
|
onProjectRegistered={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Set Auth Token")).toBeDefined();
|
||||||
|
fireEvent.click(screen.getByText("Skip"));
|
||||||
|
expect(screen.getByText("Welcome to Fusion")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has DirectoryPicker for path selection", () => {
|
it("has DirectoryPicker for path selection", () => {
|
||||||
@@ -409,6 +469,8 @@ describe("SetupWizardModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows a set token action when no browser auth token is stored", () => {
|
it("shows a set token action when no browser auth token is stored", () => {
|
||||||
|
mockGetAuthToken.mockReturnValue(undefined);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<SetupWizardModal
|
<SetupWizardModal
|
||||||
onProjectRegistered={vi.fn()}
|
onProjectRegistered={vi.fn()}
|
||||||
@@ -416,6 +478,9 @@ describe("SetupWizardModal", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Skip auth step to get to the project form
|
||||||
|
fireEvent.click(screen.getByText("Skip"));
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Advanced settings"));
|
fireEvent.click(screen.getByText("Advanced settings"));
|
||||||
|
|
||||||
expect(screen.getByLabelText("Browser Auth Token")).toBeDefined();
|
expect(screen.getByLabelText("Browser Auth Token")).toBeDefined();
|
||||||
@@ -423,7 +488,7 @@ describe("SetupWizardModal", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Reset token" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Reset token" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores a browser auth token and reloads the page", () => {
|
it("stores a browser auth token without reloading", () => {
|
||||||
render(
|
render(
|
||||||
<SetupWizardModal
|
<SetupWizardModal
|
||||||
onProjectRegistered={vi.fn()}
|
onProjectRegistered={vi.fn()}
|
||||||
@@ -435,10 +500,10 @@ describe("SetupWizardModal", () => {
|
|||||||
fireEvent.change(screen.getByLabelText("Browser Auth Token"), {
|
fireEvent.change(screen.getByLabelText("Browser Auth Token"), {
|
||||||
target: { value: "daemon-token" },
|
target: { value: "daemon-token" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Set token" }));
|
fireEvent.click(screen.getByRole("button", { name: "Update token" }));
|
||||||
|
|
||||||
expect(mockSetAuthToken).toHaveBeenCalledWith("daemon-token");
|
expect(mockSetAuthToken).toHaveBeenCalledWith("daemon-token");
|
||||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
expect(reloadMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows reset when a browser auth token is already stored", () => {
|
it("shows reset when a browser auth token is already stored", () => {
|
||||||
@@ -457,7 +522,7 @@ describe("SetupWizardModal", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Reset token" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Reset token" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets the stored browser auth token and reloads the page", () => {
|
it("resets the stored browser auth token without reloading", () => {
|
||||||
mockGetAuthToken.mockReturnValue("stored-token");
|
mockGetAuthToken.mockReturnValue("stored-token");
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -471,7 +536,7 @@ describe("SetupWizardModal", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Reset token" }));
|
fireEvent.click(screen.getByRole("button", { name: "Reset token" }));
|
||||||
|
|
||||||
expect(mockClearAuthToken).toHaveBeenCalledTimes(1);
|
expect(mockClearAuthToken).toHaveBeenCalledTimes(1);
|
||||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
expect(reloadMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("node selector", () => {
|
describe("node selector", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user