feat(FN-3112): tokenize NodesView border-radius and motion styles

Fixes FN-3112: Updates NodesView CSS to use tokenized border-radius and transition values from the design system instead of hardcoded pixel values, ensuring consistency with the dashboard's established token variables.

Fusion-Task-Id: FN-3112
This commit is contained in:
Fusion
2026-05-04 06:06:44 -07:00
committed by gsxdsm
parent 5c904d8751
commit 5dcb739bbe
17 changed files with 1319 additions and 430 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Extend dashboard node management with managed Docker node status UI, Docker-specific detail sections, and Docker node status/logs API routes.

View File

@@ -165,10 +165,17 @@ export class DockerClientService {
return this.createDockerInstance(hostConfig); return this.createDockerInstance(hostConfig);
} }
async getContainerInfo(containerId: string): Promise<DockerContainerInspectResult | null> { async getContainerInfo(containerId: string, hostConfig?: DockerHostConfig): Promise<DockerContainerInspectResult | null> {
try { try {
const docker = await this.getInstance(); const docker = await this.getDockerInstance(hostConfig);
const inspect = await docker.getContainer(containerId).inspect(); const inspect = await docker.getContainer(containerId).inspect();
const ports = Object.entries(inspect.NetworkSettings?.Ports ?? {}).reduce<Record<string, string>>((acc, [key, value]) => {
const binding = Array.isArray(value) && value.length > 0 ? value[0] : undefined;
if (binding?.HostPort) {
acc[key] = binding.HostPort;
}
return acc;
}, {});
return { return {
id: inspect.Id, id: inspect.Id,
name: (inspect.Name ?? "").replace(/^\//, ""), name: (inspect.Name ?? "").replace(/^\//, ""),
@@ -181,7 +188,11 @@ export class DockerClientService {
restarting: Boolean(inspect.State?.Restarting), restarting: Boolean(inspect.State?.Restarting),
dead: Boolean(inspect.State?.Dead), dead: Boolean(inspect.State?.Dead),
error: inspect.State?.Error || undefined, error: inspect.State?.Error || undefined,
exitCode: typeof inspect.State?.ExitCode === "number" ? inspect.State.ExitCode : undefined,
startedAt: inspect.State?.StartedAt || undefined,
finishedAt: inspect.State?.FinishedAt || undefined,
}, },
ports,
}; };
} catch (error) { } catch (error) {
const message = toErrorMessage(error); const message = toErrorMessage(error);
@@ -192,6 +203,19 @@ export class DockerClientService {
} }
} }
async getContainerLogs(containerId: string, hostConfig?: DockerHostConfig, options?: { tail?: number }): Promise<string> {
const docker = await this.getDockerInstance(hostConfig);
const stream = await docker.getContainer(containerId).logs({
stdout: true,
stderr: true,
tail: options?.tail ?? 100,
});
if (Buffer.isBuffer(stream)) {
return stream.toString("utf8");
}
return String(stream ?? "");
}
private async getInstance(): Promise<Docker> { private async getInstance(): Promise<Docker> {
if (!this.dockerInstance) { if (!this.dockerInstance) {
this.dockerInstance = await this.createDockerInstance(this.defaultHostConfig); this.dockerInstance = await this.createDockerInstance(this.defaultHostConfig);

View File

@@ -2705,7 +2705,12 @@ export interface DockerContainerInspectResult {
restarting: boolean; restarting: boolean;
dead: boolean; dead: boolean;
error?: string; error?: string;
exitCode?: number;
startedAt?: string;
finishedAt?: string;
}; };
/** Optional exposed ports summary */
ports?: Record<string, string>;
} }
/** Configuration for the Fusion Docker image to use for provisioning */ /** Configuration for the Fusion Docker image to use for provisioning */

View File

@@ -5311,6 +5311,41 @@ export interface DockerNodeInfo {
updatedAt: string; updatedAt: string;
} }
export interface ManagedDockerNodeInfo {
id: string;
nodeId?: string;
name: string;
containerId?: string;
status: string;
hostConfig: {
type: "local" | "remote";
host?: string;
context?: string;
tlsOptions?: Record<string, unknown>;
};
envVars: Record<string, string>;
reachableUrl?: string;
imageName: string;
imageTag: string;
volumeMounts: Array<{ hostPath: string; containerPath: string; readOnly?: boolean }>;
persistentStorage: boolean;
resourceSizing?: { cpuLimit?: string; memoryLimit?: string };
errorMessage?: string;
linkedNode?: NodeInfo;
createdAt: string;
updatedAt: string;
}
export interface ContainerStatusInfo {
running: boolean;
status: string;
startedAt?: string;
finishedAt?: string;
exitCode?: number;
error?: string;
ports?: Record<string, string>;
}
/** Node discovered over local network mDNS/DNS-SD */ /** Node discovered over local network mDNS/DNS-SD */
export interface DiscoveredNodeInfo { export interface DiscoveredNodeInfo {
name: string; name: string;
@@ -5457,6 +5492,27 @@ export function listManagedDockerNodes(): Promise<DockerNodeInfo[]> {
return api<DockerNodeInfo[]>("/docker-nodes"); return api<DockerNodeInfo[]>("/docker-nodes");
} }
export function fetchManagedDockerNodes(): Promise<ManagedDockerNodeInfo[]> {
return api<ManagedDockerNodeInfo[]>("/docker/nodes");
}
export function fetchManagedDockerNode(id: string): Promise<ManagedDockerNodeInfo> {
return api<ManagedDockerNodeInfo>(`/docker/nodes/${encodeURIComponent(id)}`);
}
export function fetchManagedDockerNodeContainerStatus(id: string): Promise<ContainerStatusInfo> {
return api<ContainerStatusInfo>(`/docker/nodes/${encodeURIComponent(id)}/container-status`);
}
export function fetchDockerNodeLogs(id: string, options?: { tail?: number }): Promise<{ logs: string }> {
const params = new URLSearchParams();
if (typeof options?.tail === "number") {
params.set("tail", String(options.tail));
}
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return api<{ logs: string }>(`/docker/nodes/${encodeURIComponent(id)}/logs${suffix}`);
}
/** Create a managed Docker node */ /** Create a managed Docker node */
export function createManagedDockerNode(input: ManagedDockerNodeInput): Promise<DockerNodeInfo> { export function createManagedDockerNode(input: ManagedDockerNodeInput): Promise<DockerNodeInfo> {
return api<DockerNodeInfo>("/docker-nodes", { return api<DockerNodeInfo>("/docker-nodes", {

View File

@@ -1,6 +1,6 @@
import { memo, useCallback, useMemo, useState } from "react"; import { memo, useCallback, useMemo, useState } from "react";
import { Activity, Play, RotateCw, Server, Settings, Shield, Square, Trash2 } from "lucide-react"; import { Activity, Box, Play, RotateCw, Server, Settings, Shield, Square, Trash2 } from "lucide-react";
import type { NodeInfo, ProjectInfo } from "../api"; import type { ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../api";
import { getProjectCountForNode } from "../utils/nodeProjectAssignment"; import { getProjectCountForNode } from "../utils/nodeProjectAssignment";
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync"; import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
import { formatRelativeTime, getSyncStateColor } from "../hooks/useNodeSettingsSync"; import { formatRelativeTime, getSyncStateColor } from "../hooks/useNodeSettingsSync";
@@ -17,13 +17,20 @@ export interface NodeCardProps {
authSyncState?: "match" | "differs" | "not-synced"; authSyncState?: "match" | "differs" | "not-synced";
/** Per-provider auth match details for tooltip. Map of provider name (e.g. "anthropic") to its match status. */ /** Per-provider auth match details for tooltip. Map of provider name (e.g. "anthropic") to its match status. */
authSyncProviders?: Record<string, "match" | "differs">; authSyncProviders?: Record<string, "match" | "differs">;
managedDockerNode?: ManagedDockerNodeInfo;
} }
const STATUS_CONFIG: Record<NodeInfo["status"], { label: string; color: string; className: string }> = { const STATUS_CONFIG: Record<string, { label: string; color: string; className: string }> = {
online: { label: "Online", color: "var(--color-success)", className: "node-card__status--online" }, online: { label: "Online", color: "var(--color-success)", className: "node-card__status--online" },
offline: { label: "Offline", color: "var(--color-error)", className: "node-card__status--offline" }, offline: { label: "Offline", color: "var(--color-error)", className: "node-card__status--offline" },
connecting: { label: "Connecting", color: "var(--color-warning)", className: "node-card__status--connecting" }, connecting: { label: "Connecting", color: "var(--color-warning)", className: "node-card__status--connecting" },
error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" }, error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" },
creating: { label: "Creating", color: "var(--color-warning)", className: "node-card__status--creating" },
recreating: { label: "Recreating", color: "var(--color-warning)", className: "node-card__status--recreating" },
deleting: { label: "Deleting", color: "var(--color-error)", className: "node-card__status--deleting" },
running: { label: "Running", color: "var(--color-success)", className: "node-card__status--online" },
stopped: { label: "Stopped", color: "var(--color-error)", className: "node-card__status--offline" },
exited: { label: "Exited", color: "var(--color-error)", className: "node-card__status--offline" },
}; };
const AUTH_SYNC_COLORS: Record<string, string> = { const AUTH_SYNC_COLORS: Record<string, string> = {
@@ -68,6 +75,16 @@ function areNodeCardPropsEqual(previous: NodeCardProps, next: NodeCardProps): bo
if (prevNode.updatedAt !== nextNode.updatedAt) return false; if (prevNode.updatedAt !== nextNode.updatedAt) return false;
if (previous.isLoading !== next.isLoading) return false; if (previous.isLoading !== next.isLoading) return false;
const prevDocker = previous.managedDockerNode;
const nextDocker = next.managedDockerNode;
if (!!prevDocker !== !!nextDocker) return false;
if (prevDocker && nextDocker) {
if (prevDocker.id !== nextDocker.id) return false;
if (prevDocker.status !== nextDocker.status) return false;
if (prevDocker.imageTag !== nextDocker.imageTag) return false;
if (prevDocker.updatedAt !== nextDocker.updatedAt) return false;
}
// Compare sync status // Compare sync status
const prevSync = previous.syncStatus; const prevSync = previous.syncStatus;
const nextSync = next.syncStatus; const nextSync = next.syncStatus;
@@ -113,9 +130,14 @@ function NodeCardInner({
syncStatus, syncStatus,
authSyncState, authSyncState,
authSyncProviders, authSyncProviders,
managedDockerNode,
}: NodeCardProps) { }: NodeCardProps) {
const [removeArmed, setRemoveArmed] = useState(false); const [removeArmed, setRemoveArmed] = useState(false);
const statusConfig = STATUS_CONFIG[node.status]; const statusConfig = STATUS_CONFIG[node.status] ?? STATUS_CONFIG.offline;
const dockerStatusConfig = managedDockerNode ? (STATUS_CONFIG[managedDockerNode.status] ?? STATUS_CONFIG.error) : null;
const dockerHost = managedDockerNode?.hostConfig.type === "remote"
? `Remote: ${managedDockerNode.hostConfig.host ?? "unknown"}`
: "Local Docker";
const assignedProjectCount = useMemo(() => { const assignedProjectCount = useMemo(() => {
return getProjectCountForNode(projects, node); return getProjectCountForNode(projects, node);
@@ -171,6 +193,12 @@ function NodeCardInner({
<h3 className="node-card__name" title={node.name}>{node.name}</h3> <h3 className="node-card__name" title={node.name}>{node.name}</h3>
<div className="node-card__meta-row"> <div className="node-card__meta-row">
<span className="node-card__type-badge">{node.type === "local" ? "Local" : "Remote"}</span> <span className="node-card__type-badge">{node.type === "local" ? "Local" : "Remote"}</span>
{managedDockerNode && (
<span className="node-card__docker-badge" title="Managed Docker node">
<Box size={12} aria-hidden />
Docker
</span>
)}
<span <span
className={`node-card__status ${statusConfig.className}`} className={`node-card__status ${statusConfig.className}`}
style={{ color: statusConfig.color }} style={{ color: statusConfig.color }}
@@ -179,6 +207,16 @@ function NodeCardInner({
<span className="node-card__status-indicator" style={{ backgroundColor: statusConfig.color }} aria-hidden /> <span className="node-card__status-indicator" style={{ backgroundColor: statusConfig.color }} aria-hidden />
{statusConfig.label} {statusConfig.label}
</span> </span>
{managedDockerNode && dockerStatusConfig && (
<span
className={`node-card__status ${dockerStatusConfig.className}`}
style={{ color: dockerStatusConfig.color }}
data-status={managedDockerNode.status}
>
<span className="node-card__status-indicator" style={{ backgroundColor: dockerStatusConfig.color }} aria-hidden />
{dockerStatusConfig.label}
</span>
)}
{node.type === "remote" && authSyncState && ( {node.type === "remote" && authSyncState && (
<span <span
className={`node-card__auth-indicator node-card__auth-indicator--${authSyncState}`} className={`node-card__auth-indicator node-card__auth-indicator--${authSyncState}`}
@@ -200,6 +238,14 @@ function NodeCardInner({
{truncateUrl(node.url)} {truncateUrl(node.url)}
</div> </div>
)} )}
{managedDockerNode && (
<div className="node-card__docker-meta">
<span title={`${managedDockerNode.imageName}:${managedDockerNode.imageTag}`}>
{managedDockerNode.imageName}:{managedDockerNode.imageTag}
</span>
<span title={dockerHost}>{dockerHost}</span>
</div>
)}
<div className="node-card__metrics"> <div className="node-card__metrics">
<div className="node-card__metric"> <div className="node-card__metric">
@@ -256,41 +302,42 @@ function NodeCardInner({
<span>Edit</span> <span>Edit</span>
</button> </button>
<button {managedDockerNode && (
className="btn btn-sm node-card__action" <>
type="button" <button
onClick={handleEdit} className="btn btn-sm node-card__action"
disabled={isLoading} type="button"
aria-label="Start node container" disabled
title="Start Container" aria-label="Start node container"
> title="Available after FN-3113"
<Play size={14} /> >
<span>Start</span> <Play size={14} />
</button> <span>Start</span>
</button>
<button <button
className="btn btn-sm node-card__action" className="btn btn-sm node-card__action"
type="button" type="button"
onClick={handleEdit} disabled
disabled={isLoading} aria-label="Stop node container"
aria-label="Stop node container" title="Available after FN-3113"
title="Stop Container" >
> <Square size={14} />
<Square size={14} /> <span>Stop</span>
<span>Stop</span> </button>
</button>
<button <button
className="btn btn-sm node-card__action" className="btn btn-sm node-card__action"
type="button" type="button"
onClick={handleEdit} disabled
disabled={isLoading} aria-label="Restart node container"
aria-label="Restart node container" title="Available after FN-3113"
title="Restart Container" >
> <RotateCw size={14} />
<RotateCw size={14} /> <span>Restart</span>
<span>Restart</span> </button>
</button> </>
)}
<button <button
className={`btn btn-sm node-card__action node-card__action--remove ${removeArmed ? "btn-danger is-armed" : ""}`} className={`btn btn-sm node-card__action node-card__action--remove ${removeArmed ? "btn-danger is-armed" : ""}`}

View File

@@ -183,6 +183,117 @@
background: color-mix(in srgb, var(--color-error) 20%, transparent); background: color-mix(in srgb, var(--color-error) 20%, transparent);
} }
.docker-management {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.docker-management__status-card {
display: flex;
flex-direction: column;
gap: var(--space-sm);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-md);
}
.docker-management__status-row {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.docker-management__status-dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: 50%;
}
.docker-management__status-dot--success { background: var(--color-success); }
.docker-management__status-dot--warning { background: var(--color-warning); }
.docker-management__status-dot--error { background: var(--color-error); }
.docker-management__status-meta {
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs));
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
}
.docker-management__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.docker-management__info-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.docker-management__log-viewer {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-md);
}
.docker-management__log-viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
}
.docker-management__log-viewer pre {
margin: 0;
max-height: calc(var(--space-2xl) * 9.375);
overflow-y: auto;
font-family: var(--font-mono);
font-size: calc(var(--space-sm) + var(--space-xs));
color: var(--text);
white-space: pre-wrap;
}
.docker-management__env-list {
margin: var(--space-sm) 0 0;
}
.docker-management__env-list div {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: var(--space-sm);
padding: var(--space-xs) 0;
}
.docker-management__env-list dt {
color: var(--text-muted);
font-family: var(--font-mono);
}
.docker-management__env-list dd {
margin: 0;
font-family: var(--font-mono);
word-break: break-all;
}
.docker-management__mounts-list {
margin: var(--space-sm) 0 0;
padding-left: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.docker-management__mounts-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.node-detail-modal__grid, .node-detail-modal__grid,
.node-detail-modal__docker-grid { .node-detail-modal__docker-grid {
@@ -192,4 +303,30 @@
.node-detail-modal__field--full { .node-detail-modal__field--full {
grid-column: span 1; grid-column: span 1;
} }
.docker-management__actions {
flex-direction: column;
}
.docker-management__actions .btn {
width: 100%;
justify-content: center;
}
.docker-management__info-grid {
grid-template-columns: 1fr;
}
.docker-management__env-list div {
grid-template-columns: 1fr;
}
.docker-management__mounts-list li {
flex-direction: column;
align-items: flex-start;
}
.docker-management__log-viewer pre {
max-height: calc(var(--space-2xl) * 6.25);
}
} }

View File

@@ -1,6 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Activity, Download, Pencil, Save, Shield, Upload, X } from "lucide-react"; import {
import type { NodeInfo, NodeUpdateInput, ProjectInfo } from "../api"; Activity,
Download,
FileText,
Pencil,
Play,
RotateCcw,
Save,
Shield,
Square,
Upload,
X,
} from "lucide-react";
import type { ContainerStatusInfo, ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { getProjectsForNode } from "../utils/nodeProjectAssignment"; import { getProjectsForNode } from "../utils/nodeProjectAssignment";
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync"; import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
@@ -23,12 +35,16 @@ interface NodeDetailModalProps {
onPushSettings?: (nodeId: string) => Promise<unknown>; onPushSettings?: (nodeId: string) => Promise<unknown>;
onPullSettings?: (nodeId: string) => Promise<unknown>; onPullSettings?: (nodeId: string) => Promise<unknown>;
onSyncAuth?: (nodeId: string) => Promise<unknown>; onSyncAuth?: (nodeId: string) => Promise<unknown>;
/** Sync history entries for this node */
syncHistory?: SyncLogEntry[]; syncHistory?: SyncLogEntry[];
/** Called when sync conflicts need resolution */
onResolveConflicts?: (resolutions: ConflictResolutionResult[]) => Promise<void>; onResolveConflicts?: (resolutions: ConflictResolutionResult[]) => Promise<void>;
managedDockerNode?: ManagedDockerNodeInfo;
containerStatus?: ContainerStatusInfo;
onFetchContainerStatus?: (managedId: string) => Promise<ContainerStatusInfo>;
onFetchLogs?: (managedId: string) => Promise<string>;
} }
const SENSITIVE_ENV_KEY_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD)/i;
function formatTimestamp(value?: string): string { function formatTimestamp(value?: string): string {
if (!value) return "—"; if (!value) return "—";
const date = new Date(value); const date = new Date(value);
@@ -36,6 +52,21 @@ function formatTimestamp(value?: string): string {
return date.toLocaleString(); return date.toLocaleString();
} }
function formatDockerUptime(startedAt?: string): string {
if (!startedAt) return "—";
const started = new Date(startedAt);
const now = Date.now();
if (Number.isNaN(started.getTime()) || started.getTime() > now) return "—";
const seconds = Math.floor((now - started.getTime()) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ${minutes % 60}m`;
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
function getSyncStateDotClass(syncState: ComputedNodeSyncStatus["syncState"]): string { function getSyncStateDotClass(syncState: ComputedNodeSyncStatus["syncState"]): string {
switch (syncState) { switch (syncState) {
case "synced": case "synced":
@@ -52,6 +83,32 @@ function getSyncStateDotClass(syncState: ComputedNodeSyncStatus["syncState"]): s
} }
} }
function getDockerStatusTone(status?: string): "success" | "warning" | "error" {
if (status === "running") return "success";
if (status === "creating" || status === "recreating" || status === "restarting") return "warning";
return "error";
}
function getDockerStatusLabel(status?: string): string {
if (!status) return "Unknown";
return `${status.charAt(0).toUpperCase()}${status.slice(1)}`;
}
function parsePortFromReachableUrl(url?: string): string {
if (!url) return "—";
try {
const parsed = new URL(url);
if (parsed.port) return parsed.port;
return parsed.protocol === "https:" ? "443" : parsed.protocol === "http:" ? "80" : "—";
} catch {
return "—";
}
}
function maskEnvValue(key: string, value: string): string {
return SENSITIVE_ENV_KEY_PATTERN.test(key) ? "••••••••" : value;
}
export function NodeDetailModal({ export function NodeDetailModal({
isOpen, isOpen,
onClose, onClose,
@@ -66,6 +123,10 @@ export function NodeDetailModal({
onSyncAuth, onSyncAuth,
syncHistory = [], syncHistory = [],
onResolveConflicts, onResolveConflicts,
managedDockerNode,
containerStatus,
onFetchContainerStatus,
onFetchLogs,
}: NodeDetailModalProps) { }: NodeDetailModalProps) {
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
@@ -75,18 +136,19 @@ export function NodeDetailModal({
const [maxConcurrent, setMaxConcurrent] = useState(2); const [maxConcurrent, setMaxConcurrent] = useState(2);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
// Sync action states
const [isPushing, setIsPushing] = useState(false); const [isPushing, setIsPushing] = useState(false);
const [isPulling, setIsPulling] = useState(false); const [isPulling, setIsPulling] = useState(false);
const [isSyncingAuth, setIsSyncingAuth] = useState(false); const [isSyncingAuth, setIsSyncingAuth] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null); const [syncError, setSyncError] = useState<string | null>(null);
// Conflict resolution modal state
const [showConflictModal, setShowConflictModal] = useState(false); const [showConflictModal, setShowConflictModal] = useState(false);
const [conflicts] = useState<SettingsConflictEntry[]>([]); const [conflicts] = useState<SettingsConflictEntry[]>([]);
const [dockerStatus, setDockerStatus] = useState<"running" | "stopped" | "recreating">("running");
const [dockerEnv, setDockerEnv] = useState("FUSION_LOG_LEVEL=info"); const [liveContainerStatus, setLiveContainerStatus] = useState<ContainerStatusInfo | undefined>(containerStatus);
const [dockerMounts, setDockerMounts] = useState("/srv/fusion:/data:rw"); const [isRefreshingContainerStatus, setIsRefreshingContainerStatus] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [logs, setLogs] = useState("");
const [logsLoading, setLogsLoading] = useState(false);
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
@@ -95,9 +157,15 @@ export function NodeDetailModal({
}; };
}, []); }, []);
useEffect(() => {
setLiveContainerStatus(containerStatus);
}, [containerStatus]);
useEffect(() => { useEffect(() => {
if (!node || !isOpen) { if (!node || !isOpen) {
setEditMode(false); setEditMode(false);
setLogsOpen(false);
setLogs("");
return; return;
} }
@@ -127,10 +195,17 @@ export function NodeDetailModal({
return getProjectsForNode(projects, node); return getProjectsForNode(projects, node);
}, [node, projects]); }, [node, projects]);
const isManagedDockerNode = useMemo( const dockerHost = useMemo(() => {
() => (node?.capabilities as readonly string[] | undefined)?.includes("docker-managed") ?? false, if (!managedDockerNode) return "—";
[node], return managedDockerNode.hostConfig.type === "remote" ? managedDockerNode.hostConfig.host ?? "—" : "Local Docker";
); }, [managedDockerNode]);
const dockerResourceSizing = useMemo(() => {
if (!managedDockerNode?.resourceSizing?.cpuLimit && !managedDockerNode?.resourceSizing?.memoryLimit) {
return "Default";
}
return `${managedDockerNode.resourceSizing?.cpuLimit ?? "Default CPU"} / ${managedDockerNode.resourceSizing?.memoryLimit ?? "Default memory"}`;
}, [managedDockerNode]);
const handleHealthCheck = useCallback(async () => { const handleHealthCheck = useCallback(async () => {
if (!node) return; if (!node) return;
@@ -210,14 +285,42 @@ export function NodeDetailModal({
setSyncError(null); setSyncError(null);
}, []); }, []);
const handleDockerLifecycle = useCallback((action: "start" | "stop" | "restart" | "recreate" | "upgrade") => { const handleRefreshContainerStatus = useCallback(async () => {
if (action === "start") setDockerStatus("running"); if (!managedDockerNode || !onFetchContainerStatus) return;
if (action === "stop") setDockerStatus("stopped"); setIsRefreshingContainerStatus(true);
if (action === "restart") setDockerStatus("running"); try {
if (action === "recreate") setDockerStatus("recreating"); const result = await onFetchContainerStatus(managedDockerNode.id);
if (action === "upgrade") setDockerStatus("recreating"); if (!isMountedRef.current) return;
addToast(`Docker action queued: ${action}`, "success"); setLiveContainerStatus(result);
}, [addToast]); } catch (error) {
const message = error instanceof Error ? error.message : "Failed to fetch container status";
addToast(message, "error");
} finally {
if (isMountedRef.current) {
setIsRefreshingContainerStatus(false);
}
}
}, [addToast, managedDockerNode, onFetchContainerStatus]);
const handleFetchLogs = useCallback(async () => {
if (!managedDockerNode || !onFetchLogs) return;
setLogsOpen(true);
setLogsLoading(true);
try {
const result = await onFetchLogs(managedDockerNode.id);
if (!isMountedRef.current) return;
setLogs(result);
} catch (error) {
if (!isMountedRef.current) return;
setLogs("");
const message = error instanceof Error ? error.message : "Failed to fetch container logs";
addToast(message, "error");
} finally {
if (isMountedRef.current) {
setLogsLoading(false);
}
}
}, [addToast, managedDockerNode, onFetchLogs]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!node || isSaving) return; if (!node || isSaving) return;
@@ -267,6 +370,9 @@ export function NodeDetailModal({
if (!isOpen || !node) return null; if (!isOpen || !node) return null;
const effectiveDockerStatus = liveContainerStatus?.status ?? managedDockerNode?.status;
const dockerStatusTone = getDockerStatusTone(effectiveDockerStatus);
return ( return (
<div className="modal-overlay open" onClick={onClose}> <div className="modal-overlay open" onClick={onClose}>
<div <div
@@ -412,39 +518,88 @@ export function NodeDetailModal({
</div> </div>
</section> </section>
{isManagedDockerNode && ( {managedDockerNode && (
<section className="node-detail-modal__section"> <section className="node-detail-modal__section docker-management">
<h4>Docker Management</h4> <h4>Docker Management</h4>
<div className="node-detail-modal__health-row">
<span>Container: <strong>{dockerStatus}</strong></span> <div className="docker-management__status-card">
<span>Image: <strong>runfusion/fusion:latest</strong></span> <div className="docker-management__status-row">
<span className={`docker-management__status-dot docker-management__status-dot--${dockerStatusTone}`} aria-hidden />
<strong>{getDockerStatusLabel(effectiveDockerStatus)}</strong>
{(effectiveDockerStatus === "creating" || effectiveDockerStatus === "recreating" || effectiveDockerStatus === "restarting") && (
<RotateCcw size={14} className="spin" aria-hidden />
)}
</div>
<div className="docker-management__status-meta">
{effectiveDockerStatus === "running" && <span>Uptime: {formatDockerUptime(liveContainerStatus?.startedAt)}</span>}
{effectiveDockerStatus !== "running" && liveContainerStatus?.exitCode !== undefined && (
<span>Exit code: {liveContainerStatus.exitCode}</span>
)}
{(liveContainerStatus?.error || managedDockerNode.errorMessage) && (
<span>{liveContainerStatus?.error ?? managedDockerNode.errorMessage}</span>
)}
</div>
<button className="btn btn-sm" onClick={() => void handleRefreshContainerStatus()} disabled={!onFetchContainerStatus || isRefreshingContainerStatus}>
{isRefreshingContainerStatus ? "Refreshing..." : "Refresh Status"}
</button>
</div> </div>
<div className="node-detail-modal__sync-actions">
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("start")}>Start</button> <div className="node-detail-modal__grid docker-management__info-grid">
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("stop")}>Stop</button> <div className="node-detail-modal__field"><span>Image</span><strong><code>{managedDockerNode.imageName}:{managedDockerNode.imageTag}</code></strong></div>
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("restart")}>Restart</button> <div className="node-detail-modal__field"><span>Container ID</span><strong><code>{managedDockerNode.containerId ? managedDockerNode.containerId.slice(0, 12) : "—"}</code></strong></div>
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("recreate")}>Recreate</button> <div className="node-detail-modal__field"><span>Host</span><strong>{dockerHost}</strong></div>
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("upgrade")}>Upgrade Image</button> <div className="node-detail-modal__field"><span>Persistent Storage</span><strong>{managedDockerNode.persistentStorage ? "Yes" : "No"}</strong></div>
<div className="node-detail-modal__field"><span>Port</span><strong>{parsePortFromReachableUrl(managedDockerNode.reachableUrl)}</strong></div>
<div className="node-detail-modal__field"><span>Resource Sizing</span><strong>{dockerResourceSizing}</strong></div>
</div> </div>
<div className="node-detail-modal__docker-grid">
<label className="node-detail-modal__field"> <div className="docker-management__actions">
<span>Environment Variables</span> <button className="btn btn-sm" disabled title="Available after FN-3113"><Play size={14} />Start</button>
<textarea className="input node-detail-modal__textarea" value={dockerEnv} onChange={(event) => setDockerEnv(event.target.value)} /> <button className="btn btn-sm" disabled title="Available after FN-3113"><Square size={14} />Stop</button>
</label> <button className="btn btn-sm" disabled title="Available after FN-3113"><RotateCcw size={14} />Restart</button>
<label className="node-detail-modal__field"> <button className="btn btn-sm" onClick={() => void handleFetchLogs()} disabled={!onFetchLogs}><FileText size={14} />View Logs</button>
<span>Volume Mounts</span>
<textarea className="input node-detail-modal__textarea" value={dockerMounts} onChange={(event) => setDockerMounts(event.target.value)} />
</label>
</div>
<div className="node-detail-modal__sync-actions">
<button className="btn btn-sm" onClick={() => addToast("Container logs opened", "success")}>View Logs</button>
<button className="btn btn-sm" onClick={() => addToast("Config changes saved", "success")}>Save Config</button>
<button className="btn btn-danger btn-sm" onClick={() => addToast("Delete flow opened (retain/remove volumes)", "warning")}>Delete Node</button>
</div> </div>
{logsOpen && (
<div className="docker-management__log-viewer">
<div className="docker-management__log-viewer-header">
<strong>Container Logs</strong>
<button className="btn-icon" onClick={() => setLogsOpen(false)} aria-label="Close logs"><X size={14} /></button>
</div>
{logsLoading ? (
<p>Fetching logs...</p>
) : (
<pre>{logs.trim() || "No logs available"}</pre>
)}
</div>
)}
<details>
<summary>Environment Variables</summary>
<dl className="docker-management__env-list">
{Object.entries(managedDockerNode.envVars).map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{maskEnvValue(key, value)}</dd>
</div>
))}
</dl>
</details>
<details>
<summary>Volume Mounts</summary>
<ul className="docker-management__mounts-list">
{managedDockerNode.volumeMounts.map((mount) => (
<li key={`${mount.hostPath}:${mount.containerPath}`}>
<span>{mount.hostPath} {mount.containerPath}</span>
{mount.readOnly && <span className="node-card__type-badge">Read-only</span>}
</li>
))}
</ul>
</details>
</section> </section>
)} )}
{/* Settings Sync section — only for remote nodes */}
{node.type === "remote" && ( {node.type === "remote" && (
<section className="node-detail-modal__section"> <section className="node-detail-modal__section">
<h4>Settings Sync</h4> <h4>Settings Sync</h4>
@@ -456,12 +611,7 @@ export function NodeDetailModal({
aria-hidden aria-hidden
/> />
<span> <span>
Last sync:{" "} Last sync: <strong>{syncStatus.lastSyncAt ? formatRelativeTime(syncStatus.lastSyncAt) : "Never synced"}</strong>
<strong>
{syncStatus.lastSyncAt
? formatRelativeTime(syncStatus.lastSyncAt)
: "Never synced"}
</strong>
</span> </span>
{syncStatus.diffCount > 0 && ( {syncStatus.diffCount > 0 && (
<span className="node-detail-modal__sync-diff"> <span className="node-detail-modal__sync-diff">
@@ -472,29 +622,17 @@ export function NodeDetailModal({
)} )}
<div className="node-detail-modal__sync-actions"> <div className="node-detail-modal__sync-actions">
<button <button className="btn btn-sm" onClick={handlePushSettings} disabled={isPushing || !onPushSettings}>
className="btn btn-sm"
onClick={handlePushSettings}
disabled={isPushing || !onPushSettings}
>
<Upload size={14} /> <Upload size={14} />
{isPushing ? "Pushing..." : "Push Settings"} {isPushing ? "Pushing..." : "Push Settings"}
</button> </button>
<button <button className="btn btn-sm" onClick={handlePullSettings} disabled={isPulling || !onPullSettings}>
className="btn btn-sm"
onClick={handlePullSettings}
disabled={isPulling || !onPullSettings}
>
<Download size={14} /> <Download size={14} />
{isPulling ? "Pulling..." : "Pull Settings"} {isPulling ? "Pulling..." : "Pull Settings"}
</button> </button>
<button <button className="btn btn-sm" onClick={handleSyncAuth} disabled={isSyncingAuth || !onSyncAuth}>
className="btn btn-sm"
onClick={handleSyncAuth}
disabled={isSyncingAuth || !onSyncAuth}
>
<Shield size={14} /> <Shield size={14} />
{isSyncingAuth ? "Syncing..." : "Sync Auth"} {isSyncingAuth ? "Syncing..." : "Sync Auth"}
</button> </button>
@@ -503,11 +641,7 @@ export function NodeDetailModal({
{syncError && ( {syncError && (
<div className="node-detail-modal__sync-error"> <div className="node-detail-modal__sync-error">
<span>{syncError}</span> <span>{syncError}</span>
<button <button className="node-detail-modal__sync-error-dismiss" onClick={handleDismissSyncError} aria-label="Dismiss error">
className="node-detail-modal__sync-error-dismiss"
onClick={handleDismissSyncError}
aria-label="Dismiss error"
>
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
@@ -515,15 +649,10 @@ export function NodeDetailModal({
</section> </section>
)} )}
{/* Sync History section — only for remote nodes */}
{node.type === "remote" && ( {node.type === "remote" && (
<section className="node-detail-modal__section"> <section className="node-detail-modal__section">
<h4>Sync History</h4> <h4>Sync History</h4>
<SettingsSyncLog <SettingsSyncLog nodeId={node.id} entries={syncHistory} singleNode={true} />
nodeId={node.id}
entries={syncHistory}
singleNode={true}
/>
</section> </section>
)} )}
</div> </div>
@@ -537,7 +666,6 @@ export function NodeDetailModal({
</div> </div>
</div> </div>
{/* Conflict resolution modal — rendered outside main modal container */}
{node.type === "remote" && ( {node.type === "remote" && (
<SettingsSyncConflictModal <SettingsSyncConflictModal
isOpen={showConflictModal} isOpen={showConflictModal}

View File

@@ -14,6 +14,8 @@
} }
.nodes-view { .nodes-view {
--nodes-view-spin-duration: calc(var(--transition-slow) * 4);
--nodes-view-pulse-duration: calc(var(--transition-slow) * 5);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
@@ -64,7 +66,7 @@
.nodes-view-stat { .nodes-view-stat {
background: var(--card); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -101,7 +103,7 @@
.nodes-view-empty { .nodes-view-empty {
padding: var(--space-xl); padding: var(--space-xl);
border: 1px dashed var(--border); border: 1px dashed var(--border);
border-radius: var(--radius); border-radius: var(--radius-md);
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
display: flex; display: flex;
@@ -118,7 +120,7 @@
.node-card { .node-card {
background: var(--card); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius-md);
padding: var(--space-md); padding: var(--space-md);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -304,6 +306,33 @@
background: color-mix(in srgb, var(--color-error) 14%, transparent); background: color-mix(in srgb, var(--color-error) 14%, transparent);
} }
.node-card__docker-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
border-radius: var(--radius-pill);
padding: var(--space-xs) var(--space-sm);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
background: color-mix(in srgb, var(--color-info) 15%, transparent);
color: var(--color-info);
}
.node-card__docker-meta {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-xs);
font-family: var(--font-mono);
font-size: calc(var(--space-sm) + var(--space-xs));
color: var(--text-muted);
}
.node-card__docker-meta span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Mesh Topology ──────────────────────────────────────────────────────────── */ /* ── Mesh Topology ──────────────────────────────────────────────────────────── */
.mesh-topology { .mesh-topology {
@@ -533,7 +562,7 @@
.nodes-view .node-status-indicator__dot--connecting { .nodes-view .node-status-indicator__dot--connecting {
background: var(--color-warning); background: var(--color-warning);
animation: pulse-warning 1.5s ease-in-out infinite; animation: pulse-warning var(--nodes-view-pulse-duration) ease-in-out infinite;
} }
.nodes-view .node-status-indicator__spinner { .nodes-view .node-status-indicator__spinner {
@@ -542,7 +571,7 @@
border: calc(var(--space-xs) / 2) solid transparent; border: calc(var(--space-xs) / 2) solid transparent;
border-top-color: currentColor; border-top-color: currentColor;
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin var(--nodes-view-spin-duration) linear infinite;
} }
.nodes-view .node-status-indicator__name { .nodes-view .node-status-indicator__name {
@@ -565,6 +594,15 @@
} }
} }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin var(--nodes-view-spin-duration) linear infinite;
}
/* ── Nodes View Mobile Responsive ──────────────────────────────────────── */ /* ── Nodes View Mobile Responsive ──────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.nodes-view { .nodes-view {
@@ -579,6 +617,18 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.node-card__docker-badge {
gap: var(--space-xs);
}
.node-card__docker-meta {
gap: var(--space-xs);
}
.node-card__docker-meta span {
max-width: calc(var(--space-2xl) * 4.5);
}
/* Connect Node Modal mobile */ /* Connect Node Modal mobile */
.connect-node-modal { .connect-node-modal {
width: calc(100vw - (var(--space-md) * 2)); width: calc(100vw - (var(--space-md) * 2));
@@ -590,7 +640,6 @@
/* NodesView mobile alignment */ /* NodesView mobile alignment */
.nodes-view-header { .nodes-view-header {
padding: var(--space-sm) var(--space-md);
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-sm); gap: var(--space-sm);
} }
@@ -658,4 +707,5 @@
font-size: calc(var(--space-md) + var(--space-xs) / 4); font-size: calc(var(--space-md) + var(--space-xs) / 4);
} }
} }

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Server, Wifi, WifiOff, Globe, RefreshCw, X } from "lucide-react"; import { Box, Plus, Server, Wifi, WifiOff, Globe, RefreshCw, X } from "lucide-react";
import "./NodesView.css"; import "./NodesView.css";
import { useNodes } from "../hooks/useNodes"; import { useNodes } from "../hooks/useNodes";
import { useProjects } from "../hooks/useProjects"; import { useProjects } from "../hooks/useProjects";
import { useNodeSettingsSync, computeSyncState } from "../hooks/useNodeSettingsSync"; import { useNodeSettingsSync, computeSyncState } from "../hooks/useNodeSettingsSync";
import type { NodeInfo, NodeUpdateInput } from "../api"; import type { ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput } from "../api";
import { NodeCard } from "./NodeCard"; import { NodeCard } from "./NodeCard";
import { MeshTopology } from "./MeshTopology"; import { MeshTopology } from "./MeshTopology";
import { AddNodeModal, type AddNodeInput } from "./AddNodeModal"; import { AddNodeModal, type AddNodeInput } from "./AddNodeModal";
@@ -23,7 +23,14 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
const { nodes, loading, error, refresh, register, update, unregister, healthCheck } = useNodes(); const { nodes, loading, error, refresh, register, update, unregister, healthCheck } = useNodes();
const { projects } = useProjects(); const { projects } = useProjects();
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync(); const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
const { dockerNodes, create: createDockerNode } = useManagedDockerNodes(); const {
dockerNodes,
loading: dockerLoading,
refresh: refreshDocker,
getContainerStatus,
getLogs,
create: createDockerNode,
} = useManagedDockerNodes();
const [addModalOpen, setAddModalOpen] = useState(false); const [addModalOpen, setAddModalOpen] = useState(false);
const [dockerOnboardingOpen, setDockerOnboardingOpen] = useState(false); const [dockerOnboardingOpen, setDockerOnboardingOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null); const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
@@ -50,8 +57,9 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
const synced = nodes.filter( const synced = nodes.filter(
(node) => node.type === "remote" && syncStatusMap[node.id] && computeSyncState(syncStatusMap[node.id]).syncState === "synced" (node) => node.type === "remote" && syncStatusMap[node.id] && computeSyncState(syncStatusMap[node.id]).syncState === "synced"
).length; ).length;
return { total, online, offline, remote, synced }; const docker = dockerNodes.length;
}, [nodes, syncStatusMap]); return { total, online, offline, remote, synced, docker };
}, [dockerNodes.length, nodes, syncStatusMap]);
const handleRegister = useCallback(async (input: AddNodeInput) => { const handleRegister = useCallback(async (input: AddNodeInput) => {
await register(input); await register(input);
@@ -69,13 +77,23 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
} }
}, [addToast, createDockerNode]); }, [addToast, createDockerNode]);
const dockerNodeMap = useMemo(() => {
const map = new Map<string, ManagedDockerNodeInfo>();
for (const dockerNode of dockerNodes) {
if (dockerNode.nodeId) {
map.set(dockerNode.nodeId, dockerNode);
}
}
return map;
}, [dockerNodes]);
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
try { try {
await refresh(); await Promise.all([refresh(), refreshDocker()]);
} catch { } catch {
addToast("Failed to refresh nodes", "error"); addToast("Failed to refresh nodes", "error");
} }
}, [addToast, refresh]); }, [addToast, refresh, refreshDocker]);
const handleHealthCheck = useCallback(async (id: string) => { const handleHealthCheck = useCallback(async (id: string) => {
try { try {
@@ -123,7 +141,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
> >
<X size={16} /> <X size={16} />
</button> </button>
<button className="btn btn-sm" onClick={() => void handleRefresh()} disabled={loading}> <button className="btn btn-sm" onClick={() => void handleRefresh()} disabled={loading || dockerLoading}>
<RefreshCw size={14} className={loading ? "spin" : ""} /> <RefreshCw size={14} className={loading ? "spin" : ""} />
Refresh Refresh
</button> </button>
@@ -131,9 +149,9 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
<Plus size={14} /> <Plus size={14} />
Add Node Add Node
</button> </button>
<button className="btn btn-primary btn-sm" onClick={() => setDockerOnboardingOpen(true)}> <button className="btn btn-sm" onClick={() => setDockerOnboardingOpen(true)} title="Add a managed Docker node">
<Plus size={14} /> <Box size={14} />
Provision Docker Node Add Docker Node
</button> </button>
</div> </div>
</div> </div>
@@ -159,20 +177,14 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
<span><RefreshCw size={14} /> Synced</span> <span><RefreshCw size={14} /> Synced</span>
<strong>{stats.synced}</strong> <strong>{stats.synced}</strong>
</div> </div>
<div className="nodes-view-stat" data-testid="nodes-stat-docker">
<span><Box size={14} /> Docker</span>
<strong>{stats.docker}</strong>
</div>
</div> </div>
{error && <div className="nodes-view-error">{error}</div>} {error && <div className="nodes-view-error">{error}</div>}
<section className="nodes-view-topology" aria-label="Docker Nodes Summary">
<h3 className="nodes-view-section-title">Docker Nodes</h3>
<div className="nodes-view-stat">
<span>Managed Docker Nodes</span>
<strong>{dockerNodes.length}</strong>
<button className="btn btn-sm" onClick={() => setDockerOnboardingOpen(true)}>
Provision
</button>
</div>
</section>
{/* Mesh Topology Visualization */} {/* Mesh Topology Visualization */}
{!loading && nodes.length > 0 && ( {!loading && nodes.length > 0 && (
@@ -214,6 +226,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
syncStatus={nodeSyncStatus} syncStatus={nodeSyncStatus}
authSyncState={node.type === "remote" ? getAuthSyncState(node.id) : undefined} authSyncState={node.type === "remote" ? getAuthSyncState(node.id) : undefined}
authSyncProviders={node.type === "remote" ? getAuthProviders(node.id) : undefined} authSyncProviders={node.type === "remote" ? getAuthProviders(node.id) : undefined}
managedDockerNode={dockerNodeMap.get(node.id)}
/> />
); );
})} })}
@@ -248,6 +261,9 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
onPushSettings={pushSettings} onPushSettings={pushSettings}
onPullSettings={pullSettings} onPullSettings={pullSettings}
onSyncAuth={syncAuth} onSyncAuth={syncAuth}
managedDockerNode={selectedNode ? dockerNodeMap.get(selectedNode.id) : undefined}
onFetchContainerStatus={getContainerStatus}
onFetchLogs={getLogs}
/> />
</div> </div>
); );

View File

@@ -14,6 +14,7 @@ vi.mock("lucide-react", () => ({
Square: () => <span data-testid="square-icon">square</span>, Square: () => <span data-testid="square-icon">square</span>,
RotateCw: () => <span data-testid="rotate-icon">rotate</span>, RotateCw: () => <span data-testid="rotate-icon">rotate</span>,
Trash2: () => <span data-testid="trash-icon">trash</span>, Trash2: () => <span data-testid="trash-icon">trash</span>,
Box: () => <span data-testid="box-icon">box</span>,
})); }));
vi.mock("../../hooks/useNodeSettingsSync", () => ({ vi.mock("../../hooks/useNodeSettingsSync", () => ({
@@ -69,6 +70,21 @@ function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): Comput
} }
describe("NodeCard", () => { describe("NodeCard", () => {
const managedDockerNode = {
id: "dn-1",
nodeId: "node-1",
name: "Docker Node",
status: "running",
hostConfig: { type: "remote" as const, host: "tcp://docker:2376" },
envVars: {},
imageName: "runfusion/fusion",
imageTag: "latest",
volumeMounts: [],
persistentStorage: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("renders node name, type, status, project count, and concurrency", () => { it("renders node name, type, status, project count, and concurrency", () => {
const node = makeNode({ id: "node-abc", name: "Build Worker", type: "remote", status: "connecting", url: "https://remote.example.com" }); const node = makeNode({ id: "node-abc", name: "Build Worker", type: "remote", status: "connecting", url: "https://remote.example.com" });
const projects = [ const projects = [
@@ -547,6 +563,59 @@ describe("NodeCard", () => {
}); });
}); });
it("does not render docker badge without managed docker data", () => {
render(
<NodeCard
node={makeNode()}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
);
expect(screen.queryByText("Docker")).not.toBeInTheDocument();
});
it("renders docker badge and metadata with managed docker data", () => {
render(
<NodeCard
node={makeNode({ type: "remote", url: "https://node.example" })}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
managedDockerNode={managedDockerNode}
/>
);
expect(screen.getByText("Docker")).toBeInTheDocument();
expect(screen.getByTestId("box-icon")).toBeInTheDocument();
expect(screen.getByText("runfusion/fusion:latest")).toBeInTheDocument();
expect(screen.getByText("Remote: tcp://docker:2376")).toBeInTheDocument();
});
it.each([
["running", "Running", "node-card__status--online"],
["stopped", "Stopped", "node-card__status--offline"],
["creating", "Creating", "node-card__status--creating"],
["recreating", "Recreating", "node-card__status--recreating"],
["deleting", "Deleting", "node-card__status--deleting"],
])("maps docker status %s", (status, label, className) => {
render(
<NodeCard
node={makeNode({ type: "remote" })}
projects={[]}
onHealthCheck={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
managedDockerNode={{ ...managedDockerNode, status, updatedAt: `${status}` }}
/>
);
expect(screen.getByText(label).className).toContain(className);
});
describe("auth sync indicator", () => { describe("auth sync indicator", () => {
it("renders auth indicator for remote node with match state", () => { it("renders auth indicator for remote node with match state", () => {
const node = makeNode({ const node = makeNode({

View File

@@ -1,47 +1,30 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { NodeDetailModal } from "../NodeDetailModal"; import { NodeDetailModal } from "../NodeDetailModal";
import type { NodeInfo, ProjectInfo } from "../../api"; import type { ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync";
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => ({
Activity: () => <span data-testid="activity-icon">activity</span>, Activity: () => <span>activity</span>,
Download: () => <span data-testid="download-icon">download</span>, Download: () => <span>download</span>,
Pencil: () => <span data-testid="pencil-icon">pencil</span>, FileText: () => <span>file-text</span>,
Save: () => <span data-testid="save-icon">save</span>, Pencil: () => <span>pencil</span>,
Shield: () => <span data-testid="shield-icon">shield</span>, Play: () => <span>play</span>,
Upload: () => <span data-testid="upload-icon">upload</span>, RotateCcw: () => <span>rotate</span>,
X: () => <span data-testid="x-icon">x</span>, Save: () => <span>save</span>,
ChevronDown: () => <span data-testid="chevron-down">chevron</span>, Shield: () => <span>shield</span>,
Play: () => <span data-testid="play-icon">play</span>, Square: () => <span>square</span>,
Square: () => <span data-testid="square-icon">square</span>, Upload: () => <span>upload</span>,
RotateCw: () => <span data-testid="rotate-icon">rotate</span>, X: () => <span>x</span>,
})); ChevronDown: () => <span>chevron</span>,
vi.mock("../../hooks/useNodeSettingsSync", () => ({
formatRelativeTime: vi.fn((ts: string | null) => {
if (!ts) return "Never synced";
return "Synced 2m ago";
}),
getSyncStateColor: vi.fn((state: string) => {
switch (state) {
case "synced": return "var(--color-success)";
case "diff": return "var(--warning)";
case "error": return "var(--color-error)";
case "pending": return "var(--warning)";
case "never-synced": return "var(--text-muted)";
default: return "var(--text-muted)";
}
}),
})); }));
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo { function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return { return {
id: "node-1", id: "node-1",
name: "Test Node", name: "Node 1",
type: "remote", type: "remote",
status: "online", status: "online",
url: "https://test.example.com", url: "http://localhost:7777",
maxConcurrent: 2, maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
@@ -49,261 +32,100 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
}; };
} }
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo { function makeDockerNode(overrides: Partial<ManagedDockerNodeInfo> = {}): ManagedDockerNodeInfo {
return { return {
id: "proj-1", id: "mdn-1",
name: "Project One", nodeId: "node-1",
path: "/workspace/project-one", name: "Docker Node",
status: "active", containerId: "abcdef1234567890",
isolationMode: "in-process", status: "running",
hostConfig: { type: "local" },
envVars: { FUSION_TOKEN: "secret", FUSION_LOG_LEVEL: "info" },
reachableUrl: "http://localhost:7777",
imageName: "runfusion/fusion",
imageTag: "latest",
volumeMounts: [{ hostPath: "/host", containerPath: "/data", readOnly: true }],
persistentStorage: true,
resourceSizing: { cpuLimit: "2", memoryLimit: "4GB" },
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
...overrides, ...overrides,
}; };
} }
function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): ComputedNodeSyncStatus { const baseProps = {
return {
syncState: "synced",
lastSyncAt: new Date(Date.now() - 120000).toISOString(),
diffCount: 0,
...overrides,
};
}
const defaultProps = {
isOpen: true, isOpen: true,
onClose: vi.fn(), onClose: vi.fn(),
node: makeNode(), node: makeNode(),
projects: [], projects: [] as ProjectInfo[],
onUpdate: vi.fn().mockResolvedValue(undefined), onUpdate: vi.fn().mockResolvedValue(undefined),
onHealthCheck: vi.fn().mockResolvedValue(undefined), onHealthCheck: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(), addToast: vi.fn(),
}; };
describe("NodeDetailModal", () => { describe("NodeDetailModal docker section", () => {
beforeEach(() => { it("does not render docker section without managedDockerNode", () => {
vi.clearAllMocks(); render(<NodeDetailModal {...baseProps} />);
expect(screen.queryByText("Docker Management")).not.toBeInTheDocument();
}); });
describe("basic rendering", () => { it("renders docker section and disabled lifecycle actions", () => {
it("renders modal when isOpen is true", () => { render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} />);
render(<NodeDetailModal {...defaultProps} />); expect(screen.getByText("Docker Management")).toBeInTheDocument();
expect(screen.getByRole("dialog")).toBeInTheDocument(); const lifecycleButtons = screen.getAllByTitle("Available after FN-3113");
}); expect(lifecycleButtons).toHaveLength(3);
lifecycleButtons.forEach((button) => expect(button).toBeDisabled());
it("does not render modal when isOpen is false", () => {
render(<NodeDetailModal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("renders node name in dialog title", () => {
const node = makeNode({ name: "Custom Node Name" });
render(<NodeDetailModal {...defaultProps} node={node} />);
expect(screen.getByRole("dialog", { name: "Node details for Custom Node Name" })).toBeInTheDocument();
});
it("renders Overview, Projects, Health, and Settings Sync sections for remote nodes", () => {
render(<NodeDetailModal {...defaultProps} />);
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText(/^Assigned Projects \(\d+\)$/)).toBeInTheDocument();
expect(screen.getByText("Health")).toBeInTheDocument();
expect(screen.queryByText("Docker Management")).not.toBeInTheDocument();
expect(screen.getByText("Settings Sync")).toBeInTheDocument();
});
it("renders Docker Management section for managed docker nodes", () => {
const dockerManagedNode = makeNode({ capabilities: ["docker-managed"] });
render(<NodeDetailModal {...defaultProps} node={dockerManagedNode} />);
expect(screen.getByText("Docker Management")).toBeInTheDocument();
});
it("does not render Settings Sync section for local nodes", () => {
const localNode = makeNode({ type: "local" });
render(<NodeDetailModal {...defaultProps} node={localNode} />);
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText(/^Projects \(\d+\)$/)).toBeInTheDocument();
expect(screen.getByText("Health")).toBeInTheDocument();
expect(screen.queryByText("Settings Sync")).not.toBeInTheDocument();
});
}); });
describe("Settings Sync section", () => { it("loads and displays logs", async () => {
it("renders Push Settings, Pull Settings, and Sync Auth buttons for remote nodes", () => { const onFetchLogs = vi.fn().mockResolvedValue("line-1\nline-2");
render(<NodeDetailModal {...defaultProps} />); render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} onFetchLogs={onFetchLogs} />);
expect(screen.getByText("Push Settings")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /view logs/i }));
expect(screen.getByText("Pull Settings")).toBeInTheDocument(); expect(await screen.findByText((content) => content.includes("line-1") && content.includes("line-2"))).toBeInTheDocument();
expect(screen.getByText("Sync Auth")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Close logs"));
}); await waitFor(() => expect(screen.queryByText("Container Logs")).not.toBeInTheDocument());
});
it("displays sync status with last sync time", () => { it("renders status card and info grid values", () => {
const syncStatus = makeSyncStatus({ syncState: "synced" }); render(
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />); <NodeDetailModal
expect(screen.getByText(/Last sync:/)).toBeInTheDocument(); {...baseProps}
}); managedDockerNode={makeDockerNode()}
containerStatus={{ running: true, status: "running", startedAt: "2026-01-01T00:00:00.000Z" }}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
expect(screen.getByText(/Uptime:/)).toBeInTheDocument();
expect(screen.getByText("runfusion/fusion:latest")).toBeInTheDocument();
expect(screen.getByText("abcdef123456")).toBeInTheDocument();
expect(screen.getByText("Local Docker")).toBeInTheDocument();
expect(screen.getByText("Yes")).toBeInTheDocument();
expect(screen.getByText("7777")).toBeInTheDocument();
expect(screen.getByText("2 / 4GB")).toBeInTheDocument();
});
it("displays diff count when available", () => { it("refreshes container status", async () => {
const syncStatus = makeSyncStatus({ syncState: "diff", diffCount: 3 }); const onFetchContainerStatus = vi.fn().mockResolvedValue({ running: false, status: "stopped", exitCode: 1 });
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />); render(
// The diff count is displayed with the "Differences:" label <NodeDetailModal
const diffElement = document.querySelector(".node-detail-modal__sync-diff"); {...baseProps}
expect(diffElement).toBeInTheDocument(); managedDockerNode={makeDockerNode()}
expect(diffElement?.textContent).toContain("Differences:"); onFetchContainerStatus={onFetchContainerStatus}
expect(diffElement?.textContent).toContain("3"); />,
}); );
fireEvent.click(screen.getByRole("button", { name: /refresh status/i }));
await waitFor(() => expect(onFetchContainerStatus).toHaveBeenCalledWith("mdn-1"));
expect(await screen.findByText("Stopped")).toBeInTheDocument();
expect(screen.getByText("Exit code: 1")).toBeInTheDocument();
});
it("displays 'Never synced' when lastSyncAt is null", () => { it("masks sensitive env values and shows read-only mount", () => {
const syncStatus = makeSyncStatus({ syncState: "never-synced", lastSyncAt: null }); render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} />);
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />); fireEvent.click(screen.getByText("Environment Variables"));
expect(screen.getByText(/Never synced/)).toBeInTheDocument(); expect(screen.getByText("••••••••")).toBeInTheDocument();
}); expect(screen.getByText("info")).toBeInTheDocument();
fireEvent.click(screen.getByText("Volume Mounts"));
it("Push Settings button calls onPushSettings", async () => { expect(screen.getByText("/host → /data")).toBeInTheDocument();
const onPushSettings = vi.fn().mockResolvedValue(undefined); expect(screen.getByText("Read-only")).toBeInTheDocument();
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
});
expect(onPushSettings).toHaveBeenCalledWith(defaultProps.node!.id);
});
it("Pull Settings button calls onPullSettings", async () => {
const onPullSettings = vi.fn().mockResolvedValue(undefined);
render(<NodeDetailModal {...defaultProps} onPullSettings={onPullSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Pull Settings"));
});
expect(onPullSettings).toHaveBeenCalledWith(defaultProps.node!.id);
});
it("Sync Auth button calls onSyncAuth", async () => {
const onSyncAuth = vi.fn().mockResolvedValue(undefined);
render(<NodeDetailModal {...defaultProps} onSyncAuth={onSyncAuth} />);
await act(async () => {
fireEvent.click(screen.getByText("Sync Auth"));
});
expect(onSyncAuth).toHaveBeenCalledWith(defaultProps.node!.id);
});
it("shows loading state on Push Settings button during operation", async () => {
const onPushSettings = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
});
expect(screen.getByText("Pushing...")).toBeInTheDocument();
});
it("shows loading state on Pull Settings button during operation", async () => {
const onPullSettings = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<NodeDetailModal {...defaultProps} onPullSettings={onPullSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Pull Settings"));
});
expect(screen.getByText("Pulling...")).toBeInTheDocument();
});
it("shows loading state on Sync Auth button during operation", async () => {
const onSyncAuth = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<NodeDetailModal {...defaultProps} onSyncAuth={onSyncAuth} />);
await act(async () => {
fireEvent.click(screen.getByText("Sync Auth"));
});
expect(screen.getByText("Syncing...")).toBeInTheDocument();
});
it("displays sync error when push operation fails", async () => {
const onPushSettings = vi.fn().mockRejectedValue(new Error("Push failed: connection refused"));
const addToast = vi.fn();
render(
<NodeDetailModal
{...defaultProps}
onPushSettings={onPushSettings}
addToast={addToast}
/>
);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Push failed: connection refused/)).toBeInTheDocument();
});
it("displays sync error when pull operation fails", async () => {
const onPullSettings = vi.fn().mockRejectedValue(new Error("Pull failed: timeout"));
const addToast = vi.fn();
render(
<NodeDetailModal
{...defaultProps}
onPullSettings={onPullSettings}
addToast={addToast}
/>
);
await act(async () => {
fireEvent.click(screen.getByText("Pull Settings"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Pull failed: timeout/)).toBeInTheDocument();
});
it("displays sync error when auth sync operation fails", async () => {
const onSyncAuth = vi.fn().mockRejectedValue(new Error("Auth sync failed"));
const addToast = vi.fn();
render(
<NodeDetailModal
{...defaultProps}
onSyncAuth={onSyncAuth}
addToast={addToast}
/>
);
await act(async () => {
fireEvent.click(screen.getByText("Sync Auth"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Auth sync failed/)).toBeInTheDocument();
});
it("dismisses sync error when dismiss button is clicked", async () => {
const onPushSettings = vi.fn().mockRejectedValue(new Error("Push failed"));
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
await act(async () => {
fireEvent.click(screen.getByText("Push Settings"));
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(screen.getByText(/Push failed/)).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByLabelText("Dismiss error"));
});
expect(screen.queryByText(/Push failed/)).not.toBeInTheDocument();
});
it("buttons are disabled when no handlers provided", () => {
render(<NodeDetailModal {...defaultProps} />);
expect(screen.getByText("Push Settings")).toBeDisabled();
expect(screen.getByText("Pull Settings")).toBeDisabled();
expect(screen.getByText("Sync Auth")).toBeDisabled();
});
}); });
}); });

View File

@@ -120,11 +120,47 @@ beforeEach(() => {
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn().mockResolvedValue(undefined), refresh: vi.fn().mockResolvedValue(undefined),
getContainerStatus: vi.fn().mockResolvedValue({ running: true, status: "running" }),
getLogs: vi.fn().mockResolvedValue(""),
create: vi.fn().mockResolvedValue(undefined), create: vi.fn().mockResolvedValue(undefined),
}); });
}); });
describe("NodesView", () => { describe("NodesView", () => {
it("renders docker stat and passes docker data to matching node card", () => {
mockUseNodes.mockReturnValue(makeUseNodesResult({
nodes: [makeNode({ id: "node-1", name: "Alpha", type: "remote", url: "https://alpha.node" })],
}));
mockUseManagedDockerNodes.mockReturnValue({
dockerNodes: [{
id: "mdn-1",
nodeId: "node-1",
name: "Docker Alpha",
status: "running",
hostConfig: { type: "local" },
envVars: {},
imageName: "runfusion/fusion",
imageTag: "latest",
volumeMounts: [],
persistentStorage: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
getContainerStatus: vi.fn().mockResolvedValue({ running: true, status: "running" }),
getLogs: vi.fn().mockResolvedValue(""),
create: vi.fn().mockResolvedValue(undefined),
});
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByTestId("nodes-stat-docker").textContent).toContain("1");
expect(screen.getAllByText("Docker").length).toBeGreaterThan(0);
expect(screen.getByText("runfusion/fusion:latest")).toBeInTheDocument();
});
it("renders node cards and stats", () => { it("renders node cards and stats", () => {
mockUseProjects.mockReturnValue({ mockUseProjects.mockReturnValue({
projects: [makeProject({ nodeId: "node-1" }), makeProject({ id: "proj-2", nodeId: "node-2" })], projects: [makeProject({ nodeId: "node-1" }), makeProject({ id: "proj-2", nodeId: "node-2" })],

View File

@@ -35,10 +35,10 @@ describe("nodes-view mobile CSS", () => {
const cssContent = loadAllAppCss(); const cssContent = loadAllAppCss();
const mobileMediaBlock = extractMobileMediaBlocks(cssContent); const mobileMediaBlock = extractMobileMediaBlocks(cssContent);
it("defines .nodes-view-header with compact padding on mobile", () => { it("defines .nodes-view-header mobile wrapping without extra padding", () => {
expect(mobileMediaBlock).toContain(".nodes-view-header"); expect(mobileMediaBlock).toContain(".nodes-view-header");
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-header"); const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-header");
expect(block).toMatch(/padding:\s*var\(--space-sm\)\s+var\(--space-md\)/); expect(block).not.toContain("padding:");
expect(block).toContain("flex-wrap: wrap"); expect(block).toContain("flex-wrap: wrap");
}); });

View File

@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useManagedDockerNodes } from "../useManagedDockerNodes";
import * as api from "../../api";
import type { ManagedDockerNodeInfo } from "../../api";
vi.mock("../../api", () => ({
fetchManagedDockerNodes: vi.fn(),
fetchManagedDockerNodeContainerStatus: vi.fn(),
fetchDockerNodeLogs: vi.fn(),
createManagedDockerNode: vi.fn(),
}));
const mockFetchManagedDockerNodes = vi.mocked(api.fetchManagedDockerNodes);
const mockFetchManagedDockerNodeContainerStatus = vi.mocked(api.fetchManagedDockerNodeContainerStatus);
const mockFetchDockerNodeLogs = vi.mocked(api.fetchDockerNodeLogs);
function makeDockerNode(overrides: Partial<ManagedDockerNodeInfo> = {}): ManagedDockerNodeInfo {
return {
id: "dn-1",
name: "Docker Node",
status: "running",
hostConfig: { type: "local" },
envVars: {},
imageName: "runfusion/fusion",
imageTag: "latest",
volumeMounts: [],
persistentStorage: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("useManagedDockerNodes", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchManagedDockerNodes.mockReset();
mockFetchManagedDockerNodeContainerStatus.mockReset();
mockFetchDockerNodeLogs.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("fetches docker nodes on mount", async () => {
mockFetchManagedDockerNodes.mockResolvedValueOnce([makeDockerNode()]);
const { result } = renderHook(() => useManagedDockerNodes());
await act(async () => {
await flushPromises();
});
expect(result.current.loading).toBe(false);
expect(result.current.dockerNodes).toHaveLength(1);
});
it("polls every 15 seconds", async () => {
mockFetchManagedDockerNodes
.mockResolvedValueOnce([makeDockerNode({ name: "Before" })])
.mockResolvedValueOnce([makeDockerNode({ name: "After" })]);
const { result } = renderHook(() => useManagedDockerNodes());
await act(async () => {
await flushPromises();
});
await act(async () => {
vi.advanceTimersByTime(15000);
await flushPromises();
});
expect(result.current.dockerNodes[0]?.name).toBe("After");
});
it("gets container status", async () => {
mockFetchManagedDockerNodes.mockResolvedValueOnce([makeDockerNode()]);
mockFetchManagedDockerNodeContainerStatus.mockResolvedValueOnce({ running: true, status: "running" });
const { result } = renderHook(() => useManagedDockerNodes());
await act(async () => {
await flushPromises();
});
await expect(result.current.getContainerStatus("dn-1")).resolves.toEqual({ running: true, status: "running" });
});
it("gets docker logs string", async () => {
mockFetchManagedDockerNodes.mockResolvedValueOnce([makeDockerNode()]);
mockFetchDockerNodeLogs.mockResolvedValueOnce({ logs: "hello" });
const { result } = renderHook(() => useManagedDockerNodes());
await act(async () => {
await flushPromises();
});
await expect(result.current.getLogs("dn-1", { tail: 50 })).resolves.toBe("hello");
expect(mockFetchDockerNodeLogs).toHaveBeenCalledWith("dn-1", { tail: 50 });
});
it("keeps stale data on polling failure and sets error", async () => {
mockFetchManagedDockerNodes
.mockResolvedValueOnce([makeDockerNode({ name: "Stale" })])
.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => useManagedDockerNodes());
await act(async () => {
await flushPromises();
});
await act(async () => {
vi.advanceTimersByTime(15000);
await flushPromises();
});
expect(result.current.dockerNodes[0]?.name).toBe("Stale");
expect(result.current.error).toBe("boom");
});
it("refreshes on visibility change", async () => {
const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
mockFetchManagedDockerNodes
.mockResolvedValueOnce([makeDockerNode({ name: "Initial" })])
.mockResolvedValueOnce([makeDockerNode({ name: "Visible Again" })]);
const { result } = renderHook(() => useManagedDockerNodes());
await act(async () => {
await flushPromises();
});
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
await act(async () => {
vi.advanceTimersByTime(1100);
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
});
expect(result.current.dockerNodes[0]?.name).toBe("Visible Again");
if (originalVisibilityState) {
Object.defineProperty(document, "visibilityState", originalVisibilityState);
}
});
});

View File

@@ -1,25 +1,37 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { ManagedDockerNodeInput } from "@fusion/core"; import type { ManagedDockerNodeInput } from "@fusion/core";
import type { DockerNodeInfo } from "../api"; import type { ContainerStatusInfo, ManagedDockerNodeInfo } from "../api";
import { createManagedDockerNode, listManagedDockerNodes } from "../api"; import {
createManagedDockerNode,
fetchDockerNodeLogs,
fetchManagedDockerNodeContainerStatus,
fetchManagedDockerNodes,
} from "../api";
export interface UseManagedDockerNodesResult { export interface UseManagedDockerNodesResult {
dockerNodes: DockerNodeInfo[]; dockerNodes: ManagedDockerNodeInfo[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
create: (input: ManagedDockerNodeInput) => Promise<DockerNodeInfo>; getContainerStatus: (id: string) => Promise<ContainerStatusInfo>;
getLogs: (id: string, options?: { tail?: number }) => Promise<string>;
create: (input: ManagedDockerNodeInput) => Promise<ManagedDockerNodeInfo>;
} }
const POLL_INTERVAL_MS = 15000;
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
export function useManagedDockerNodes(): UseManagedDockerNodesResult { export function useManagedDockerNodes(): UseManagedDockerNodesResult {
const [dockerNodes, setDockerNodes] = useState<DockerNodeInfo[]>([]); const [dockerNodes, setDockerNodes] = useState<ManagedDockerNodeInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const lastVisibilityRefreshRef = useRef<number>(0);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
setError(null); setError(null);
const data = await listManagedDockerNodes(); const data = await fetchManagedDockerNodes();
setDockerNodes(data); setDockerNodes(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes"); setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes");
@@ -32,7 +44,7 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
async function load() { async function load() {
setLoading(true); setLoading(true);
try { try {
const data = await listManagedDockerNodes(); const data = await fetchManagedDockerNodes();
if (!cancelled) { if (!cancelled) {
setDockerNodes(data); setDockerNodes(data);
setError(null); setError(null);
@@ -49,16 +61,90 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
} }
void load(); void load();
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") {
return;
}
const now = Date.now();
if (now - lastVisibilityRefreshRef.current < VISIBILITY_REFRESH_DEBOUNCE_MS) {
return;
}
lastVisibilityRefreshRef.current = now;
void refresh();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => { return () => {
cancelled = true; cancelled = true;
document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [refresh]);
useEffect(() => {
if (loading) {
return;
}
intervalRef.current = setInterval(() => {
void refresh();
}, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [loading, refresh]);
const getContainerStatus = useCallback(async (id: string): Promise<ContainerStatusInfo> => {
return fetchManagedDockerNodeContainerStatus(id);
}, []); }, []);
const create = useCallback(async (input: ManagedDockerNodeInput): Promise<DockerNodeInfo> => { const getLogs = useCallback(async (id: string, options?: { tail?: number }): Promise<string> => {
const node = await createManagedDockerNode(input); const result = await fetchDockerNodeLogs(id, options);
setDockerNodes((previous) => [...previous, node]); return result.logs;
return node;
}, []); }, []);
return { dockerNodes, loading, error, refresh, create }; const create = useCallback(async (input: ManagedDockerNodeInput): Promise<ManagedDockerNodeInfo> => {
const created = await createManagedDockerNode(input);
const normalized = {
...created,
nodeId: created.nodeId ?? undefined,
containerId: created.containerId ?? undefined,
status: created.status,
hostConfig: {
type: created.hostConfig.host || created.hostConfig.context ? "remote" : "local",
host: created.hostConfig.host,
context: created.hostConfig.context,
},
reachableUrl: created.reachableUrl ?? undefined,
volumeMounts: created.volumeMounts.map((mount) => ({
hostPath: mount.hostPath,
containerPath: mount.containerPath,
readOnly: mount.mode === "ro" ? true : undefined,
})),
persistentStorage: created.persistentStorage,
resourceSizing: {
cpuLimit: created.resourceSizing.cpus !== undefined ? String(created.resourceSizing.cpus) : undefined,
memoryLimit: created.resourceSizing.memoryMB !== undefined ? `${created.resourceSizing.memoryMB}MB` : undefined,
},
errorMessage: created.errorMessage ?? undefined,
} satisfies ManagedDockerNodeInfo;
setDockerNodes((previous) => [...previous, normalized]);
return normalized;
}, []);
return {
dockerNodes,
loading,
error,
refresh,
getContainerStatus,
getLogs,
create,
};
} }

View File

@@ -9,6 +9,8 @@ const service = {
listContexts: vi.fn(), listContexts: vi.fn(),
testConnection: vi.fn(), testConnection: vi.fn(),
recreateContainer: vi.fn(), recreateContainer: vi.fn(),
getContainerInfo: vi.fn(),
getContainerLogs: vi.fn(),
}; };
const mockCentralInstance = { const mockCentralInstance = {
@@ -99,6 +101,98 @@ describe("registerDockerNodeRoutes", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect((res.body as any).available).toBe(false); expect((res.body as any).available).toBe(false);
}); });
it("GET /api/docker/nodes returns enriched list", async () => {
mockCentralInstance.listManagedDockerNodes = vi.fn().mockResolvedValue([
{
id: "mdn-1",
nodeId: "node-1",
name: "Docker One",
status: "running",
hostConfig: {},
envVars: {},
imageName: "runfusion/fusion",
imageTag: "latest",
volumeMounts: [],
persistentStorage: true,
resourceSizing: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]);
mockCentralInstance.getNode.mockResolvedValue({ id: "node-1", name: "Linked", type: "remote", status: "online", maxConcurrent: 2, createdAt: "x", updatedAt: "x" });
const res = await request(app(), "GET", "/api/docker/nodes");
expect(res.status).toBe(200);
expect((res.body as any)[0].linkedNode.id).toBe("node-1");
});
it("GET /api/docker/nodes/:id/container-status handles success and errors", async () => {
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
service.getContainerInfo.mockResolvedValueOnce({ state: { running: true }, status: "running" });
const ok = await request(app(), "GET", "/api/docker/nodes/mdn-1/container-status");
expect(ok.status).toBe(200);
expect((ok.body as any).status).toBe("running");
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce(undefined);
const notFound = await request(app(), "GET", "/api/docker/nodes/missing/container-status");
expect(notFound.status).toBe(404);
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "creating", hostConfig: {}, containerId: null });
const bad = await request(app(), "GET", "/api/docker/nodes/mdn-1/container-status");
expect(bad.status).toBe(400);
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
service.getContainerInfo.mockRejectedValueOnce(new Error("down"));
const unavailable = await request(app(), "GET", "/api/docker/nodes/mdn-1/container-status");
expect(unavailable.status).toBe(503);
});
it("GET /api/docker/nodes/:id/logs clamps tail and handles errors", async () => {
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
service.getContainerLogs.mockResolvedValueOnce("hello");
const ok = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs?tail=5000");
expect(ok.status).toBe(200);
expect(service.getContainerLogs).toHaveBeenCalledWith("c1", {}, { tail: 1000 });
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce(undefined);
const notFound = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs");
expect(notFound.status).toBe(404);
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "creating", hostConfig: {}, containerId: null });
const noContainer = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs");
expect(noContainer.status).toBe(400);
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
service.getContainerLogs.mockRejectedValueOnce(new Error("down"));
const bad = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs");
expect(bad.status).toBe(503);
});
it("GET /api/docker/nodes/:id returns enriched node and 404", async () => {
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({
id: "mdn-1",
nodeId: "node-1",
name: "Docker One",
status: "running",
hostConfig: {},
envVars: {},
imageName: "runfusion/fusion",
imageTag: "latest",
volumeMounts: [],
persistentStorage: true,
resourceSizing: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockCentralInstance.getNode.mockResolvedValueOnce({ id: "node-1", name: "Linked", type: "remote", status: "online", maxConcurrent: 2, createdAt: "x", updatedAt: "x" });
const ok = await request(app(), "GET", "/api/docker/nodes/mdn-1");
expect(ok.status).toBe(200);
expect((ok.body as any).linkedNode.id).toBe("node-1");
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce(undefined);
const missing = await request(app(), "GET", "/api/docker/nodes/missing");
expect(missing.status).toBe(404);
});
}); });
describe("Mesh config routes", () => { describe("Mesh config routes", () => {

View File

@@ -1,4 +1,12 @@
import type { DockerExtraCli, DockerHostConfig, DockerVolumeMount, ManagedDockerNodeInput, FullProvisioningInput } from "@fusion/core"; import type {
DockerExtraCli,
DockerHostConfig,
DockerVolumeMount,
FullProvisioningInput,
ManagedDockerNode,
ManagedDockerNodeInput,
NodeConfig,
} from "@fusion/core";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ApiError, badRequest, notFound } from "../api-error.js"; import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js"; import type { ApiRouteRegistrar } from "./types.js";
@@ -55,6 +63,33 @@ function sanitizeExtraClis(input: unknown): DockerExtraCli[] {
return input as DockerExtraCli[]; return input as DockerExtraCli[];
} }
function toManagedDockerNodeInfo(managedNode: ManagedDockerNode, linkedNode?: NodeConfig) {
return {
...managedNode,
hostConfig: {
type: managedNode.hostConfig.host || managedNode.hostConfig.context ? "remote" : "local",
host: managedNode.hostConfig.host,
context: managedNode.hostConfig.context,
tlsOptions: {
tlsVerify: managedNode.hostConfig.tlsVerify,
tlsCaPath: managedNode.hostConfig.tlsCaPath,
tlsCertPath: managedNode.hostConfig.tlsCertPath,
tlsKeyPath: managedNode.hostConfig.tlsKeyPath,
},
},
volumeMounts: managedNode.volumeMounts.map((mount) => ({
hostPath: mount.hostPath,
containerPath: mount.containerPath,
readOnly: mount.mode === "ro" ? true : undefined,
})),
resourceSizing: {
cpuLimit: managedNode.resourceSizing?.cpus !== undefined ? String(managedNode.resourceSizing.cpus) : undefined,
memoryLimit: managedNode.resourceSizing?.memoryMB !== undefined ? `${managedNode.resourceSizing.memoryMB}MB` : undefined,
},
linkedNode: linkedNode ?? undefined,
};
}
export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => { export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
const { router, rethrowAsApiError } = ctx; const { router, rethrowAsApiError } = ctx;
@@ -102,6 +137,132 @@ export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
} }
}); });
router.get("/docker/nodes", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
try {
const nodes = await central.listManagedDockerNodes();
const enriched = await Promise.all(nodes.map(async (managedNode) => {
const linkedNode = managedNode.nodeId ? await central.getNode(managedNode.nodeId) : undefined;
return toManagedDockerNodeInfo(managedNode, linkedNode);
}));
enriched.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
res.json(enriched);
} finally {
await central.close();
}
} catch (error: unknown) {
if (error instanceof ApiError) {
throw error;
}
rethrowAsApiError(error);
}
});
router.get("/docker/nodes/:managedId/container-status", async (req, res) => {
try {
const { CentralCore, DockerClientService } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
try {
const managedNode = await central.getManagedDockerNode(req.params.managedId);
if (!managedNode) {
throw notFound("Managed Docker node not found");
}
if (!managedNode.containerId) {
throw badRequest(`Node has no container yet (status: ${managedNode.status})`);
}
try {
const dockerService = new DockerClientService(managedNode.hostConfig);
const info = await dockerService.getContainerInfo(managedNode.containerId, managedNode.hostConfig);
if (!info) {
throw notFound("Container not found");
}
res.json({
running: info.state.running,
status: info.status,
startedAt: info.state.startedAt,
finishedAt: info.state.finishedAt,
exitCode: info.state.exitCode,
error: info.state.error,
ports: info.ports,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.status(503).json({ error: `Docker unreachable: ${message}` });
}
} finally {
await central.close();
}
} catch (error: unknown) {
if (error instanceof ApiError) {
throw error;
}
rethrowAsApiError(error);
}
});
router.get("/docker/nodes/:managedId/logs", async (req, res) => {
try {
const { CentralCore, DockerClientService } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
try {
const managedNode = await central.getManagedDockerNode(req.params.managedId);
if (!managedNode) {
throw notFound("Managed Docker node not found");
}
if (!managedNode.containerId) {
throw badRequest(`Node has no container yet (status: ${managedNode.status})`);
}
const tailValue = Number(req.query.tail ?? 100);
const tail = Number.isFinite(tailValue) ? Math.max(1, Math.min(1000, Math.floor(tailValue))) : 100;
try {
const dockerService = new DockerClientService(managedNode.hostConfig);
const logs = await dockerService.getContainerLogs(managedNode.containerId, managedNode.hostConfig, { tail });
res.json({ logs });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.status(503).json({ error: `Docker unreachable: ${message}` });
}
} finally {
await central.close();
}
} catch (error: unknown) {
if (error instanceof ApiError) {
throw error;
}
rethrowAsApiError(error);
}
});
router.get("/docker/nodes/:managedId", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
try {
const node = await central.getManagedDockerNode(req.params.managedId);
if (!node) {
throw notFound("Managed Docker node not found");
}
const linkedNode = node.nodeId ? await central.getNode(node.nodeId) : undefined;
res.json(toManagedDockerNodeInfo(node, linkedNode));
} finally {
await central.close();
}
} catch (error: unknown) {
if (error instanceof ApiError) {
throw error;
}
rethrowAsApiError(error);
}
});
router.get("/docker-nodes", async (_req, res) => { router.get("/docker-nodes", async (_req, res) => {
try { try {
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");