feat(FN-2729): merge fusion/fn-2729
- fix(FN-2729): silence ansi strip lint warning - test(FN-2729): complete Step 10 — cover node health indicators - feat(FN-2729): complete Step 9 — colorize CLI node status output - feat(FN-2729): complete Step 8 — use NodeHealthDot in settings routing status - test(FN-2729): align ListView node override option expectations - feat(FN-2729): complete Step 7 — add bulk node status indicators - fix(engine): prevent auto-merge cooldown loop on unresolvable conflicts - feat(FN-2911): improve chat attachment compose and preview UX - feat(FN-2921): merge fusion/fn-2921 - feat(FN-2909): merge fusion/fn-2909 - feat(FN-2914): merge fusion/fn-2914 - feat(FN-2902): merge fusion/fn-2902 - chore(release): v0.8.3 - Update changeset - fix(tui): swap 3/4 panel hotkeys so they match the help text - fix(tui): always show auth token and report accurate macOS memory usage Fusion-Task-Id: FN-2729
This commit is contained in:
@@ -245,6 +245,7 @@ describe("node commands", () => {
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].name).toBe("json-node");
|
||||
expect(parsed[0].apiKey).toBe("none");
|
||||
expect(output).not.toMatch(/\x1b\[[0-9;]*m/);
|
||||
});
|
||||
|
||||
it("runNodeList masks API keys in JSON output", async () => {
|
||||
@@ -277,9 +278,18 @@ describe("node commands", () => {
|
||||
await runNodeList();
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("● online");
|
||||
expect(output).toContain("○ offline");
|
||||
expect(output).toContain("✕ error");
|
||||
expect(output).toContain("\x1b[32m●\x1b[0m \x1b[32monline\x1b[0m");
|
||||
expect(output).toContain("\x1b[31m○\x1b[0m \x1b[31moffline\x1b[0m");
|
||||
expect(output).toContain("\x1b[31m✕\x1b[0m \x1b[31merror\x1b[0m");
|
||||
});
|
||||
|
||||
it("runNodeList colorizes connecting status", async () => {
|
||||
mockListNodes.mockResolvedValue([makeNode({ name: "connecting-node", status: "connecting" })]);
|
||||
|
||||
await runNodeList();
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("\x1b[33m◐\x1b[0m \x1b[33mconnecting\x1b[0m");
|
||||
});
|
||||
|
||||
// ── runNodeConnect Tests ─────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { CentralCore, type NodeConfig } from "@fusion/core";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
const GREEN = "\x1b[32m";
|
||||
const RED = "\x1b[31m";
|
||||
const YELLOW = "\x1b[33m";
|
||||
const GRAY = "\x1b[90m";
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
// ── Options Interfaces ───────────────────────────────────────────────────────
|
||||
|
||||
/** Options for node list command. */
|
||||
@@ -133,19 +139,51 @@ export function formatLastActivity(timestamp?: string | null): string {
|
||||
*/
|
||||
function getStatusIndicator(status: string): string {
|
||||
switch (status) {
|
||||
case "online": return "●";
|
||||
case "offline": return "○";
|
||||
case "error": return "✕";
|
||||
case "connecting": return "◐";
|
||||
default: return "○";
|
||||
case "online":
|
||||
return `${GREEN}●${RESET}`;
|
||||
case "offline":
|
||||
return `${RED}○${RESET}`;
|
||||
case "error":
|
||||
return `${RED}✕${RESET}`;
|
||||
case "connecting":
|
||||
return `${YELLOW}◐${RESET}`;
|
||||
default:
|
||||
return `${GRAY}○${RESET}`;
|
||||
}
|
||||
}
|
||||
|
||||
function colorizeStatusText(status: string): string {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return `${GREEN}${status}${RESET}`;
|
||||
case "offline":
|
||||
case "error":
|
||||
return `${RED}${status}${RESET}`;
|
||||
case "connecting":
|
||||
return `${YELLOW}${status}${RESET}`;
|
||||
default:
|
||||
return `${GRAY}${status}${RESET}`;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp("\\u001b\\[[0-9;]*m", "g");
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function visualPadEnd(text: string, minWidth: number): string {
|
||||
const visibleLength = stripAnsi(text).length;
|
||||
if (visibleLength >= minWidth) return text;
|
||||
return `${text}${" ".repeat(minWidth - visibleLength)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color-coded status string.
|
||||
*/
|
||||
function formatStatus(status: string): string {
|
||||
return `${getStatusIndicator(status)} ${status}`;
|
||||
return `${getStatusIndicator(status)} ${colorizeStatusText(status)}`;
|
||||
}
|
||||
|
||||
// ── Core Command Functions ──────────────────────────────────────────────────
|
||||
@@ -201,7 +239,7 @@ export async function runNodeList(options: NodeListOptions = {}): Promise<void>
|
||||
for (const node of sorted) {
|
||||
const name = node.name.padEnd(16);
|
||||
const type = node.type.padEnd(8);
|
||||
const statusStr = formatStatus(node.status).padEnd(12);
|
||||
const statusStr = visualPadEnd(formatStatus(node.status), 12);
|
||||
const max = String(node.maxConcurrent).padStart(3);
|
||||
|
||||
if (hasMetrics && node.systemMetrics) {
|
||||
@@ -527,7 +565,7 @@ export async function runMeshStatus(options: MeshStatusOptions = {}): Promise<vo
|
||||
for (const node of sorted) {
|
||||
const name = node.name.padEnd(16);
|
||||
const type = node.type.padEnd(8);
|
||||
const statusStr = formatStatus(node.status).padEnd(12);
|
||||
const statusStr = visualPadEnd(formatStatus(node.status), 12);
|
||||
const url = node.type === "remote" ? (node.url ?? "-") : "(local)";
|
||||
console.log(` ${name} ${type} ${statusStr} ${url}`);
|
||||
}
|
||||
|
||||
@@ -514,39 +514,6 @@
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.node-selector-option-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-selector-option-dot--local {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.node-selector-option-dot--online {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.node-selector-option-dot--offline {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.node-selector-option-dot--connecting {
|
||||
background: var(--triage);
|
||||
animation: node-connecting-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.node-selector-option-dot--error {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
@keyframes node-connecting-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.node-selector-option-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ProjectInfo } from "../api";
|
||||
import type { NodeConfig, ProjectStatus } from "@fusion/core";
|
||||
import { fetchScripts } from "../api";
|
||||
import { NodeStatusIndicator } from "./NodeStatusIndicator";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
||||
|
||||
@@ -939,7 +940,7 @@ export function Header({
|
||||
aria-selected={!isRemote}
|
||||
data-testid="node-option-local"
|
||||
>
|
||||
<span className="node-selector-option-dot node-selector-option-dot--local" />
|
||||
<NodeHealthDot status="online" compact />
|
||||
<span className="node-selector-option-label">Local</span>
|
||||
</button>
|
||||
|
||||
@@ -956,7 +957,7 @@ export function Header({
|
||||
aria-selected={currentNode?.id === node.id}
|
||||
data-testid={`node-option-${node.id}`}
|
||||
>
|
||||
<span className={`node-selector-option-dot node-selector-option-dot--${node.status}`} />
|
||||
<NodeHealthDot status={node.status} compact />
|
||||
<span className="node-selector-option-label">{node.name}</span>
|
||||
<span className="node-selector-option-status">{node.status}</span>
|
||||
</button>
|
||||
|
||||
@@ -38,6 +38,13 @@ function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
return "Offline";
|
||||
}
|
||||
|
||||
function getNodeStatusSymbol(status: NodeInfo["status"]): string {
|
||||
if (status === "online") return "●";
|
||||
if (status === "connecting") return "◐";
|
||||
if (status === "error") return "✕";
|
||||
return "○";
|
||||
}
|
||||
|
||||
function readVisibleColumns(projectId?: string): Set<ListColumn> {
|
||||
try {
|
||||
const saved = getScopedItem("kb-dashboard-list-columns", projectId);
|
||||
@@ -772,7 +779,9 @@ export function ListView({
|
||||
<option value="__no_change__">No change</option>
|
||||
<option value="">Use project default</option>
|
||||
{availableNodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>{`${node.name || node.id} (${getNodeStatusLabel(node.status)})`}</option>
|
||||
<option key={node.id} value={node.id}>
|
||||
{`${getNodeStatusSymbol(node.status)} ${node.name || node.id} (${getNodeStatusLabel(node.status)})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
19
packages/dashboard/app/components/NodeHealthDot.css
Normal file
19
packages/dashboard/app/components/NodeHealthDot.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.node-health-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.node-health-dot--compact {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.node-health-dot--compact .status-dot {
|
||||
width: var(--space-xs);
|
||||
height: var(--space-xs);
|
||||
}
|
||||
|
||||
.node-health-dot__label {
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
28
packages/dashboard/app/components/NodeHealthDot.tsx
Normal file
28
packages/dashboard/app/components/NodeHealthDot.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { NodeStatus } from "@fusion/core";
|
||||
import "./NodeHealthDot.css";
|
||||
|
||||
export interface NodeHealthDotProps {
|
||||
status: NodeStatus;
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function toStatusLabel(status: NodeStatus): string {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
export function NodeHealthDot({ status, showLabel = false, className, compact = false }: NodeHealthDotProps) {
|
||||
const label = toStatusLabel(status);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`node-health-dot${compact ? " node-health-dot--compact" : ""}${className ? ` ${className}` : ""}`}
|
||||
title={label}
|
||||
aria-label={`Node status: ${label}`}
|
||||
>
|
||||
<span className={`status-dot status-dot--${status}`} />
|
||||
{showLabel ? <span className="node-health-dot__label">{label}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -10,13 +10,6 @@ interface ProjectNodeSelectorProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<NodeInfo["status"], string> = {
|
||||
online: "🟢",
|
||||
offline: "🔴",
|
||||
connecting: "🟡",
|
||||
error: "🔴",
|
||||
};
|
||||
|
||||
export function ProjectNodeSelector({
|
||||
projectId,
|
||||
currentNodeId,
|
||||
@@ -48,9 +41,10 @@ export function ProjectNodeSelector({
|
||||
<option
|
||||
key={node.id}
|
||||
value={node.id}
|
||||
title={`Status: ${node.status}`}
|
||||
className={node.status === "offline" || node.status === "error" ? "project-node-selector__option--dim" : ""}
|
||||
>
|
||||
{STATUS_DOT[node.status]} {node.name} ({node.type})
|
||||
{node.name} ({node.type}) — {node.status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import type { NodeInfo } from "../api";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
|
||||
const STORAGE_KEY = "kb-quick-entry-text";
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
@@ -70,13 +71,6 @@ function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
return "Offline";
|
||||
}
|
||||
|
||||
function getNodeStatusClass(status: NodeInfo["status"]): string {
|
||||
if (status === "online") return "quick-entry-node-status--online";
|
||||
if (status === "connecting") return "quick-entry-node-status--connecting";
|
||||
if (status === "error") return "quick-entry-node-status--error";
|
||||
return "quick-entry-node-status--offline";
|
||||
}
|
||||
|
||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||
return provider && modelId ? `${provider}/${modelId}` : "";
|
||||
}
|
||||
@@ -1400,9 +1394,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const selectedNode = nodes.find((node) => node.id === nodeId);
|
||||
if (!selectedNode) return null;
|
||||
return (
|
||||
<span className={`quick-entry-node-status ${getNodeStatusClass(selectedNode.status)}`}>
|
||||
<span className="quick-entry-node-status__dot" aria-hidden="true" />
|
||||
{getNodeStatusLabel(selectedNode.status)}
|
||||
<span className="quick-entry-node-status">
|
||||
<NodeHealthDot status={selectedNode.status} showLabel />
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -13,13 +13,6 @@ interface RoutingTabProps {
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<NodeInfo["status"], string> = {
|
||||
online: "🟢",
|
||||
offline: "🔴",
|
||||
connecting: "🟡",
|
||||
error: "🔴",
|
||||
};
|
||||
|
||||
type RoutingSettings = Settings & {
|
||||
defaultNodeId?: string;
|
||||
unavailableNodePolicy?: "block" | "fallback-local";
|
||||
@@ -82,7 +75,7 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
||||
|
||||
const effectiveNode = effectiveNodeId ? nodesById.get(effectiveNodeId) : undefined;
|
||||
const effectiveNodeName = effectiveNode
|
||||
? `${STATUS_DOT[effectiveNode.status]} ${effectiveNode.name} (${effectiveNode.type})`
|
||||
? `${effectiveNode.name} (${effectiveNode.type}) — ${effectiveNode.status}`
|
||||
: effectiveNodeId
|
||||
? `${effectiveNodeId} (node unavailable or unknown)`
|
||||
: "Local (no routing configured)";
|
||||
@@ -180,8 +173,8 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
||||
>
|
||||
<option value="">Use project default</option>
|
||||
{sortedNodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{STATUS_DOT[node.status]} {node.name} ({node.type})
|
||||
<option key={node.id} value={node.id} title={`Status: ${node.status}`}>
|
||||
{node.name} ({node.type}) — {node.status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1448,35 +1448,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings-node-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.settings-node-status--online .settings-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.settings-node-status--offline .settings-node-status__dot,
|
||||
.settings-node-status--error .settings-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.settings-node-status--connecting .settings-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
/* Node Routing section */
|
||||
.settings-node-routing-note {
|
||||
background: var(--surface);
|
||||
@@ -1498,109 +1469,6 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.settings-node-status--online .settings-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.settings-node-status--offline .settings-node-status__dot,
|
||||
.settings-node-status--error .settings-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.settings-node-status--connecting .settings-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.settings-node-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 12px;
|
||||
.settings-node-status__prefix {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.settings-node-status--online .settings-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.settings-node-status--offline .settings-node-status__dot,
|
||||
.settings-node-status--error .settings-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.settings-node-status--connecting .settings-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.settings-node-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.settings-node-status--online .settings-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.settings-node-status--offline .settings-node-status__dot,
|
||||
.settings-node-status--error .settings-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.settings-node-status--connecting .settings-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.settings-node-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.settings-node-status--online .settings-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.settings-node-status--offline .settings-node-status__dot,
|
||||
.settings-node-status--error .settings-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.settings-node-status--connecting .settings-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPr
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub star count — fetched once per session, cached in localStorage (1 h).
|
||||
@@ -51,13 +52,6 @@ function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error
|
||||
return "Offline";
|
||||
}
|
||||
|
||||
function getNodeStatusClass(status: "online" | "offline" | "connecting" | "error"): string {
|
||||
if (status === "online") return "settings-node-status--online";
|
||||
if (status === "connecting") return "settings-node-status--connecting";
|
||||
if (status === "error") return "settings-node-status--error";
|
||||
return "settings-node-status--offline";
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the user already clicked the "Star on GitHub" button at any point in
|
||||
* the past? Used to permanently hide the button afterward — clicking opens
|
||||
@@ -2701,9 +2695,9 @@ export function SettingsModal({
|
||||
const selectedNode = nodes.find((node) => node.id === form.defaultNodeId);
|
||||
if (!selectedNode) return null;
|
||||
return (
|
||||
<div className={`settings-node-status ${getNodeStatusClass(selectedNode.status)}`}>
|
||||
<span className="settings-node-status__dot" aria-hidden="true" />
|
||||
<span>{`Selected node: ${getNodeStatusLabel(selectedNode.status)}`}</span>
|
||||
<div className="settings-node-status">
|
||||
<span className="settings-node-status__prefix">Selected node:</span>
|
||||
<NodeHealthDot status={selectedNode.status} showLabel />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -2198,7 +2198,17 @@ describe("ListView - Bulk Selection", () => {
|
||||
fireEvent.click(screen.getByLabelText("Select FN-001"));
|
||||
|
||||
expect(await screen.findByLabelText("Node Override")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("option", { name: "Node One (Online)" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("option", { name: "● Node One (Online)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders non-online statuses with distinct symbols and labels", async () => {
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-2", name: "Node Two", status: "offline" } as never]);
|
||||
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
|
||||
fireEvent.click(screen.getByLabelText("Select FN-001"));
|
||||
|
||||
expect(await screen.findByRole("option", { name: "○ Node Two (Offline)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies explicit node override through batchUpdateTaskModels", async () => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { NodeHealthDot } from "../NodeHealthDot";
|
||||
|
||||
describe("NodeHealthDot", () => {
|
||||
it.each(["online", "offline", "error", "connecting"] as const)("renders status dot for %s", (status) => {
|
||||
render(<NodeHealthDot status={status} />);
|
||||
expect(document.querySelector(`.status-dot--${status}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows label when requested", () => {
|
||||
render(<NodeHealthDot status="online" showLabel />);
|
||||
expect(screen.getByText("Online")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides label by default", () => {
|
||||
render(<NodeHealthDot status="online" />);
|
||||
expect(document.querySelector(".node-health-dot__label")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies compact class", () => {
|
||||
render(<NodeHealthDot status="online" compact />);
|
||||
expect(document.querySelector(".node-health-dot--compact")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets accessible label and title", () => {
|
||||
render(<NodeHealthDot status="online" />);
|
||||
const wrapper = screen.getByLabelText("Node status: Online");
|
||||
expect(wrapper).toHaveAttribute("title", "Online");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ProjectNodeSelector } from "../ProjectNodeSelector";
|
||||
import type { NodeInfo } from "../../api";
|
||||
|
||||
const nodes: NodeInfo[] = [
|
||||
{ id: "node-1", name: "Alpha", type: "remote", status: "online" },
|
||||
{ id: "node-2", name: "Beta", type: "remote", status: "offline" },
|
||||
{ id: "node-3", name: "Gamma", type: "local", status: "error" },
|
||||
];
|
||||
|
||||
describe("ProjectNodeSelector", () => {
|
||||
it("renders select with auto option", () => {
|
||||
render(<ProjectNodeSelector projectId="p1" nodes={nodes} onSelect={vi.fn()} />);
|
||||
expect(screen.getByRole("option", { name: "Auto (no assignment)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders node option labels with type and status", () => {
|
||||
render(<ProjectNodeSelector projectId="p1" nodes={nodes} onSelect={vi.fn()} />);
|
||||
expect(screen.getByRole("option", { name: "Alpha (remote) — online" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Beta (remote) — offline" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dims offline and error options", () => {
|
||||
render(<ProjectNodeSelector projectId="p1" nodes={nodes} onSelect={vi.fn()} />);
|
||||
expect(screen.getByRole("option", { name: "Beta (remote) — offline" })).toHaveClass("project-node-selector__option--dim");
|
||||
expect(screen.getByRole("option", { name: "Gamma (local) — error" })).toHaveClass("project-node-selector__option--dim");
|
||||
});
|
||||
|
||||
it("does not dim online options", () => {
|
||||
render(<ProjectNodeSelector projectId="p1" nodes={nodes} onSelect={vi.fn()} />);
|
||||
expect(screen.getByRole("option", { name: "Alpha (remote) — online" })).not.toHaveClass("project-node-selector__option--dim");
|
||||
});
|
||||
});
|
||||
@@ -76,19 +76,28 @@ describe("RoutingTab", () => {
|
||||
|
||||
expect(await screen.findByText("Per-task override")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Alpha (local) — online")[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders selector options with status text", async () => {
|
||||
render(<RoutingTab task={makeTask()} settings={makeSettings()} addToast={addToast} />);
|
||||
|
||||
expect(await screen.findByRole("option", { name: "Alpha (local) — online" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Beta (remote) — offline" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders routing summary with project default", async () => {
|
||||
render(
|
||||
<RoutingTab
|
||||
task={makeTask()}
|
||||
settings={makeSettings({ defaultNodeId: "node-a" })}
|
||||
settings={makeSettings({ defaultNodeId: "node-b" })}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Project default")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Unhealthy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no-routing summary when no override or project default exists", async () => {
|
||||
|
||||
@@ -168,7 +168,8 @@ describe("SettingsModal Node Routing section", () => {
|
||||
renderModal();
|
||||
await openNodeRouting();
|
||||
expect(screen.getByLabelText("Default Execution Node")).toHaveValue("node-remote-1");
|
||||
expect(screen.getByText(/Selected node: Online/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Selected node:/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Node status: Online")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists all available nodes in selector", async () => {
|
||||
|
||||
@@ -70,6 +70,39 @@ html {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot--online {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.status-dot--offline,
|
||||
.status-dot--error {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.status-dot--connecting {
|
||||
background: var(--color-warning);
|
||||
animation: status-dot-pulse var(--transition-slow) ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes status-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Design Tokens (Theme-Agnostic Defaults) === */
|
||||
:root {
|
||||
/* Typography */
|
||||
|
||||
Reference in New Issue
Block a user