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:
5
.changeset/fn-3112-docker-node-ui.md
Normal file
5
.changeset/fn-3112-docker-node-ui.md
Normal 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.
|
||||
@@ -165,10 +165,17 @@ export class DockerClientService {
|
||||
return this.createDockerInstance(hostConfig);
|
||||
}
|
||||
|
||||
async getContainerInfo(containerId: string): Promise<DockerContainerInspectResult | null> {
|
||||
async getContainerInfo(containerId: string, hostConfig?: DockerHostConfig): Promise<DockerContainerInspectResult | null> {
|
||||
try {
|
||||
const docker = await this.getInstance();
|
||||
const docker = await this.getDockerInstance(hostConfig);
|
||||
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 {
|
||||
id: inspect.Id,
|
||||
name: (inspect.Name ?? "").replace(/^\//, ""),
|
||||
@@ -181,7 +188,11 @@ export class DockerClientService {
|
||||
restarting: Boolean(inspect.State?.Restarting),
|
||||
dead: Boolean(inspect.State?.Dead),
|
||||
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) {
|
||||
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> {
|
||||
if (!this.dockerInstance) {
|
||||
this.dockerInstance = await this.createDockerInstance(this.defaultHostConfig);
|
||||
|
||||
@@ -2705,7 +2705,12 @@ export interface DockerContainerInspectResult {
|
||||
restarting: boolean;
|
||||
dead: boolean;
|
||||
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 */
|
||||
|
||||
@@ -5311,6 +5311,41 @@ export interface DockerNodeInfo {
|
||||
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 */
|
||||
export interface DiscoveredNodeInfo {
|
||||
name: string;
|
||||
@@ -5457,6 +5492,27 @@ export function listManagedDockerNodes(): Promise<DockerNodeInfo[]> {
|
||||
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 */
|
||||
export function createManagedDockerNode(input: ManagedDockerNodeInput): Promise<DockerNodeInfo> {
|
||||
return api<DockerNodeInfo>("/docker-nodes", {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { Activity, Play, RotateCw, Server, Settings, Shield, Square, Trash2 } from "lucide-react";
|
||||
import type { NodeInfo, ProjectInfo } from "../api";
|
||||
import { Activity, Box, Play, RotateCw, Server, Settings, Shield, Square, Trash2 } from "lucide-react";
|
||||
import type { ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../api";
|
||||
import { getProjectCountForNode } from "../utils/nodeProjectAssignment";
|
||||
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
||||
import { formatRelativeTime, getSyncStateColor } from "../hooks/useNodeSettingsSync";
|
||||
@@ -17,13 +17,20 @@ export interface NodeCardProps {
|
||||
authSyncState?: "match" | "differs" | "not-synced";
|
||||
/** Per-provider auth match details for tooltip. Map of provider name (e.g. "anthropic") to its match status. */
|
||||
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" },
|
||||
offline: { label: "Offline", color: "var(--color-error)", className: "node-card__status--offline" },
|
||||
connecting: { label: "Connecting", color: "var(--color-warning)", className: "node-card__status--connecting" },
|
||||
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> = {
|
||||
@@ -68,6 +75,16 @@ function areNodeCardPropsEqual(previous: NodeCardProps, next: NodeCardProps): bo
|
||||
if (prevNode.updatedAt !== nextNode.updatedAt) 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
|
||||
const prevSync = previous.syncStatus;
|
||||
const nextSync = next.syncStatus;
|
||||
@@ -113,9 +130,14 @@ function NodeCardInner({
|
||||
syncStatus,
|
||||
authSyncState,
|
||||
authSyncProviders,
|
||||
managedDockerNode,
|
||||
}: NodeCardProps) {
|
||||
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(() => {
|
||||
return getProjectCountForNode(projects, node);
|
||||
@@ -171,6 +193,12 @@ function NodeCardInner({
|
||||
<h3 className="node-card__name" title={node.name}>{node.name}</h3>
|
||||
<div className="node-card__meta-row">
|
||||
<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
|
||||
className={`node-card__status ${statusConfig.className}`}
|
||||
style={{ color: statusConfig.color }}
|
||||
@@ -179,6 +207,16 @@ function NodeCardInner({
|
||||
<span className="node-card__status-indicator" style={{ backgroundColor: statusConfig.color }} aria-hidden />
|
||||
{statusConfig.label}
|
||||
</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 && (
|
||||
<span
|
||||
className={`node-card__auth-indicator node-card__auth-indicator--${authSyncState}`}
|
||||
@@ -200,6 +238,14 @@ function NodeCardInner({
|
||||
{truncateUrl(node.url)}
|
||||
</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__metric">
|
||||
@@ -256,41 +302,42 @@ function NodeCardInner({
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
aria-label="Start node container"
|
||||
title="Start Container"
|
||||
>
|
||||
<Play size={14} />
|
||||
<span>Start</span>
|
||||
</button>
|
||||
{managedDockerNode && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label="Start node container"
|
||||
title="Available after FN-3113"
|
||||
>
|
||||
<Play size={14} />
|
||||
<span>Start</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
aria-label="Stop node container"
|
||||
title="Stop Container"
|
||||
>
|
||||
<Square size={14} />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label="Stop node container"
|
||||
title="Available after FN-3113"
|
||||
>
|
||||
<Square size={14} />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
aria-label="Restart node container"
|
||||
title="Restart Container"
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label="Restart node container"
|
||||
title="Available after FN-3113"
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn btn-sm node-card__action node-card__action--remove ${removeArmed ? "btn-danger is-armed" : ""}`}
|
||||
|
||||
@@ -183,6 +183,117 @@
|
||||
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) {
|
||||
.node-detail-modal__grid,
|
||||
.node-detail-modal__docker-grid {
|
||||
@@ -192,4 +303,30 @@
|
||||
.node-detail-modal__field--full {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Activity, Download, Pencil, Save, Shield, Upload, X } from "lucide-react";
|
||||
import type { NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
|
||||
import {
|
||||
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 { getProjectsForNode } from "../utils/nodeProjectAssignment";
|
||||
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
||||
@@ -23,12 +35,16 @@ interface NodeDetailModalProps {
|
||||
onPushSettings?: (nodeId: string) => Promise<unknown>;
|
||||
onPullSettings?: (nodeId: string) => Promise<unknown>;
|
||||
onSyncAuth?: (nodeId: string) => Promise<unknown>;
|
||||
/** Sync history entries for this node */
|
||||
syncHistory?: SyncLogEntry[];
|
||||
/** Called when sync conflicts need resolution */
|
||||
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 {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
@@ -36,6 +52,21 @@ function formatTimestamp(value?: string): string {
|
||||
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 {
|
||||
switch (syncState) {
|
||||
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({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -66,6 +123,10 @@ export function NodeDetailModal({
|
||||
onSyncAuth,
|
||||
syncHistory = [],
|
||||
onResolveConflicts,
|
||||
managedDockerNode,
|
||||
containerStatus,
|
||||
onFetchContainerStatus,
|
||||
onFetchLogs,
|
||||
}: NodeDetailModalProps) {
|
||||
const isMountedRef = useRef(true);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -75,18 +136,19 @@ export function NodeDetailModal({
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Sync action states
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [isSyncingAuth, setIsSyncingAuth] = useState(false);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
|
||||
// Conflict resolution modal state
|
||||
const [showConflictModal, setShowConflictModal] = useState(false);
|
||||
const [conflicts] = useState<SettingsConflictEntry[]>([]);
|
||||
const [dockerStatus, setDockerStatus] = useState<"running" | "stopped" | "recreating">("running");
|
||||
const [dockerEnv, setDockerEnv] = useState("FUSION_LOG_LEVEL=info");
|
||||
const [dockerMounts, setDockerMounts] = useState("/srv/fusion:/data:rw");
|
||||
|
||||
const [liveContainerStatus, setLiveContainerStatus] = useState<ContainerStatusInfo | undefined>(containerStatus);
|
||||
const [isRefreshingContainerStatus, setIsRefreshingContainerStatus] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [logs, setLogs] = useState("");
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
@@ -95,9 +157,15 @@ export function NodeDetailModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLiveContainerStatus(containerStatus);
|
||||
}, [containerStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node || !isOpen) {
|
||||
setEditMode(false);
|
||||
setLogsOpen(false);
|
||||
setLogs("");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,10 +195,17 @@ export function NodeDetailModal({
|
||||
return getProjectsForNode(projects, node);
|
||||
}, [node, projects]);
|
||||
|
||||
const isManagedDockerNode = useMemo(
|
||||
() => (node?.capabilities as readonly string[] | undefined)?.includes("docker-managed") ?? false,
|
||||
[node],
|
||||
);
|
||||
const dockerHost = useMemo(() => {
|
||||
if (!managedDockerNode) return "—";
|
||||
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 () => {
|
||||
if (!node) return;
|
||||
@@ -210,14 +285,42 @@ export function NodeDetailModal({
|
||||
setSyncError(null);
|
||||
}, []);
|
||||
|
||||
const handleDockerLifecycle = useCallback((action: "start" | "stop" | "restart" | "recreate" | "upgrade") => {
|
||||
if (action === "start") setDockerStatus("running");
|
||||
if (action === "stop") setDockerStatus("stopped");
|
||||
if (action === "restart") setDockerStatus("running");
|
||||
if (action === "recreate") setDockerStatus("recreating");
|
||||
if (action === "upgrade") setDockerStatus("recreating");
|
||||
addToast(`Docker action queued: ${action}`, "success");
|
||||
}, [addToast]);
|
||||
const handleRefreshContainerStatus = useCallback(async () => {
|
||||
if (!managedDockerNode || !onFetchContainerStatus) return;
|
||||
setIsRefreshingContainerStatus(true);
|
||||
try {
|
||||
const result = await onFetchContainerStatus(managedDockerNode.id);
|
||||
if (!isMountedRef.current) return;
|
||||
setLiveContainerStatus(result);
|
||||
} 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 () => {
|
||||
if (!node || isSaving) return;
|
||||
@@ -267,6 +370,9 @@ export function NodeDetailModal({
|
||||
|
||||
if (!isOpen || !node) return null;
|
||||
|
||||
const effectiveDockerStatus = liveContainerStatus?.status ?? managedDockerNode?.status;
|
||||
const dockerStatusTone = getDockerStatusTone(effectiveDockerStatus);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose}>
|
||||
<div
|
||||
@@ -412,39 +518,88 @@ export function NodeDetailModal({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isManagedDockerNode && (
|
||||
<section className="node-detail-modal__section">
|
||||
{managedDockerNode && (
|
||||
<section className="node-detail-modal__section docker-management">
|
||||
<h4>Docker Management</h4>
|
||||
<div className="node-detail-modal__health-row">
|
||||
<span>Container: <strong>{dockerStatus}</strong></span>
|
||||
<span>Image: <strong>runfusion/fusion:latest</strong></span>
|
||||
|
||||
<div className="docker-management__status-card">
|
||||
<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 className="node-detail-modal__sync-actions">
|
||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("start")}>Start</button>
|
||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("stop")}>Stop</button>
|
||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("restart")}>Restart</button>
|
||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("recreate")}>Recreate</button>
|
||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("upgrade")}>Upgrade Image</button>
|
||||
|
||||
<div className="node-detail-modal__grid docker-management__info-grid">
|
||||
<div className="node-detail-modal__field"><span>Image</span><strong><code>{managedDockerNode.imageName}:{managedDockerNode.imageTag}</code></strong></div>
|
||||
<div className="node-detail-modal__field"><span>Container ID</span><strong><code>{managedDockerNode.containerId ? managedDockerNode.containerId.slice(0, 12) : "—"}</code></strong></div>
|
||||
<div className="node-detail-modal__field"><span>Host</span><strong>{dockerHost}</strong></div>
|
||||
<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 className="node-detail-modal__docker-grid">
|
||||
<label className="node-detail-modal__field">
|
||||
<span>Environment Variables</span>
|
||||
<textarea className="input node-detail-modal__textarea" value={dockerEnv} onChange={(event) => setDockerEnv(event.target.value)} />
|
||||
</label>
|
||||
<label className="node-detail-modal__field">
|
||||
<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 className="docker-management__actions">
|
||||
<button className="btn btn-sm" disabled title="Available after FN-3113"><Play size={14} />Start</button>
|
||||
<button className="btn btn-sm" disabled title="Available after FN-3113"><Square size={14} />Stop</button>
|
||||
<button className="btn btn-sm" disabled title="Available after FN-3113"><RotateCcw size={14} />Restart</button>
|
||||
<button className="btn btn-sm" onClick={() => void handleFetchLogs()} disabled={!onFetchLogs}><FileText size={14} />View Logs</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Settings Sync section — only for remote nodes */}
|
||||
{node.type === "remote" && (
|
||||
<section className="node-detail-modal__section">
|
||||
<h4>Settings Sync</h4>
|
||||
@@ -456,12 +611,7 @@ export function NodeDetailModal({
|
||||
aria-hidden
|
||||
/>
|
||||
<span>
|
||||
Last sync:{" "}
|
||||
<strong>
|
||||
{syncStatus.lastSyncAt
|
||||
? formatRelativeTime(syncStatus.lastSyncAt)
|
||||
: "Never synced"}
|
||||
</strong>
|
||||
Last sync: <strong>{syncStatus.lastSyncAt ? formatRelativeTime(syncStatus.lastSyncAt) : "Never synced"}</strong>
|
||||
</span>
|
||||
{syncStatus.diffCount > 0 && (
|
||||
<span className="node-detail-modal__sync-diff">
|
||||
@@ -472,29 +622,17 @@ export function NodeDetailModal({
|
||||
)}
|
||||
|
||||
<div className="node-detail-modal__sync-actions">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={handlePushSettings}
|
||||
disabled={isPushing || !onPushSettings}
|
||||
>
|
||||
<button className="btn btn-sm" onClick={handlePushSettings} disabled={isPushing || !onPushSettings}>
|
||||
<Upload size={14} />
|
||||
{isPushing ? "Pushing..." : "Push Settings"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={handlePullSettings}
|
||||
disabled={isPulling || !onPullSettings}
|
||||
>
|
||||
<button className="btn btn-sm" onClick={handlePullSettings} disabled={isPulling || !onPullSettings}>
|
||||
<Download size={14} />
|
||||
{isPulling ? "Pulling..." : "Pull Settings"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={handleSyncAuth}
|
||||
disabled={isSyncingAuth || !onSyncAuth}
|
||||
>
|
||||
<button className="btn btn-sm" onClick={handleSyncAuth} disabled={isSyncingAuth || !onSyncAuth}>
|
||||
<Shield size={14} />
|
||||
{isSyncingAuth ? "Syncing..." : "Sync Auth"}
|
||||
</button>
|
||||
@@ -503,11 +641,7 @@ export function NodeDetailModal({
|
||||
{syncError && (
|
||||
<div className="node-detail-modal__sync-error">
|
||||
<span>{syncError}</span>
|
||||
<button
|
||||
className="node-detail-modal__sync-error-dismiss"
|
||||
onClick={handleDismissSyncError}
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<button className="node-detail-modal__sync-error-dismiss" onClick={handleDismissSyncError} aria-label="Dismiss error">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -515,15 +649,10 @@ export function NodeDetailModal({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Sync History section — only for remote nodes */}
|
||||
{node.type === "remote" && (
|
||||
<section className="node-detail-modal__section">
|
||||
<h4>Sync History</h4>
|
||||
<SettingsSyncLog
|
||||
nodeId={node.id}
|
||||
entries={syncHistory}
|
||||
singleNode={true}
|
||||
/>
|
||||
<SettingsSyncLog nodeId={node.id} entries={syncHistory} singleNode={true} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
@@ -537,7 +666,6 @@ export function NodeDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict resolution modal — rendered outside main modal container */}
|
||||
{node.type === "remote" && (
|
||||
<SettingsSyncConflictModal
|
||||
isOpen={showConflictModal}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
}
|
||||
|
||||
.nodes-view {
|
||||
--nodes-view-spin-duration: calc(var(--transition-slow) * 4);
|
||||
--nodes-view-pulse-duration: calc(var(--transition-slow) * 5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
@@ -64,7 +66,7 @@
|
||||
.nodes-view-stat {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -101,7 +103,7 @@
|
||||
.nodes-view-empty {
|
||||
padding: var(--space-xl);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
@@ -118,7 +120,7 @@
|
||||
.node-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -304,6 +306,33 @@
|
||||
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 {
|
||||
@@ -533,7 +562,7 @@
|
||||
|
||||
.nodes-view .node-status-indicator__dot--connecting {
|
||||
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 {
|
||||
@@ -542,7 +571,7 @@
|
||||
border: calc(var(--space-xs) / 2) solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
animation: spin var(--nodes-view-spin-duration) linear infinite;
|
||||
}
|
||||
|
||||
.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 ──────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.nodes-view {
|
||||
@@ -579,6 +617,18 @@
|
||||
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 {
|
||||
width: calc(100vw - (var(--space-md) * 2));
|
||||
@@ -590,7 +640,6 @@
|
||||
|
||||
/* NodesView mobile alignment */
|
||||
.nodes-view-header {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
@@ -658,4 +707,5 @@
|
||||
font-size: calc(var(--space-md) + var(--space-xs) / 4);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { useNodes } from "../hooks/useNodes";
|
||||
import { useProjects } from "../hooks/useProjects";
|
||||
import { useNodeSettingsSync, computeSyncState } from "../hooks/useNodeSettingsSync";
|
||||
import type { NodeInfo, NodeUpdateInput } from "../api";
|
||||
import type { ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput } from "../api";
|
||||
import { NodeCard } from "./NodeCard";
|
||||
import { MeshTopology } from "./MeshTopology";
|
||||
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 { projects } = useProjects();
|
||||
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 [dockerOnboardingOpen, setDockerOnboardingOpen] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
|
||||
@@ -50,8 +57,9 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
const synced = nodes.filter(
|
||||
(node) => node.type === "remote" && syncStatusMap[node.id] && computeSyncState(syncStatusMap[node.id]).syncState === "synced"
|
||||
).length;
|
||||
return { total, online, offline, remote, synced };
|
||||
}, [nodes, syncStatusMap]);
|
||||
const docker = dockerNodes.length;
|
||||
return { total, online, offline, remote, synced, docker };
|
||||
}, [dockerNodes.length, nodes, syncStatusMap]);
|
||||
|
||||
const handleRegister = useCallback(async (input: AddNodeInput) => {
|
||||
await register(input);
|
||||
@@ -69,13 +77,23 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
}
|
||||
}, [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 () => {
|
||||
try {
|
||||
await refresh();
|
||||
await Promise.all([refresh(), refreshDocker()]);
|
||||
} catch {
|
||||
addToast("Failed to refresh nodes", "error");
|
||||
}
|
||||
}, [addToast, refresh]);
|
||||
}, [addToast, refresh, refreshDocker]);
|
||||
|
||||
const handleHealthCheck = useCallback(async (id: string) => {
|
||||
try {
|
||||
@@ -123,7 +141,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
>
|
||||
<X size={16} />
|
||||
</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" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
@@ -131,9 +149,9 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
<Plus size={14} />
|
||||
Add Node
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setDockerOnboardingOpen(true)}>
|
||||
<Plus size={14} />
|
||||
Provision Docker Node
|
||||
<button className="btn btn-sm" onClick={() => setDockerOnboardingOpen(true)} title="Add a managed Docker node">
|
||||
<Box size={14} />
|
||||
Add Docker Node
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,20 +177,14 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
<span><RefreshCw size={14} /> Synced</span>
|
||||
<strong>{stats.synced}</strong>
|
||||
</div>
|
||||
<div className="nodes-view-stat" data-testid="nodes-stat-docker">
|
||||
<span><Box size={14} /> Docker</span>
|
||||
<strong>{stats.docker}</strong>
|
||||
</div>
|
||||
</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 */}
|
||||
{!loading && nodes.length > 0 && (
|
||||
@@ -214,6 +226,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
syncStatus={nodeSyncStatus}
|
||||
authSyncState={node.type === "remote" ? getAuthSyncState(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}
|
||||
onPullSettings={pullSettings}
|
||||
onSyncAuth={syncAuth}
|
||||
managedDockerNode={selectedNode ? dockerNodeMap.get(selectedNode.id) : undefined}
|
||||
onFetchContainerStatus={getContainerStatus}
|
||||
onFetchLogs={getLogs}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock("lucide-react", () => ({
|
||||
Square: () => <span data-testid="square-icon">square</span>,
|
||||
RotateCw: () => <span data-testid="rotate-icon">rotate</span>,
|
||||
Trash2: () => <span data-testid="trash-icon">trash</span>,
|
||||
Box: () => <span data-testid="box-icon">box</span>,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useNodeSettingsSync", () => ({
|
||||
@@ -69,6 +70,21 @@ function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): Comput
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const node = makeNode({ id: "node-abc", name: "Build Worker", type: "remote", status: "connecting", url: "https://remote.example.com" });
|
||||
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", () => {
|
||||
it("renders auth indicator for remote node with match state", () => {
|
||||
const node = makeNode({
|
||||
|
||||
@@ -1,47 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { NodeDetailModal } from "../NodeDetailModal";
|
||||
import type { NodeInfo, ProjectInfo } from "../../api";
|
||||
import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync";
|
||||
import type { ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Activity: () => <span data-testid="activity-icon">activity</span>,
|
||||
Download: () => <span data-testid="download-icon">download</span>,
|
||||
Pencil: () => <span data-testid="pencil-icon">pencil</span>,
|
||||
Save: () => <span data-testid="save-icon">save</span>,
|
||||
Shield: () => <span data-testid="shield-icon">shield</span>,
|
||||
Upload: () => <span data-testid="upload-icon">upload</span>,
|
||||
X: () => <span data-testid="x-icon">x</span>,
|
||||
ChevronDown: () => <span data-testid="chevron-down">chevron</span>,
|
||||
Play: () => <span data-testid="play-icon">play</span>,
|
||||
Square: () => <span data-testid="square-icon">square</span>,
|
||||
RotateCw: () => <span data-testid="rotate-icon">rotate</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)";
|
||||
}
|
||||
}),
|
||||
Activity: () => <span>activity</span>,
|
||||
Download: () => <span>download</span>,
|
||||
FileText: () => <span>file-text</span>,
|
||||
Pencil: () => <span>pencil</span>,
|
||||
Play: () => <span>play</span>,
|
||||
RotateCcw: () => <span>rotate</span>,
|
||||
Save: () => <span>save</span>,
|
||||
Shield: () => <span>shield</span>,
|
||||
Square: () => <span>square</span>,
|
||||
Upload: () => <span>upload</span>,
|
||||
X: () => <span>x</span>,
|
||||
ChevronDown: () => <span>chevron</span>,
|
||||
}));
|
||||
|
||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||
return {
|
||||
id: "node-1",
|
||||
name: "Test Node",
|
||||
name: "Node 1",
|
||||
type: "remote",
|
||||
status: "online",
|
||||
url: "https://test.example.com",
|
||||
url: "http://localhost:7777",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "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 {
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
path: "/workspace/project-one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
id: "mdn-1",
|
||||
nodeId: "node-1",
|
||||
name: "Docker Node",
|
||||
containerId: "abcdef1234567890",
|
||||
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",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): ComputedNodeSyncStatus {
|
||||
return {
|
||||
syncState: "synced",
|
||||
lastSyncAt: new Date(Date.now() - 120000).toISOString(),
|
||||
diffCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
const baseProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
node: makeNode(),
|
||||
projects: [],
|
||||
projects: [] as ProjectInfo[],
|
||||
onUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
onHealthCheck: vi.fn().mockResolvedValue(undefined),
|
||||
addToast: vi.fn(),
|
||||
};
|
||||
|
||||
describe("NodeDetailModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
describe("NodeDetailModal docker section", () => {
|
||||
it("does not render docker section without managedDockerNode", () => {
|
||||
render(<NodeDetailModal {...baseProps} />);
|
||||
expect(screen.queryByText("Docker Management")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("basic rendering", () => {
|
||||
it("renders modal when isOpen is true", () => {
|
||||
render(<NodeDetailModal {...defaultProps} />);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
it("renders docker section and disabled lifecycle actions", () => {
|
||||
render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} />);
|
||||
expect(screen.getByText("Docker Management")).toBeInTheDocument();
|
||||
const lifecycleButtons = screen.getAllByTitle("Available after FN-3113");
|
||||
expect(lifecycleButtons).toHaveLength(3);
|
||||
lifecycleButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
});
|
||||
|
||||
describe("Settings Sync section", () => {
|
||||
it("renders Push Settings, Pull Settings, and Sync Auth buttons for remote nodes", () => {
|
||||
render(<NodeDetailModal {...defaultProps} />);
|
||||
expect(screen.getByText("Push Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pull Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sync Auth")).toBeInTheDocument();
|
||||
});
|
||||
it("loads and displays logs", async () => {
|
||||
const onFetchLogs = vi.fn().mockResolvedValue("line-1\nline-2");
|
||||
render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} onFetchLogs={onFetchLogs} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /view logs/i }));
|
||||
expect(await screen.findByText((content) => content.includes("line-1") && content.includes("line-2"))).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText("Close logs"));
|
||||
await waitFor(() => expect(screen.queryByText("Container Logs")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("displays sync status with last sync time", () => {
|
||||
const syncStatus = makeSyncStatus({ syncState: "synced" });
|
||||
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
|
||||
expect(screen.getByText(/Last sync:/)).toBeInTheDocument();
|
||||
});
|
||||
it("renders status card and info grid values", () => {
|
||||
render(
|
||||
<NodeDetailModal
|
||||
{...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", () => {
|
||||
const syncStatus = makeSyncStatus({ syncState: "diff", diffCount: 3 });
|
||||
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
|
||||
// The diff count is displayed with the "Differences:" label
|
||||
const diffElement = document.querySelector(".node-detail-modal__sync-diff");
|
||||
expect(diffElement).toBeInTheDocument();
|
||||
expect(diffElement?.textContent).toContain("Differences:");
|
||||
expect(diffElement?.textContent).toContain("3");
|
||||
});
|
||||
it("refreshes container status", async () => {
|
||||
const onFetchContainerStatus = vi.fn().mockResolvedValue({ running: false, status: "stopped", exitCode: 1 });
|
||||
render(
|
||||
<NodeDetailModal
|
||||
{...baseProps}
|
||||
managedDockerNode={makeDockerNode()}
|
||||
onFetchContainerStatus={onFetchContainerStatus}
|
||||
/>,
|
||||
);
|
||||
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", () => {
|
||||
const syncStatus = makeSyncStatus({ syncState: "never-synced", lastSyncAt: null });
|
||||
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
|
||||
expect(screen.getByText(/Never synced/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Push Settings button calls onPushSettings", async () => {
|
||||
const onPushSettings = vi.fn().mockResolvedValue(undefined);
|
||||
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();
|
||||
});
|
||||
it("masks sensitive env values and shows read-only mount", () => {
|
||||
render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} />);
|
||||
fireEvent.click(screen.getByText("Environment Variables"));
|
||||
expect(screen.getByText("••••••••")).toBeInTheDocument();
|
||||
expect(screen.getByText("info")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Volume Mounts"));
|
||||
expect(screen.getByText("/host → /data")).toBeInTheDocument();
|
||||
expect(screen.getByText("Read-only")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,11 +120,47 @@ beforeEach(() => {
|
||||
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),
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
mockUseProjects.mockReturnValue({
|
||||
projects: [makeProject({ nodeId: "node-1" }), makeProject({ id: "proj-2", nodeId: "node-2" })],
|
||||
|
||||
@@ -35,10 +35,10 @@ describe("nodes-view mobile CSS", () => {
|
||||
const cssContent = loadAllAppCss();
|
||||
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");
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 { DockerNodeInfo } from "../api";
|
||||
import { createManagedDockerNode, listManagedDockerNodes } from "../api";
|
||||
import type { ContainerStatusInfo, ManagedDockerNodeInfo } from "../api";
|
||||
import {
|
||||
createManagedDockerNode,
|
||||
fetchDockerNodeLogs,
|
||||
fetchManagedDockerNodeContainerStatus,
|
||||
fetchManagedDockerNodes,
|
||||
} from "../api";
|
||||
|
||||
export interface UseManagedDockerNodesResult {
|
||||
dockerNodes: DockerNodeInfo[];
|
||||
dockerNodes: ManagedDockerNodeInfo[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
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 {
|
||||
const [dockerNodes, setDockerNodes] = useState<DockerNodeInfo[]>([]);
|
||||
const [dockerNodes, setDockerNodes] = useState<ManagedDockerNodeInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await listManagedDockerNodes();
|
||||
const data = await fetchManagedDockerNodes();
|
||||
setDockerNodes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes");
|
||||
@@ -32,7 +44,7 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listManagedDockerNodes();
|
||||
const data = await fetchManagedDockerNodes();
|
||||
if (!cancelled) {
|
||||
setDockerNodes(data);
|
||||
setError(null);
|
||||
@@ -49,16 +61,90 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
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 node = await createManagedDockerNode(input);
|
||||
setDockerNodes((previous) => [...previous, node]);
|
||||
return node;
|
||||
const getLogs = useCallback(async (id: string, options?: { tail?: number }): Promise<string> => {
|
||||
const result = await fetchDockerNodeLogs(id, options);
|
||||
return result.logs;
|
||||
}, []);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ const service = {
|
||||
listContexts: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
recreateContainer: vi.fn(),
|
||||
getContainerInfo: vi.fn(),
|
||||
getContainerLogs: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCentralInstance = {
|
||||
@@ -99,6 +101,98 @@ describe("registerDockerNodeRoutes", () => {
|
||||
expect(res.status).toBe(200);
|
||||
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", () => {
|
||||
|
||||
@@ -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 { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
@@ -55,6 +63,33 @@ function sanitizeExtraClis(input: unknown): 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) => {
|
||||
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) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
|
||||
Reference in New Issue
Block a user