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);
|
return this.createDockerInstance(hostConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContainerInfo(containerId: string): Promise<DockerContainerInspectResult | null> {
|
async getContainerInfo(containerId: string, hostConfig?: DockerHostConfig): Promise<DockerContainerInspectResult | null> {
|
||||||
try {
|
try {
|
||||||
const docker = await this.getInstance();
|
const docker = await this.getDockerInstance(hostConfig);
|
||||||
const inspect = await docker.getContainer(containerId).inspect();
|
const inspect = await docker.getContainer(containerId).inspect();
|
||||||
|
const ports = Object.entries(inspect.NetworkSettings?.Ports ?? {}).reduce<Record<string, string>>((acc, [key, value]) => {
|
||||||
|
const binding = Array.isArray(value) && value.length > 0 ? value[0] : undefined;
|
||||||
|
if (binding?.HostPort) {
|
||||||
|
acc[key] = binding.HostPort;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
return {
|
return {
|
||||||
id: inspect.Id,
|
id: inspect.Id,
|
||||||
name: (inspect.Name ?? "").replace(/^\//, ""),
|
name: (inspect.Name ?? "").replace(/^\//, ""),
|
||||||
@@ -181,7 +188,11 @@ export class DockerClientService {
|
|||||||
restarting: Boolean(inspect.State?.Restarting),
|
restarting: Boolean(inspect.State?.Restarting),
|
||||||
dead: Boolean(inspect.State?.Dead),
|
dead: Boolean(inspect.State?.Dead),
|
||||||
error: inspect.State?.Error || undefined,
|
error: inspect.State?.Error || undefined,
|
||||||
|
exitCode: typeof inspect.State?.ExitCode === "number" ? inspect.State.ExitCode : undefined,
|
||||||
|
startedAt: inspect.State?.StartedAt || undefined,
|
||||||
|
finishedAt: inspect.State?.FinishedAt || undefined,
|
||||||
},
|
},
|
||||||
|
ports,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toErrorMessage(error);
|
const message = toErrorMessage(error);
|
||||||
@@ -192,6 +203,19 @@ export class DockerClientService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getContainerLogs(containerId: string, hostConfig?: DockerHostConfig, options?: { tail?: number }): Promise<string> {
|
||||||
|
const docker = await this.getDockerInstance(hostConfig);
|
||||||
|
const stream = await docker.getContainer(containerId).logs({
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
tail: options?.tail ?? 100,
|
||||||
|
});
|
||||||
|
if (Buffer.isBuffer(stream)) {
|
||||||
|
return stream.toString("utf8");
|
||||||
|
}
|
||||||
|
return String(stream ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
private async getInstance(): Promise<Docker> {
|
private async getInstance(): Promise<Docker> {
|
||||||
if (!this.dockerInstance) {
|
if (!this.dockerInstance) {
|
||||||
this.dockerInstance = await this.createDockerInstance(this.defaultHostConfig);
|
this.dockerInstance = await this.createDockerInstance(this.defaultHostConfig);
|
||||||
|
|||||||
@@ -2705,7 +2705,12 @@ export interface DockerContainerInspectResult {
|
|||||||
restarting: boolean;
|
restarting: boolean;
|
||||||
dead: boolean;
|
dead: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
};
|
};
|
||||||
|
/** Optional exposed ports summary */
|
||||||
|
ports?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Configuration for the Fusion Docker image to use for provisioning */
|
/** Configuration for the Fusion Docker image to use for provisioning */
|
||||||
|
|||||||
@@ -5311,6 +5311,41 @@ export interface DockerNodeInfo {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManagedDockerNodeInfo {
|
||||||
|
id: string;
|
||||||
|
nodeId?: string;
|
||||||
|
name: string;
|
||||||
|
containerId?: string;
|
||||||
|
status: string;
|
||||||
|
hostConfig: {
|
||||||
|
type: "local" | "remote";
|
||||||
|
host?: string;
|
||||||
|
context?: string;
|
||||||
|
tlsOptions?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
reachableUrl?: string;
|
||||||
|
imageName: string;
|
||||||
|
imageTag: string;
|
||||||
|
volumeMounts: Array<{ hostPath: string; containerPath: string; readOnly?: boolean }>;
|
||||||
|
persistentStorage: boolean;
|
||||||
|
resourceSizing?: { cpuLimit?: string; memoryLimit?: string };
|
||||||
|
errorMessage?: string;
|
||||||
|
linkedNode?: NodeInfo;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerStatusInfo {
|
||||||
|
running: boolean;
|
||||||
|
status: string;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
error?: string;
|
||||||
|
ports?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Node discovered over local network mDNS/DNS-SD */
|
/** Node discovered over local network mDNS/DNS-SD */
|
||||||
export interface DiscoveredNodeInfo {
|
export interface DiscoveredNodeInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5457,6 +5492,27 @@ export function listManagedDockerNodes(): Promise<DockerNodeInfo[]> {
|
|||||||
return api<DockerNodeInfo[]>("/docker-nodes");
|
return api<DockerNodeInfo[]>("/docker-nodes");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchManagedDockerNodes(): Promise<ManagedDockerNodeInfo[]> {
|
||||||
|
return api<ManagedDockerNodeInfo[]>("/docker/nodes");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchManagedDockerNode(id: string): Promise<ManagedDockerNodeInfo> {
|
||||||
|
return api<ManagedDockerNodeInfo>(`/docker/nodes/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchManagedDockerNodeContainerStatus(id: string): Promise<ContainerStatusInfo> {
|
||||||
|
return api<ContainerStatusInfo>(`/docker/nodes/${encodeURIComponent(id)}/container-status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDockerNodeLogs(id: string, options?: { tail?: number }): Promise<{ logs: string }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (typeof options?.tail === "number") {
|
||||||
|
params.set("tail", String(options.tail));
|
||||||
|
}
|
||||||
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
|
return api<{ logs: string }>(`/docker/nodes/${encodeURIComponent(id)}/logs${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a managed Docker node */
|
/** Create a managed Docker node */
|
||||||
export function createManagedDockerNode(input: ManagedDockerNodeInput): Promise<DockerNodeInfo> {
|
export function createManagedDockerNode(input: ManagedDockerNodeInput): Promise<DockerNodeInfo> {
|
||||||
return api<DockerNodeInfo>("/docker-nodes", {
|
return api<DockerNodeInfo>("/docker-nodes", {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo, useCallback, useMemo, useState } from "react";
|
import { memo, useCallback, useMemo, useState } from "react";
|
||||||
import { Activity, Play, RotateCw, Server, Settings, Shield, Square, Trash2 } from "lucide-react";
|
import { Activity, Box, Play, RotateCw, Server, Settings, Shield, Square, Trash2 } from "lucide-react";
|
||||||
import type { NodeInfo, ProjectInfo } from "../api";
|
import type { ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../api";
|
||||||
import { getProjectCountForNode } from "../utils/nodeProjectAssignment";
|
import { getProjectCountForNode } from "../utils/nodeProjectAssignment";
|
||||||
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
||||||
import { formatRelativeTime, getSyncStateColor } from "../hooks/useNodeSettingsSync";
|
import { formatRelativeTime, getSyncStateColor } from "../hooks/useNodeSettingsSync";
|
||||||
@@ -17,13 +17,20 @@ export interface NodeCardProps {
|
|||||||
authSyncState?: "match" | "differs" | "not-synced";
|
authSyncState?: "match" | "differs" | "not-synced";
|
||||||
/** Per-provider auth match details for tooltip. Map of provider name (e.g. "anthropic") to its match status. */
|
/** Per-provider auth match details for tooltip. Map of provider name (e.g. "anthropic") to its match status. */
|
||||||
authSyncProviders?: Record<string, "match" | "differs">;
|
authSyncProviders?: Record<string, "match" | "differs">;
|
||||||
|
managedDockerNode?: ManagedDockerNodeInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<NodeInfo["status"], { label: string; color: string; className: string }> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; className: string }> = {
|
||||||
online: { label: "Online", color: "var(--color-success)", className: "node-card__status--online" },
|
online: { label: "Online", color: "var(--color-success)", className: "node-card__status--online" },
|
||||||
offline: { label: "Offline", color: "var(--color-error)", className: "node-card__status--offline" },
|
offline: { label: "Offline", color: "var(--color-error)", className: "node-card__status--offline" },
|
||||||
connecting: { label: "Connecting", color: "var(--color-warning)", className: "node-card__status--connecting" },
|
connecting: { label: "Connecting", color: "var(--color-warning)", className: "node-card__status--connecting" },
|
||||||
error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" },
|
error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" },
|
||||||
|
creating: { label: "Creating", color: "var(--color-warning)", className: "node-card__status--creating" },
|
||||||
|
recreating: { label: "Recreating", color: "var(--color-warning)", className: "node-card__status--recreating" },
|
||||||
|
deleting: { label: "Deleting", color: "var(--color-error)", className: "node-card__status--deleting" },
|
||||||
|
running: { label: "Running", color: "var(--color-success)", className: "node-card__status--online" },
|
||||||
|
stopped: { label: "Stopped", color: "var(--color-error)", className: "node-card__status--offline" },
|
||||||
|
exited: { label: "Exited", color: "var(--color-error)", className: "node-card__status--offline" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTH_SYNC_COLORS: Record<string, string> = {
|
const AUTH_SYNC_COLORS: Record<string, string> = {
|
||||||
@@ -68,6 +75,16 @@ function areNodeCardPropsEqual(previous: NodeCardProps, next: NodeCardProps): bo
|
|||||||
if (prevNode.updatedAt !== nextNode.updatedAt) return false;
|
if (prevNode.updatedAt !== nextNode.updatedAt) return false;
|
||||||
if (previous.isLoading !== next.isLoading) return false;
|
if (previous.isLoading !== next.isLoading) return false;
|
||||||
|
|
||||||
|
const prevDocker = previous.managedDockerNode;
|
||||||
|
const nextDocker = next.managedDockerNode;
|
||||||
|
if (!!prevDocker !== !!nextDocker) return false;
|
||||||
|
if (prevDocker && nextDocker) {
|
||||||
|
if (prevDocker.id !== nextDocker.id) return false;
|
||||||
|
if (prevDocker.status !== nextDocker.status) return false;
|
||||||
|
if (prevDocker.imageTag !== nextDocker.imageTag) return false;
|
||||||
|
if (prevDocker.updatedAt !== nextDocker.updatedAt) return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Compare sync status
|
// Compare sync status
|
||||||
const prevSync = previous.syncStatus;
|
const prevSync = previous.syncStatus;
|
||||||
const nextSync = next.syncStatus;
|
const nextSync = next.syncStatus;
|
||||||
@@ -113,9 +130,14 @@ function NodeCardInner({
|
|||||||
syncStatus,
|
syncStatus,
|
||||||
authSyncState,
|
authSyncState,
|
||||||
authSyncProviders,
|
authSyncProviders,
|
||||||
|
managedDockerNode,
|
||||||
}: NodeCardProps) {
|
}: NodeCardProps) {
|
||||||
const [removeArmed, setRemoveArmed] = useState(false);
|
const [removeArmed, setRemoveArmed] = useState(false);
|
||||||
const statusConfig = STATUS_CONFIG[node.status];
|
const statusConfig = STATUS_CONFIG[node.status] ?? STATUS_CONFIG.offline;
|
||||||
|
const dockerStatusConfig = managedDockerNode ? (STATUS_CONFIG[managedDockerNode.status] ?? STATUS_CONFIG.error) : null;
|
||||||
|
const dockerHost = managedDockerNode?.hostConfig.type === "remote"
|
||||||
|
? `Remote: ${managedDockerNode.hostConfig.host ?? "unknown"}`
|
||||||
|
: "Local Docker";
|
||||||
|
|
||||||
const assignedProjectCount = useMemo(() => {
|
const assignedProjectCount = useMemo(() => {
|
||||||
return getProjectCountForNode(projects, node);
|
return getProjectCountForNode(projects, node);
|
||||||
@@ -171,6 +193,12 @@ function NodeCardInner({
|
|||||||
<h3 className="node-card__name" title={node.name}>{node.name}</h3>
|
<h3 className="node-card__name" title={node.name}>{node.name}</h3>
|
||||||
<div className="node-card__meta-row">
|
<div className="node-card__meta-row">
|
||||||
<span className="node-card__type-badge">{node.type === "local" ? "Local" : "Remote"}</span>
|
<span className="node-card__type-badge">{node.type === "local" ? "Local" : "Remote"}</span>
|
||||||
|
{managedDockerNode && (
|
||||||
|
<span className="node-card__docker-badge" title="Managed Docker node">
|
||||||
|
<Box size={12} aria-hidden />
|
||||||
|
Docker
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={`node-card__status ${statusConfig.className}`}
|
className={`node-card__status ${statusConfig.className}`}
|
||||||
style={{ color: statusConfig.color }}
|
style={{ color: statusConfig.color }}
|
||||||
@@ -179,6 +207,16 @@ function NodeCardInner({
|
|||||||
<span className="node-card__status-indicator" style={{ backgroundColor: statusConfig.color }} aria-hidden />
|
<span className="node-card__status-indicator" style={{ backgroundColor: statusConfig.color }} aria-hidden />
|
||||||
{statusConfig.label}
|
{statusConfig.label}
|
||||||
</span>
|
</span>
|
||||||
|
{managedDockerNode && dockerStatusConfig && (
|
||||||
|
<span
|
||||||
|
className={`node-card__status ${dockerStatusConfig.className}`}
|
||||||
|
style={{ color: dockerStatusConfig.color }}
|
||||||
|
data-status={managedDockerNode.status}
|
||||||
|
>
|
||||||
|
<span className="node-card__status-indicator" style={{ backgroundColor: dockerStatusConfig.color }} aria-hidden />
|
||||||
|
{dockerStatusConfig.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{node.type === "remote" && authSyncState && (
|
{node.type === "remote" && authSyncState && (
|
||||||
<span
|
<span
|
||||||
className={`node-card__auth-indicator node-card__auth-indicator--${authSyncState}`}
|
className={`node-card__auth-indicator node-card__auth-indicator--${authSyncState}`}
|
||||||
@@ -200,6 +238,14 @@ function NodeCardInner({
|
|||||||
{truncateUrl(node.url)}
|
{truncateUrl(node.url)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{managedDockerNode && (
|
||||||
|
<div className="node-card__docker-meta">
|
||||||
|
<span title={`${managedDockerNode.imageName}:${managedDockerNode.imageTag}`}>
|
||||||
|
{managedDockerNode.imageName}:{managedDockerNode.imageTag}
|
||||||
|
</span>
|
||||||
|
<span title={dockerHost}>{dockerHost}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="node-card__metrics">
|
<div className="node-card__metrics">
|
||||||
<div className="node-card__metric">
|
<div className="node-card__metric">
|
||||||
@@ -256,41 +302,42 @@ function NodeCardInner({
|
|||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{managedDockerNode && (
|
||||||
className="btn btn-sm node-card__action"
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={handleEdit}
|
className="btn btn-sm node-card__action"
|
||||||
disabled={isLoading}
|
type="button"
|
||||||
aria-label="Start node container"
|
disabled
|
||||||
title="Start Container"
|
aria-label="Start node container"
|
||||||
>
|
title="Available after FN-3113"
|
||||||
<Play size={14} />
|
>
|
||||||
<span>Start</span>
|
<Play size={14} />
|
||||||
</button>
|
<span>Start</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm node-card__action"
|
className="btn btn-sm node-card__action"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleEdit}
|
disabled
|
||||||
disabled={isLoading}
|
aria-label="Stop node container"
|
||||||
aria-label="Stop node container"
|
title="Available after FN-3113"
|
||||||
title="Stop Container"
|
>
|
||||||
>
|
<Square size={14} />
|
||||||
<Square size={14} />
|
<span>Stop</span>
|
||||||
<span>Stop</span>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm node-card__action"
|
className="btn btn-sm node-card__action"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleEdit}
|
disabled
|
||||||
disabled={isLoading}
|
aria-label="Restart node container"
|
||||||
aria-label="Restart node container"
|
title="Available after FN-3113"
|
||||||
title="Restart Container"
|
>
|
||||||
>
|
<RotateCw size={14} />
|
||||||
<RotateCw size={14} />
|
<span>Restart</span>
|
||||||
<span>Restart</span>
|
</button>
|
||||||
</button>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`btn btn-sm node-card__action node-card__action--remove ${removeArmed ? "btn-danger is-armed" : ""}`}
|
className={`btn btn-sm node-card__action node-card__action--remove ${removeArmed ? "btn-danger is-armed" : ""}`}
|
||||||
|
|||||||
@@ -183,6 +183,117 @@
|
|||||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docker-management {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__status-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__status-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__status-dot {
|
||||||
|
width: var(--space-sm);
|
||||||
|
height: var(--space-sm);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__status-dot--success { background: var(--color-success); }
|
||||||
|
.docker-management__status-dot--warning { background: var(--color-warning); }
|
||||||
|
.docker-management__status-dot--error { background: var(--color-error); }
|
||||||
|
|
||||||
|
.docker-management__status-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__info-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__log-viewer {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__log-viewer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__log-viewer pre {
|
||||||
|
margin: 0;
|
||||||
|
max-height: calc(var(--space-2xl) * 9.375);
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__env-list {
|
||||||
|
margin: var(--space-sm) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__env-list div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__env-list dt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__env-list dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__mounts-list {
|
||||||
|
margin: var(--space-sm) 0 0;
|
||||||
|
padding-left: var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__mounts-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.node-detail-modal__grid,
|
.node-detail-modal__grid,
|
||||||
.node-detail-modal__docker-grid {
|
.node-detail-modal__docker-grid {
|
||||||
@@ -192,4 +303,30 @@
|
|||||||
.node-detail-modal__field--full {
|
.node-detail-modal__field--full {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docker-management__actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__env-list div {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__mounts-list li {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-management__log-viewer pre {
|
||||||
|
max-height: calc(var(--space-2xl) * 6.25);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Activity, Download, Pencil, Save, Shield, Upload, X } from "lucide-react";
|
import {
|
||||||
import type { NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
|
Activity,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Pencil,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
Save,
|
||||||
|
Shield,
|
||||||
|
Square,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ContainerStatusInfo, ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
|
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
|
||||||
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
||||||
@@ -23,12 +35,16 @@ interface NodeDetailModalProps {
|
|||||||
onPushSettings?: (nodeId: string) => Promise<unknown>;
|
onPushSettings?: (nodeId: string) => Promise<unknown>;
|
||||||
onPullSettings?: (nodeId: string) => Promise<unknown>;
|
onPullSettings?: (nodeId: string) => Promise<unknown>;
|
||||||
onSyncAuth?: (nodeId: string) => Promise<unknown>;
|
onSyncAuth?: (nodeId: string) => Promise<unknown>;
|
||||||
/** Sync history entries for this node */
|
|
||||||
syncHistory?: SyncLogEntry[];
|
syncHistory?: SyncLogEntry[];
|
||||||
/** Called when sync conflicts need resolution */
|
|
||||||
onResolveConflicts?: (resolutions: ConflictResolutionResult[]) => Promise<void>;
|
onResolveConflicts?: (resolutions: ConflictResolutionResult[]) => Promise<void>;
|
||||||
|
managedDockerNode?: ManagedDockerNodeInfo;
|
||||||
|
containerStatus?: ContainerStatusInfo;
|
||||||
|
onFetchContainerStatus?: (managedId: string) => Promise<ContainerStatusInfo>;
|
||||||
|
onFetchLogs?: (managedId: string) => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_ENV_KEY_PATTERN = /(KEY|TOKEN|SECRET|PASSWORD)/i;
|
||||||
|
|
||||||
function formatTimestamp(value?: string): string {
|
function formatTimestamp(value?: string): string {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -36,6 +52,21 @@ function formatTimestamp(value?: string): string {
|
|||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDockerUptime(startedAt?: string): string {
|
||||||
|
if (!startedAt) return "—";
|
||||||
|
const started = new Date(startedAt);
|
||||||
|
const now = Date.now();
|
||||||
|
if (Number.isNaN(started.getTime()) || started.getTime() > now) return "—";
|
||||||
|
const seconds = Math.floor((now - started.getTime()) / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ${hours % 24}h`;
|
||||||
|
}
|
||||||
|
|
||||||
function getSyncStateDotClass(syncState: ComputedNodeSyncStatus["syncState"]): string {
|
function getSyncStateDotClass(syncState: ComputedNodeSyncStatus["syncState"]): string {
|
||||||
switch (syncState) {
|
switch (syncState) {
|
||||||
case "synced":
|
case "synced":
|
||||||
@@ -52,6 +83,32 @@ function getSyncStateDotClass(syncState: ComputedNodeSyncStatus["syncState"]): s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDockerStatusTone(status?: string): "success" | "warning" | "error" {
|
||||||
|
if (status === "running") return "success";
|
||||||
|
if (status === "creating" || status === "recreating" || status === "restarting") return "warning";
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDockerStatusLabel(status?: string): string {
|
||||||
|
if (!status) return "Unknown";
|
||||||
|
return `${status.charAt(0).toUpperCase()}${status.slice(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePortFromReachableUrl(url?: string): string {
|
||||||
|
if (!url) return "—";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.port) return parsed.port;
|
||||||
|
return parsed.protocol === "https:" ? "443" : parsed.protocol === "http:" ? "80" : "—";
|
||||||
|
} catch {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskEnvValue(key: string, value: string): string {
|
||||||
|
return SENSITIVE_ENV_KEY_PATTERN.test(key) ? "••••••••" : value;
|
||||||
|
}
|
||||||
|
|
||||||
export function NodeDetailModal({
|
export function NodeDetailModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -66,6 +123,10 @@ export function NodeDetailModal({
|
|||||||
onSyncAuth,
|
onSyncAuth,
|
||||||
syncHistory = [],
|
syncHistory = [],
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
|
managedDockerNode,
|
||||||
|
containerStatus,
|
||||||
|
onFetchContainerStatus,
|
||||||
|
onFetchLogs,
|
||||||
}: NodeDetailModalProps) {
|
}: NodeDetailModalProps) {
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
@@ -75,18 +136,19 @@ export function NodeDetailModal({
|
|||||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
// Sync action states
|
|
||||||
const [isPushing, setIsPushing] = useState(false);
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
const [isPulling, setIsPulling] = useState(false);
|
const [isPulling, setIsPulling] = useState(false);
|
||||||
const [isSyncingAuth, setIsSyncingAuth] = useState(false);
|
const [isSyncingAuth, setIsSyncingAuth] = useState(false);
|
||||||
const [syncError, setSyncError] = useState<string | null>(null);
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Conflict resolution modal state
|
|
||||||
const [showConflictModal, setShowConflictModal] = useState(false);
|
const [showConflictModal, setShowConflictModal] = useState(false);
|
||||||
const [conflicts] = useState<SettingsConflictEntry[]>([]);
|
const [conflicts] = useState<SettingsConflictEntry[]>([]);
|
||||||
const [dockerStatus, setDockerStatus] = useState<"running" | "stopped" | "recreating">("running");
|
|
||||||
const [dockerEnv, setDockerEnv] = useState("FUSION_LOG_LEVEL=info");
|
const [liveContainerStatus, setLiveContainerStatus] = useState<ContainerStatusInfo | undefined>(containerStatus);
|
||||||
const [dockerMounts, setDockerMounts] = useState("/srv/fusion:/data:rw");
|
const [isRefreshingContainerStatus, setIsRefreshingContainerStatus] = useState(false);
|
||||||
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
|
const [logs, setLogs] = useState("");
|
||||||
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
@@ -95,9 +157,15 @@ export function NodeDetailModal({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLiveContainerStatus(containerStatus);
|
||||||
|
}, [containerStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!node || !isOpen) {
|
if (!node || !isOpen) {
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
|
setLogsOpen(false);
|
||||||
|
setLogs("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,10 +195,17 @@ export function NodeDetailModal({
|
|||||||
return getProjectsForNode(projects, node);
|
return getProjectsForNode(projects, node);
|
||||||
}, [node, projects]);
|
}, [node, projects]);
|
||||||
|
|
||||||
const isManagedDockerNode = useMemo(
|
const dockerHost = useMemo(() => {
|
||||||
() => (node?.capabilities as readonly string[] | undefined)?.includes("docker-managed") ?? false,
|
if (!managedDockerNode) return "—";
|
||||||
[node],
|
return managedDockerNode.hostConfig.type === "remote" ? managedDockerNode.hostConfig.host ?? "—" : "Local Docker";
|
||||||
);
|
}, [managedDockerNode]);
|
||||||
|
|
||||||
|
const dockerResourceSizing = useMemo(() => {
|
||||||
|
if (!managedDockerNode?.resourceSizing?.cpuLimit && !managedDockerNode?.resourceSizing?.memoryLimit) {
|
||||||
|
return "Default";
|
||||||
|
}
|
||||||
|
return `${managedDockerNode.resourceSizing?.cpuLimit ?? "Default CPU"} / ${managedDockerNode.resourceSizing?.memoryLimit ?? "Default memory"}`;
|
||||||
|
}, [managedDockerNode]);
|
||||||
|
|
||||||
const handleHealthCheck = useCallback(async () => {
|
const handleHealthCheck = useCallback(async () => {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -210,14 +285,42 @@ export function NodeDetailModal({
|
|||||||
setSyncError(null);
|
setSyncError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDockerLifecycle = useCallback((action: "start" | "stop" | "restart" | "recreate" | "upgrade") => {
|
const handleRefreshContainerStatus = useCallback(async () => {
|
||||||
if (action === "start") setDockerStatus("running");
|
if (!managedDockerNode || !onFetchContainerStatus) return;
|
||||||
if (action === "stop") setDockerStatus("stopped");
|
setIsRefreshingContainerStatus(true);
|
||||||
if (action === "restart") setDockerStatus("running");
|
try {
|
||||||
if (action === "recreate") setDockerStatus("recreating");
|
const result = await onFetchContainerStatus(managedDockerNode.id);
|
||||||
if (action === "upgrade") setDockerStatus("recreating");
|
if (!isMountedRef.current) return;
|
||||||
addToast(`Docker action queued: ${action}`, "success");
|
setLiveContainerStatus(result);
|
||||||
}, [addToast]);
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to fetch container status";
|
||||||
|
addToast(message, "error");
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsRefreshingContainerStatus(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [addToast, managedDockerNode, onFetchContainerStatus]);
|
||||||
|
|
||||||
|
const handleFetchLogs = useCallback(async () => {
|
||||||
|
if (!managedDockerNode || !onFetchLogs) return;
|
||||||
|
setLogsOpen(true);
|
||||||
|
setLogsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await onFetchLogs(managedDockerNode.id);
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
setLogs(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
setLogs("");
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to fetch container logs";
|
||||||
|
addToast(message, "error");
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setLogsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [addToast, managedDockerNode, onFetchLogs]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!node || isSaving) return;
|
if (!node || isSaving) return;
|
||||||
@@ -267,6 +370,9 @@ export function NodeDetailModal({
|
|||||||
|
|
||||||
if (!isOpen || !node) return null;
|
if (!isOpen || !node) return null;
|
||||||
|
|
||||||
|
const effectiveDockerStatus = liveContainerStatus?.status ?? managedDockerNode?.status;
|
||||||
|
const dockerStatusTone = getDockerStatusTone(effectiveDockerStatus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay open" onClick={onClose}>
|
<div className="modal-overlay open" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
@@ -412,39 +518,88 @@ export function NodeDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isManagedDockerNode && (
|
{managedDockerNode && (
|
||||||
<section className="node-detail-modal__section">
|
<section className="node-detail-modal__section docker-management">
|
||||||
<h4>Docker Management</h4>
|
<h4>Docker Management</h4>
|
||||||
<div className="node-detail-modal__health-row">
|
|
||||||
<span>Container: <strong>{dockerStatus}</strong></span>
|
<div className="docker-management__status-card">
|
||||||
<span>Image: <strong>runfusion/fusion:latest</strong></span>
|
<div className="docker-management__status-row">
|
||||||
|
<span className={`docker-management__status-dot docker-management__status-dot--${dockerStatusTone}`} aria-hidden />
|
||||||
|
<strong>{getDockerStatusLabel(effectiveDockerStatus)}</strong>
|
||||||
|
{(effectiveDockerStatus === "creating" || effectiveDockerStatus === "recreating" || effectiveDockerStatus === "restarting") && (
|
||||||
|
<RotateCcw size={14} className="spin" aria-hidden />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="docker-management__status-meta">
|
||||||
|
{effectiveDockerStatus === "running" && <span>Uptime: {formatDockerUptime(liveContainerStatus?.startedAt)}</span>}
|
||||||
|
{effectiveDockerStatus !== "running" && liveContainerStatus?.exitCode !== undefined && (
|
||||||
|
<span>Exit code: {liveContainerStatus.exitCode}</span>
|
||||||
|
)}
|
||||||
|
{(liveContainerStatus?.error || managedDockerNode.errorMessage) && (
|
||||||
|
<span>{liveContainerStatus?.error ?? managedDockerNode.errorMessage}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm" onClick={() => void handleRefreshContainerStatus()} disabled={!onFetchContainerStatus || isRefreshingContainerStatus}>
|
||||||
|
{isRefreshingContainerStatus ? "Refreshing..." : "Refresh Status"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="node-detail-modal__sync-actions">
|
|
||||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("start")}>Start</button>
|
<div className="node-detail-modal__grid docker-management__info-grid">
|
||||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("stop")}>Stop</button>
|
<div className="node-detail-modal__field"><span>Image</span><strong><code>{managedDockerNode.imageName}:{managedDockerNode.imageTag}</code></strong></div>
|
||||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("restart")}>Restart</button>
|
<div className="node-detail-modal__field"><span>Container ID</span><strong><code>{managedDockerNode.containerId ? managedDockerNode.containerId.slice(0, 12) : "—"}</code></strong></div>
|
||||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("recreate")}>Recreate</button>
|
<div className="node-detail-modal__field"><span>Host</span><strong>{dockerHost}</strong></div>
|
||||||
<button className="btn btn-sm" onClick={() => handleDockerLifecycle("upgrade")}>Upgrade Image</button>
|
<div className="node-detail-modal__field"><span>Persistent Storage</span><strong>{managedDockerNode.persistentStorage ? "Yes" : "No"}</strong></div>
|
||||||
|
<div className="node-detail-modal__field"><span>Port</span><strong>{parsePortFromReachableUrl(managedDockerNode.reachableUrl)}</strong></div>
|
||||||
|
<div className="node-detail-modal__field"><span>Resource Sizing</span><strong>{dockerResourceSizing}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="node-detail-modal__docker-grid">
|
|
||||||
<label className="node-detail-modal__field">
|
<div className="docker-management__actions">
|
||||||
<span>Environment Variables</span>
|
<button className="btn btn-sm" disabled title="Available after FN-3113"><Play size={14} />Start</button>
|
||||||
<textarea className="input node-detail-modal__textarea" value={dockerEnv} onChange={(event) => setDockerEnv(event.target.value)} />
|
<button className="btn btn-sm" disabled title="Available after FN-3113"><Square size={14} />Stop</button>
|
||||||
</label>
|
<button className="btn btn-sm" disabled title="Available after FN-3113"><RotateCcw size={14} />Restart</button>
|
||||||
<label className="node-detail-modal__field">
|
<button className="btn btn-sm" onClick={() => void handleFetchLogs()} disabled={!onFetchLogs}><FileText size={14} />View Logs</button>
|
||||||
<span>Volume Mounts</span>
|
|
||||||
<textarea className="input node-detail-modal__textarea" value={dockerMounts} onChange={(event) => setDockerMounts(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="node-detail-modal__sync-actions">
|
|
||||||
<button className="btn btn-sm" onClick={() => addToast("Container logs opened", "success")}>View Logs</button>
|
|
||||||
<button className="btn btn-sm" onClick={() => addToast("Config changes saved", "success")}>Save Config</button>
|
|
||||||
<button className="btn btn-danger btn-sm" onClick={() => addToast("Delete flow opened (retain/remove volumes)", "warning")}>Delete Node…</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{logsOpen && (
|
||||||
|
<div className="docker-management__log-viewer">
|
||||||
|
<div className="docker-management__log-viewer-header">
|
||||||
|
<strong>Container Logs</strong>
|
||||||
|
<button className="btn-icon" onClick={() => setLogsOpen(false)} aria-label="Close logs"><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
{logsLoading ? (
|
||||||
|
<p>Fetching logs...</p>
|
||||||
|
) : (
|
||||||
|
<pre>{logs.trim() || "No logs available"}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Environment Variables</summary>
|
||||||
|
<dl className="docker-management__env-list">
|
||||||
|
{Object.entries(managedDockerNode.envVars).map(([key, value]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<dt>{key}</dt>
|
||||||
|
<dd>{maskEnvValue(key, value)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Volume Mounts</summary>
|
||||||
|
<ul className="docker-management__mounts-list">
|
||||||
|
{managedDockerNode.volumeMounts.map((mount) => (
|
||||||
|
<li key={`${mount.hostPath}:${mount.containerPath}`}>
|
||||||
|
<span>{mount.hostPath} → {mount.containerPath}</span>
|
||||||
|
{mount.readOnly && <span className="node-card__type-badge">Read-only</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings Sync section — only for remote nodes */}
|
|
||||||
{node.type === "remote" && (
|
{node.type === "remote" && (
|
||||||
<section className="node-detail-modal__section">
|
<section className="node-detail-modal__section">
|
||||||
<h4>Settings Sync</h4>
|
<h4>Settings Sync</h4>
|
||||||
@@ -456,12 +611,7 @@ export function NodeDetailModal({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
Last sync:{" "}
|
Last sync: <strong>{syncStatus.lastSyncAt ? formatRelativeTime(syncStatus.lastSyncAt) : "Never synced"}</strong>
|
||||||
<strong>
|
|
||||||
{syncStatus.lastSyncAt
|
|
||||||
? formatRelativeTime(syncStatus.lastSyncAt)
|
|
||||||
: "Never synced"}
|
|
||||||
</strong>
|
|
||||||
</span>
|
</span>
|
||||||
{syncStatus.diffCount > 0 && (
|
{syncStatus.diffCount > 0 && (
|
||||||
<span className="node-detail-modal__sync-diff">
|
<span className="node-detail-modal__sync-diff">
|
||||||
@@ -472,29 +622,17 @@ export function NodeDetailModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="node-detail-modal__sync-actions">
|
<div className="node-detail-modal__sync-actions">
|
||||||
<button
|
<button className="btn btn-sm" onClick={handlePushSettings} disabled={isPushing || !onPushSettings}>
|
||||||
className="btn btn-sm"
|
|
||||||
onClick={handlePushSettings}
|
|
||||||
disabled={isPushing || !onPushSettings}
|
|
||||||
>
|
|
||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
{isPushing ? "Pushing..." : "Push Settings"}
|
{isPushing ? "Pushing..." : "Push Settings"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button className="btn btn-sm" onClick={handlePullSettings} disabled={isPulling || !onPullSettings}>
|
||||||
className="btn btn-sm"
|
|
||||||
onClick={handlePullSettings}
|
|
||||||
disabled={isPulling || !onPullSettings}
|
|
||||||
>
|
|
||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
{isPulling ? "Pulling..." : "Pull Settings"}
|
{isPulling ? "Pulling..." : "Pull Settings"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button className="btn btn-sm" onClick={handleSyncAuth} disabled={isSyncingAuth || !onSyncAuth}>
|
||||||
className="btn btn-sm"
|
|
||||||
onClick={handleSyncAuth}
|
|
||||||
disabled={isSyncingAuth || !onSyncAuth}
|
|
||||||
>
|
|
||||||
<Shield size={14} />
|
<Shield size={14} />
|
||||||
{isSyncingAuth ? "Syncing..." : "Sync Auth"}
|
{isSyncingAuth ? "Syncing..." : "Sync Auth"}
|
||||||
</button>
|
</button>
|
||||||
@@ -503,11 +641,7 @@ export function NodeDetailModal({
|
|||||||
{syncError && (
|
{syncError && (
|
||||||
<div className="node-detail-modal__sync-error">
|
<div className="node-detail-modal__sync-error">
|
||||||
<span>{syncError}</span>
|
<span>{syncError}</span>
|
||||||
<button
|
<button className="node-detail-modal__sync-error-dismiss" onClick={handleDismissSyncError} aria-label="Dismiss error">
|
||||||
className="node-detail-modal__sync-error-dismiss"
|
|
||||||
onClick={handleDismissSyncError}
|
|
||||||
aria-label="Dismiss error"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -515,15 +649,10 @@ export function NodeDetailModal({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sync History section — only for remote nodes */}
|
|
||||||
{node.type === "remote" && (
|
{node.type === "remote" && (
|
||||||
<section className="node-detail-modal__section">
|
<section className="node-detail-modal__section">
|
||||||
<h4>Sync History</h4>
|
<h4>Sync History</h4>
|
||||||
<SettingsSyncLog
|
<SettingsSyncLog nodeId={node.id} entries={syncHistory} singleNode={true} />
|
||||||
nodeId={node.id}
|
|
||||||
entries={syncHistory}
|
|
||||||
singleNode={true}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -537,7 +666,6 @@ export function NodeDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conflict resolution modal — rendered outside main modal container */}
|
|
||||||
{node.type === "remote" && (
|
{node.type === "remote" && (
|
||||||
<SettingsSyncConflictModal
|
<SettingsSyncConflictModal
|
||||||
isOpen={showConflictModal}
|
isOpen={showConflictModal}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nodes-view {
|
.nodes-view {
|
||||||
|
--nodes-view-spin-duration: calc(var(--transition-slow) * 4);
|
||||||
|
--nodes-view-pulse-duration: calc(var(--transition-slow) * 5);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
@@ -64,7 +66,7 @@
|
|||||||
.nodes-view-stat {
|
.nodes-view-stat {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -101,7 +103,7 @@
|
|||||||
.nodes-view-empty {
|
.nodes-view-empty {
|
||||||
padding: var(--space-xl);
|
padding: var(--space-xl);
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-md);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -118,7 +120,7 @@
|
|||||||
.node-card {
|
.node-card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -304,6 +306,33 @@
|
|||||||
background: color-mix(in srgb, var(--color-error) 14%, transparent);
|
background: color-mix(in srgb, var(--color-error) 14%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-card__docker-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||||
|
background: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card__docker-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card__docker-meta span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mesh Topology ──────────────────────────────────────────────────────────── */
|
/* ── Mesh Topology ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.mesh-topology {
|
.mesh-topology {
|
||||||
@@ -533,7 +562,7 @@
|
|||||||
|
|
||||||
.nodes-view .node-status-indicator__dot--connecting {
|
.nodes-view .node-status-indicator__dot--connecting {
|
||||||
background: var(--color-warning);
|
background: var(--color-warning);
|
||||||
animation: pulse-warning 1.5s ease-in-out infinite;
|
animation: pulse-warning var(--nodes-view-pulse-duration) ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodes-view .node-status-indicator__spinner {
|
.nodes-view .node-status-indicator__spinner {
|
||||||
@@ -542,7 +571,7 @@
|
|||||||
border: calc(var(--space-xs) / 2) solid transparent;
|
border: calc(var(--space-xs) / 2) solid transparent;
|
||||||
border-top-color: currentColor;
|
border-top-color: currentColor;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin var(--nodes-view-spin-duration) linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodes-view .node-status-indicator__name {
|
.nodes-view .node-status-indicator__name {
|
||||||
@@ -565,6 +594,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin var(--nodes-view-spin-duration) linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Nodes View Mobile Responsive ──────────────────────────────────────── */
|
/* ── Nodes View Mobile Responsive ──────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.nodes-view {
|
.nodes-view {
|
||||||
@@ -579,6 +617,18 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-card__docker-badge {
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card__docker-meta {
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card__docker-meta span {
|
||||||
|
max-width: calc(var(--space-2xl) * 4.5);
|
||||||
|
}
|
||||||
|
|
||||||
/* Connect Node Modal mobile */
|
/* Connect Node Modal mobile */
|
||||||
.connect-node-modal {
|
.connect-node-modal {
|
||||||
width: calc(100vw - (var(--space-md) * 2));
|
width: calc(100vw - (var(--space-md) * 2));
|
||||||
@@ -590,7 +640,6 @@
|
|||||||
|
|
||||||
/* NodesView mobile alignment */
|
/* NodesView mobile alignment */
|
||||||
.nodes-view-header {
|
.nodes-view-header {
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
@@ -658,4 +707,5 @@
|
|||||||
font-size: calc(var(--space-md) + var(--space-xs) / 4);
|
font-size: calc(var(--space-md) + var(--space-xs) / 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Plus, Server, Wifi, WifiOff, Globe, RefreshCw, X } from "lucide-react";
|
import { Box, Plus, Server, Wifi, WifiOff, Globe, RefreshCw, X } from "lucide-react";
|
||||||
import "./NodesView.css";
|
import "./NodesView.css";
|
||||||
import { useNodes } from "../hooks/useNodes";
|
import { useNodes } from "../hooks/useNodes";
|
||||||
import { useProjects } from "../hooks/useProjects";
|
import { useProjects } from "../hooks/useProjects";
|
||||||
import { useNodeSettingsSync, computeSyncState } from "../hooks/useNodeSettingsSync";
|
import { useNodeSettingsSync, computeSyncState } from "../hooks/useNodeSettingsSync";
|
||||||
import type { NodeInfo, NodeUpdateInput } from "../api";
|
import type { ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput } from "../api";
|
||||||
import { NodeCard } from "./NodeCard";
|
import { NodeCard } from "./NodeCard";
|
||||||
import { MeshTopology } from "./MeshTopology";
|
import { MeshTopology } from "./MeshTopology";
|
||||||
import { AddNodeModal, type AddNodeInput } from "./AddNodeModal";
|
import { AddNodeModal, type AddNodeInput } from "./AddNodeModal";
|
||||||
@@ -23,7 +23,14 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
const { nodes, loading, error, refresh, register, update, unregister, healthCheck } = useNodes();
|
const { nodes, loading, error, refresh, register, update, unregister, healthCheck } = useNodes();
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
|
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
|
||||||
const { dockerNodes, create: createDockerNode } = useManagedDockerNodes();
|
const {
|
||||||
|
dockerNodes,
|
||||||
|
loading: dockerLoading,
|
||||||
|
refresh: refreshDocker,
|
||||||
|
getContainerStatus,
|
||||||
|
getLogs,
|
||||||
|
create: createDockerNode,
|
||||||
|
} = useManagedDockerNodes();
|
||||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
const [dockerOnboardingOpen, setDockerOnboardingOpen] = useState(false);
|
const [dockerOnboardingOpen, setDockerOnboardingOpen] = useState(false);
|
||||||
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
|
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
|
||||||
@@ -50,8 +57,9 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
const synced = nodes.filter(
|
const synced = nodes.filter(
|
||||||
(node) => node.type === "remote" && syncStatusMap[node.id] && computeSyncState(syncStatusMap[node.id]).syncState === "synced"
|
(node) => node.type === "remote" && syncStatusMap[node.id] && computeSyncState(syncStatusMap[node.id]).syncState === "synced"
|
||||||
).length;
|
).length;
|
||||||
return { total, online, offline, remote, synced };
|
const docker = dockerNodes.length;
|
||||||
}, [nodes, syncStatusMap]);
|
return { total, online, offline, remote, synced, docker };
|
||||||
|
}, [dockerNodes.length, nodes, syncStatusMap]);
|
||||||
|
|
||||||
const handleRegister = useCallback(async (input: AddNodeInput) => {
|
const handleRegister = useCallback(async (input: AddNodeInput) => {
|
||||||
await register(input);
|
await register(input);
|
||||||
@@ -69,13 +77,23 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
}
|
}
|
||||||
}, [addToast, createDockerNode]);
|
}, [addToast, createDockerNode]);
|
||||||
|
|
||||||
|
const dockerNodeMap = useMemo(() => {
|
||||||
|
const map = new Map<string, ManagedDockerNodeInfo>();
|
||||||
|
for (const dockerNode of dockerNodes) {
|
||||||
|
if (dockerNode.nodeId) {
|
||||||
|
map.set(dockerNode.nodeId, dockerNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [dockerNodes]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await refresh();
|
await Promise.all([refresh(), refreshDocker()]);
|
||||||
} catch {
|
} catch {
|
||||||
addToast("Failed to refresh nodes", "error");
|
addToast("Failed to refresh nodes", "error");
|
||||||
}
|
}
|
||||||
}, [addToast, refresh]);
|
}, [addToast, refresh, refreshDocker]);
|
||||||
|
|
||||||
const handleHealthCheck = useCallback(async (id: string) => {
|
const handleHealthCheck = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -123,7 +141,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-sm" onClick={() => void handleRefresh()} disabled={loading}>
|
<button className="btn btn-sm" onClick={() => void handleRefresh()} disabled={loading || dockerLoading}>
|
||||||
<RefreshCw size={14} className={loading ? "spin" : ""} />
|
<RefreshCw size={14} className={loading ? "spin" : ""} />
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
@@ -131,9 +149,9 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
Add Node
|
Add Node
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => setDockerOnboardingOpen(true)}>
|
<button className="btn btn-sm" onClick={() => setDockerOnboardingOpen(true)} title="Add a managed Docker node">
|
||||||
<Plus size={14} />
|
<Box size={14} />
|
||||||
Provision Docker Node
|
Add Docker Node
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,20 +177,14 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
<span><RefreshCw size={14} /> Synced</span>
|
<span><RefreshCw size={14} /> Synced</span>
|
||||||
<strong>{stats.synced}</strong>
|
<strong>{stats.synced}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="nodes-view-stat" data-testid="nodes-stat-docker">
|
||||||
|
<span><Box size={14} /> Docker</span>
|
||||||
|
<strong>{stats.docker}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="nodes-view-error">{error}</div>}
|
{error && <div className="nodes-view-error">{error}</div>}
|
||||||
|
|
||||||
<section className="nodes-view-topology" aria-label="Docker Nodes Summary">
|
|
||||||
<h3 className="nodes-view-section-title">Docker Nodes</h3>
|
|
||||||
<div className="nodes-view-stat">
|
|
||||||
<span>Managed Docker Nodes</span>
|
|
||||||
<strong>{dockerNodes.length}</strong>
|
|
||||||
<button className="btn btn-sm" onClick={() => setDockerOnboardingOpen(true)}>
|
|
||||||
Provision
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Mesh Topology Visualization */}
|
{/* Mesh Topology Visualization */}
|
||||||
{!loading && nodes.length > 0 && (
|
{!loading && nodes.length > 0 && (
|
||||||
@@ -214,6 +226,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
syncStatus={nodeSyncStatus}
|
syncStatus={nodeSyncStatus}
|
||||||
authSyncState={node.type === "remote" ? getAuthSyncState(node.id) : undefined}
|
authSyncState={node.type === "remote" ? getAuthSyncState(node.id) : undefined}
|
||||||
authSyncProviders={node.type === "remote" ? getAuthProviders(node.id) : undefined}
|
authSyncProviders={node.type === "remote" ? getAuthProviders(node.id) : undefined}
|
||||||
|
managedDockerNode={dockerNodeMap.get(node.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -248,6 +261,9 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
onPushSettings={pushSettings}
|
onPushSettings={pushSettings}
|
||||||
onPullSettings={pullSettings}
|
onPullSettings={pullSettings}
|
||||||
onSyncAuth={syncAuth}
|
onSyncAuth={syncAuth}
|
||||||
|
managedDockerNode={selectedNode ? dockerNodeMap.get(selectedNode.id) : undefined}
|
||||||
|
onFetchContainerStatus={getContainerStatus}
|
||||||
|
onFetchLogs={getLogs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
Square: () => <span data-testid="square-icon">square</span>,
|
Square: () => <span data-testid="square-icon">square</span>,
|
||||||
RotateCw: () => <span data-testid="rotate-icon">rotate</span>,
|
RotateCw: () => <span data-testid="rotate-icon">rotate</span>,
|
||||||
Trash2: () => <span data-testid="trash-icon">trash</span>,
|
Trash2: () => <span data-testid="trash-icon">trash</span>,
|
||||||
|
Box: () => <span data-testid="box-icon">box</span>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useNodeSettingsSync", () => ({
|
vi.mock("../../hooks/useNodeSettingsSync", () => ({
|
||||||
@@ -69,6 +70,21 @@ function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): Comput
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("NodeCard", () => {
|
describe("NodeCard", () => {
|
||||||
|
const managedDockerNode = {
|
||||||
|
id: "dn-1",
|
||||||
|
nodeId: "node-1",
|
||||||
|
name: "Docker Node",
|
||||||
|
status: "running",
|
||||||
|
hostConfig: { type: "remote" as const, host: "tcp://docker:2376" },
|
||||||
|
envVars: {},
|
||||||
|
imageName: "runfusion/fusion",
|
||||||
|
imageTag: "latest",
|
||||||
|
volumeMounts: [],
|
||||||
|
persistentStorage: true,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
it("renders node name, type, status, project count, and concurrency", () => {
|
it("renders node name, type, status, project count, and concurrency", () => {
|
||||||
const node = makeNode({ id: "node-abc", name: "Build Worker", type: "remote", status: "connecting", url: "https://remote.example.com" });
|
const node = makeNode({ id: "node-abc", name: "Build Worker", type: "remote", status: "connecting", url: "https://remote.example.com" });
|
||||||
const projects = [
|
const projects = [
|
||||||
@@ -547,6 +563,59 @@ describe("NodeCard", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not render docker badge without managed docker data", () => {
|
||||||
|
render(
|
||||||
|
<NodeCard
|
||||||
|
node={makeNode()}
|
||||||
|
projects={[]}
|
||||||
|
onHealthCheck={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Docker")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders docker badge and metadata with managed docker data", () => {
|
||||||
|
render(
|
||||||
|
<NodeCard
|
||||||
|
node={makeNode({ type: "remote", url: "https://node.example" })}
|
||||||
|
projects={[]}
|
||||||
|
onHealthCheck={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
managedDockerNode={managedDockerNode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Docker")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("box-icon")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("runfusion/fusion:latest")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Remote: tcp://docker:2376")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["running", "Running", "node-card__status--online"],
|
||||||
|
["stopped", "Stopped", "node-card__status--offline"],
|
||||||
|
["creating", "Creating", "node-card__status--creating"],
|
||||||
|
["recreating", "Recreating", "node-card__status--recreating"],
|
||||||
|
["deleting", "Deleting", "node-card__status--deleting"],
|
||||||
|
])("maps docker status %s", (status, label, className) => {
|
||||||
|
render(
|
||||||
|
<NodeCard
|
||||||
|
node={makeNode({ type: "remote" })}
|
||||||
|
projects={[]}
|
||||||
|
onHealthCheck={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
managedDockerNode={{ ...managedDockerNode, status, updatedAt: `${status}` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(label).className).toContain(className);
|
||||||
|
});
|
||||||
|
|
||||||
describe("auth sync indicator", () => {
|
describe("auth sync indicator", () => {
|
||||||
it("renders auth indicator for remote node with match state", () => {
|
it("renders auth indicator for remote node with match state", () => {
|
||||||
const node = makeNode({
|
const node = makeNode({
|
||||||
|
|||||||
@@ -1,47 +1,30 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { NodeDetailModal } from "../NodeDetailModal";
|
import { NodeDetailModal } from "../NodeDetailModal";
|
||||||
import type { NodeInfo, ProjectInfo } from "../../api";
|
import type { ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
|
||||||
import type { ComputedNodeSyncStatus } from "../../hooks/useNodeSettingsSync";
|
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
Activity: () => <span data-testid="activity-icon">activity</span>,
|
Activity: () => <span>activity</span>,
|
||||||
Download: () => <span data-testid="download-icon">download</span>,
|
Download: () => <span>download</span>,
|
||||||
Pencil: () => <span data-testid="pencil-icon">pencil</span>,
|
FileText: () => <span>file-text</span>,
|
||||||
Save: () => <span data-testid="save-icon">save</span>,
|
Pencil: () => <span>pencil</span>,
|
||||||
Shield: () => <span data-testid="shield-icon">shield</span>,
|
Play: () => <span>play</span>,
|
||||||
Upload: () => <span data-testid="upload-icon">upload</span>,
|
RotateCcw: () => <span>rotate</span>,
|
||||||
X: () => <span data-testid="x-icon">x</span>,
|
Save: () => <span>save</span>,
|
||||||
ChevronDown: () => <span data-testid="chevron-down">chevron</span>,
|
Shield: () => <span>shield</span>,
|
||||||
Play: () => <span data-testid="play-icon">play</span>,
|
Square: () => <span>square</span>,
|
||||||
Square: () => <span data-testid="square-icon">square</span>,
|
Upload: () => <span>upload</span>,
|
||||||
RotateCw: () => <span data-testid="rotate-icon">rotate</span>,
|
X: () => <span>x</span>,
|
||||||
}));
|
ChevronDown: () => <span>chevron</span>,
|
||||||
|
|
||||||
vi.mock("../../hooks/useNodeSettingsSync", () => ({
|
|
||||||
formatRelativeTime: vi.fn((ts: string | null) => {
|
|
||||||
if (!ts) return "Never synced";
|
|
||||||
return "Synced 2m ago";
|
|
||||||
}),
|
|
||||||
getSyncStateColor: vi.fn((state: string) => {
|
|
||||||
switch (state) {
|
|
||||||
case "synced": return "var(--color-success)";
|
|
||||||
case "diff": return "var(--warning)";
|
|
||||||
case "error": return "var(--color-error)";
|
|
||||||
case "pending": return "var(--warning)";
|
|
||||||
case "never-synced": return "var(--text-muted)";
|
|
||||||
default: return "var(--text-muted)";
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||||
return {
|
return {
|
||||||
id: "node-1",
|
id: "node-1",
|
||||||
name: "Test Node",
|
name: "Node 1",
|
||||||
type: "remote",
|
type: "remote",
|
||||||
status: "online",
|
status: "online",
|
||||||
url: "https://test.example.com",
|
url: "http://localhost:7777",
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
@@ -49,261 +32,100 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
function makeDockerNode(overrides: Partial<ManagedDockerNodeInfo> = {}): ManagedDockerNodeInfo {
|
||||||
return {
|
return {
|
||||||
id: "proj-1",
|
id: "mdn-1",
|
||||||
name: "Project One",
|
nodeId: "node-1",
|
||||||
path: "/workspace/project-one",
|
name: "Docker Node",
|
||||||
status: "active",
|
containerId: "abcdef1234567890",
|
||||||
isolationMode: "in-process",
|
status: "running",
|
||||||
|
hostConfig: { type: "local" },
|
||||||
|
envVars: { FUSION_TOKEN: "secret", FUSION_LOG_LEVEL: "info" },
|
||||||
|
reachableUrl: "http://localhost:7777",
|
||||||
|
imageName: "runfusion/fusion",
|
||||||
|
imageTag: "latest",
|
||||||
|
volumeMounts: [{ hostPath: "/host", containerPath: "/data", readOnly: true }],
|
||||||
|
persistentStorage: true,
|
||||||
|
resourceSizing: { cpuLimit: "2", memoryLimit: "4GB" },
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSyncStatus(overrides: Partial<ComputedNodeSyncStatus> = {}): ComputedNodeSyncStatus {
|
const baseProps = {
|
||||||
return {
|
|
||||||
syncState: "synced",
|
|
||||||
lastSyncAt: new Date(Date.now() - 120000).toISOString(),
|
|
||||||
diffCount: 0,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
node: makeNode(),
|
node: makeNode(),
|
||||||
projects: [],
|
projects: [] as ProjectInfo[],
|
||||||
onUpdate: vi.fn().mockResolvedValue(undefined),
|
onUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
onHealthCheck: vi.fn().mockResolvedValue(undefined),
|
onHealthCheck: vi.fn().mockResolvedValue(undefined),
|
||||||
addToast: vi.fn(),
|
addToast: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("NodeDetailModal", () => {
|
describe("NodeDetailModal docker section", () => {
|
||||||
beforeEach(() => {
|
it("does not render docker section without managedDockerNode", () => {
|
||||||
vi.clearAllMocks();
|
render(<NodeDetailModal {...baseProps} />);
|
||||||
|
expect(screen.queryByText("Docker Management")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("basic rendering", () => {
|
it("renders docker section and disabled lifecycle actions", () => {
|
||||||
it("renders modal when isOpen is true", () => {
|
render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} />);
|
||||||
render(<NodeDetailModal {...defaultProps} />);
|
expect(screen.getByText("Docker Management")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
const lifecycleButtons = screen.getAllByTitle("Available after FN-3113");
|
||||||
});
|
expect(lifecycleButtons).toHaveLength(3);
|
||||||
|
lifecycleButtons.forEach((button) => expect(button).toBeDisabled());
|
||||||
it("does not render modal when isOpen is false", () => {
|
|
||||||
render(<NodeDetailModal {...defaultProps} isOpen={false} />);
|
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders node name in dialog title", () => {
|
|
||||||
const node = makeNode({ name: "Custom Node Name" });
|
|
||||||
render(<NodeDetailModal {...defaultProps} node={node} />);
|
|
||||||
expect(screen.getByRole("dialog", { name: "Node details for Custom Node Name" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders Overview, Projects, Health, and Settings Sync sections for remote nodes", () => {
|
|
||||||
render(<NodeDetailModal {...defaultProps} />);
|
|
||||||
expect(screen.getByText("Overview")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/^Assigned Projects \(\d+\)$/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Health")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("Docker Management")).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Settings Sync")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders Docker Management section for managed docker nodes", () => {
|
|
||||||
const dockerManagedNode = makeNode({ capabilities: ["docker-managed"] });
|
|
||||||
render(<NodeDetailModal {...defaultProps} node={dockerManagedNode} />);
|
|
||||||
expect(screen.getByText("Docker Management")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render Settings Sync section for local nodes", () => {
|
|
||||||
const localNode = makeNode({ type: "local" });
|
|
||||||
render(<NodeDetailModal {...defaultProps} node={localNode} />);
|
|
||||||
expect(screen.getByText("Overview")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/^Projects \(\d+\)$/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Health")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("Settings Sync")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Settings Sync section", () => {
|
it("loads and displays logs", async () => {
|
||||||
it("renders Push Settings, Pull Settings, and Sync Auth buttons for remote nodes", () => {
|
const onFetchLogs = vi.fn().mockResolvedValue("line-1\nline-2");
|
||||||
render(<NodeDetailModal {...defaultProps} />);
|
render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} onFetchLogs={onFetchLogs} />);
|
||||||
expect(screen.getByText("Push Settings")).toBeInTheDocument();
|
fireEvent.click(screen.getByRole("button", { name: /view logs/i }));
|
||||||
expect(screen.getByText("Pull Settings")).toBeInTheDocument();
|
expect(await screen.findByText((content) => content.includes("line-1") && content.includes("line-2"))).toBeInTheDocument();
|
||||||
expect(screen.getByText("Sync Auth")).toBeInTheDocument();
|
fireEvent.click(screen.getByLabelText("Close logs"));
|
||||||
});
|
await waitFor(() => expect(screen.queryByText("Container Logs")).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
it("displays sync status with last sync time", () => {
|
it("renders status card and info grid values", () => {
|
||||||
const syncStatus = makeSyncStatus({ syncState: "synced" });
|
render(
|
||||||
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
|
<NodeDetailModal
|
||||||
expect(screen.getByText(/Last sync:/)).toBeInTheDocument();
|
{...baseProps}
|
||||||
});
|
managedDockerNode={makeDockerNode()}
|
||||||
|
containerStatus={{ running: true, status: "running", startedAt: "2026-01-01T00:00:00.000Z" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Uptime:/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("runfusion/fusion:latest")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("abcdef123456")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Local Docker")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Yes")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("7777")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2 / 4GB")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("displays diff count when available", () => {
|
it("refreshes container status", async () => {
|
||||||
const syncStatus = makeSyncStatus({ syncState: "diff", diffCount: 3 });
|
const onFetchContainerStatus = vi.fn().mockResolvedValue({ running: false, status: "stopped", exitCode: 1 });
|
||||||
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
|
render(
|
||||||
// The diff count is displayed with the "Differences:" label
|
<NodeDetailModal
|
||||||
const diffElement = document.querySelector(".node-detail-modal__sync-diff");
|
{...baseProps}
|
||||||
expect(diffElement).toBeInTheDocument();
|
managedDockerNode={makeDockerNode()}
|
||||||
expect(diffElement?.textContent).toContain("Differences:");
|
onFetchContainerStatus={onFetchContainerStatus}
|
||||||
expect(diffElement?.textContent).toContain("3");
|
/>,
|
||||||
});
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /refresh status/i }));
|
||||||
|
await waitFor(() => expect(onFetchContainerStatus).toHaveBeenCalledWith("mdn-1"));
|
||||||
|
expect(await screen.findByText("Stopped")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Exit code: 1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("displays 'Never synced' when lastSyncAt is null", () => {
|
it("masks sensitive env values and shows read-only mount", () => {
|
||||||
const syncStatus = makeSyncStatus({ syncState: "never-synced", lastSyncAt: null });
|
render(<NodeDetailModal {...baseProps} managedDockerNode={makeDockerNode()} />);
|
||||||
render(<NodeDetailModal {...defaultProps} syncStatus={syncStatus} />);
|
fireEvent.click(screen.getByText("Environment Variables"));
|
||||||
expect(screen.getByText(/Never synced/)).toBeInTheDocument();
|
expect(screen.getByText("••••••••")).toBeInTheDocument();
|
||||||
});
|
expect(screen.getByText("info")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText("Volume Mounts"));
|
||||||
it("Push Settings button calls onPushSettings", async () => {
|
expect(screen.getByText("/host → /data")).toBeInTheDocument();
|
||||||
const onPushSettings = vi.fn().mockResolvedValue(undefined);
|
expect(screen.getByText("Read-only")).toBeInTheDocument();
|
||||||
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Push Settings"));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onPushSettings).toHaveBeenCalledWith(defaultProps.node!.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Pull Settings button calls onPullSettings", async () => {
|
|
||||||
const onPullSettings = vi.fn().mockResolvedValue(undefined);
|
|
||||||
render(<NodeDetailModal {...defaultProps} onPullSettings={onPullSettings} />);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Pull Settings"));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onPullSettings).toHaveBeenCalledWith(defaultProps.node!.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Sync Auth button calls onSyncAuth", async () => {
|
|
||||||
const onSyncAuth = vi.fn().mockResolvedValue(undefined);
|
|
||||||
render(<NodeDetailModal {...defaultProps} onSyncAuth={onSyncAuth} />);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Sync Auth"));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onSyncAuth).toHaveBeenCalledWith(defaultProps.node!.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows loading state on Push Settings button during operation", async () => {
|
|
||||||
const onPushSettings = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
|
||||||
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Push Settings"));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Pushing...")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows loading state on Pull Settings button during operation", async () => {
|
|
||||||
const onPullSettings = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
|
||||||
render(<NodeDetailModal {...defaultProps} onPullSettings={onPullSettings} />);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Pull Settings"));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Pulling...")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows loading state on Sync Auth button during operation", async () => {
|
|
||||||
const onSyncAuth = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
|
||||||
render(<NodeDetailModal {...defaultProps} onSyncAuth={onSyncAuth} />);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Sync Auth"));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Syncing...")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("displays sync error when push operation fails", async () => {
|
|
||||||
const onPushSettings = vi.fn().mockRejectedValue(new Error("Push failed: connection refused"));
|
|
||||||
const addToast = vi.fn();
|
|
||||||
render(
|
|
||||||
<NodeDetailModal
|
|
||||||
{...defaultProps}
|
|
||||||
onPushSettings={onPushSettings}
|
|
||||||
addToast={addToast}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Push Settings"));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText(/Push failed: connection refused/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("displays sync error when pull operation fails", async () => {
|
|
||||||
const onPullSettings = vi.fn().mockRejectedValue(new Error("Pull failed: timeout"));
|
|
||||||
const addToast = vi.fn();
|
|
||||||
render(
|
|
||||||
<NodeDetailModal
|
|
||||||
{...defaultProps}
|
|
||||||
onPullSettings={onPullSettings}
|
|
||||||
addToast={addToast}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Pull Settings"));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText(/Pull failed: timeout/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("displays sync error when auth sync operation fails", async () => {
|
|
||||||
const onSyncAuth = vi.fn().mockRejectedValue(new Error("Auth sync failed"));
|
|
||||||
const addToast = vi.fn();
|
|
||||||
render(
|
|
||||||
<NodeDetailModal
|
|
||||||
{...defaultProps}
|
|
||||||
onSyncAuth={onSyncAuth}
|
|
||||||
addToast={addToast}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Sync Auth"));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText(/Auth sync failed/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dismisses sync error when dismiss button is clicked", async () => {
|
|
||||||
const onPushSettings = vi.fn().mockRejectedValue(new Error("Push failed"));
|
|
||||||
render(<NodeDetailModal {...defaultProps} onPushSettings={onPushSettings} />);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText("Push Settings"));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText(/Push failed/)).toBeInTheDocument();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByLabelText("Dismiss error"));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.queryByText(/Push failed/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("buttons are disabled when no handlers provided", () => {
|
|
||||||
render(<NodeDetailModal {...defaultProps} />);
|
|
||||||
expect(screen.getByText("Push Settings")).toBeDisabled();
|
|
||||||
expect(screen.getByText("Pull Settings")).toBeDisabled();
|
|
||||||
expect(screen.getByText("Sync Auth")).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,11 +120,47 @@ beforeEach(() => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
refresh: vi.fn().mockResolvedValue(undefined),
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getContainerStatus: vi.fn().mockResolvedValue({ running: true, status: "running" }),
|
||||||
|
getLogs: vi.fn().mockResolvedValue(""),
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("NodesView", () => {
|
describe("NodesView", () => {
|
||||||
|
it("renders docker stat and passes docker data to matching node card", () => {
|
||||||
|
mockUseNodes.mockReturnValue(makeUseNodesResult({
|
||||||
|
nodes: [makeNode({ id: "node-1", name: "Alpha", type: "remote", url: "https://alpha.node" })],
|
||||||
|
}));
|
||||||
|
mockUseManagedDockerNodes.mockReturnValue({
|
||||||
|
dockerNodes: [{
|
||||||
|
id: "mdn-1",
|
||||||
|
nodeId: "node-1",
|
||||||
|
name: "Docker Alpha",
|
||||||
|
status: "running",
|
||||||
|
hostConfig: { type: "local" },
|
||||||
|
envVars: {},
|
||||||
|
imageName: "runfusion/fusion",
|
||||||
|
imageTag: "latest",
|
||||||
|
volumeMounts: [],
|
||||||
|
persistentStorage: true,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
}],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getContainerStatus: vi.fn().mockResolvedValue({ running: true, status: "running" }),
|
||||||
|
getLogs: vi.fn().mockResolvedValue(""),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("nodes-stat-docker").textContent).toContain("1");
|
||||||
|
expect(screen.getAllByText("Docker").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("runfusion/fusion:latest")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders node cards and stats", () => {
|
it("renders node cards and stats", () => {
|
||||||
mockUseProjects.mockReturnValue({
|
mockUseProjects.mockReturnValue({
|
||||||
projects: [makeProject({ nodeId: "node-1" }), makeProject({ id: "proj-2", nodeId: "node-2" })],
|
projects: [makeProject({ nodeId: "node-1" }), makeProject({ id: "proj-2", nodeId: "node-2" })],
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ describe("nodes-view mobile CSS", () => {
|
|||||||
const cssContent = loadAllAppCss();
|
const cssContent = loadAllAppCss();
|
||||||
const mobileMediaBlock = extractMobileMediaBlocks(cssContent);
|
const mobileMediaBlock = extractMobileMediaBlocks(cssContent);
|
||||||
|
|
||||||
it("defines .nodes-view-header with compact padding on mobile", () => {
|
it("defines .nodes-view-header mobile wrapping without extra padding", () => {
|
||||||
expect(mobileMediaBlock).toContain(".nodes-view-header");
|
expect(mobileMediaBlock).toContain(".nodes-view-header");
|
||||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-header");
|
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-header");
|
||||||
expect(block).toMatch(/padding:\s*var\(--space-sm\)\s+var\(--space-md\)/);
|
expect(block).not.toContain("padding:");
|
||||||
expect(block).toContain("flex-wrap: wrap");
|
expect(block).toContain("flex-wrap: wrap");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 { ManagedDockerNodeInput } from "@fusion/core";
|
||||||
import type { DockerNodeInfo } from "../api";
|
import type { ContainerStatusInfo, ManagedDockerNodeInfo } from "../api";
|
||||||
import { createManagedDockerNode, listManagedDockerNodes } from "../api";
|
import {
|
||||||
|
createManagedDockerNode,
|
||||||
|
fetchDockerNodeLogs,
|
||||||
|
fetchManagedDockerNodeContainerStatus,
|
||||||
|
fetchManagedDockerNodes,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
export interface UseManagedDockerNodesResult {
|
export interface UseManagedDockerNodesResult {
|
||||||
dockerNodes: DockerNodeInfo[];
|
dockerNodes: ManagedDockerNodeInfo[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
create: (input: ManagedDockerNodeInput) => Promise<DockerNodeInfo>;
|
getContainerStatus: (id: string) => Promise<ContainerStatusInfo>;
|
||||||
|
getLogs: (id: string, options?: { tail?: number }) => Promise<string>;
|
||||||
|
create: (input: ManagedDockerNodeInput) => Promise<ManagedDockerNodeInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 15000;
|
||||||
|
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
||||||
const [dockerNodes, setDockerNodes] = useState<DockerNodeInfo[]>([]);
|
const [dockerNodes, setDockerNodes] = useState<ManagedDockerNodeInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = await listManagedDockerNodes();
|
const data = await fetchManagedDockerNodes();
|
||||||
setDockerNodes(data);
|
setDockerNodes(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes");
|
setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes");
|
||||||
@@ -32,7 +44,7 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
|||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await listManagedDockerNodes();
|
const data = await fetchManagedDockerNodes();
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setDockerNodes(data);
|
setDockerNodes(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -49,16 +61,90 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastVisibilityRefreshRef.current < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVisibilityRefreshRef.current = now;
|
||||||
|
void refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
};
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
void refresh();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [loading, refresh]);
|
||||||
|
|
||||||
|
const getContainerStatus = useCallback(async (id: string): Promise<ContainerStatusInfo> => {
|
||||||
|
return fetchManagedDockerNodeContainerStatus(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const create = useCallback(async (input: ManagedDockerNodeInput): Promise<DockerNodeInfo> => {
|
const getLogs = useCallback(async (id: string, options?: { tail?: number }): Promise<string> => {
|
||||||
const node = await createManagedDockerNode(input);
|
const result = await fetchDockerNodeLogs(id, options);
|
||||||
setDockerNodes((previous) => [...previous, node]);
|
return result.logs;
|
||||||
return node;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { dockerNodes, loading, error, refresh, create };
|
const create = useCallback(async (input: ManagedDockerNodeInput): Promise<ManagedDockerNodeInfo> => {
|
||||||
|
const created = await createManagedDockerNode(input);
|
||||||
|
const normalized = {
|
||||||
|
...created,
|
||||||
|
nodeId: created.nodeId ?? undefined,
|
||||||
|
containerId: created.containerId ?? undefined,
|
||||||
|
status: created.status,
|
||||||
|
hostConfig: {
|
||||||
|
type: created.hostConfig.host || created.hostConfig.context ? "remote" : "local",
|
||||||
|
host: created.hostConfig.host,
|
||||||
|
context: created.hostConfig.context,
|
||||||
|
},
|
||||||
|
reachableUrl: created.reachableUrl ?? undefined,
|
||||||
|
volumeMounts: created.volumeMounts.map((mount) => ({
|
||||||
|
hostPath: mount.hostPath,
|
||||||
|
containerPath: mount.containerPath,
|
||||||
|
readOnly: mount.mode === "ro" ? true : undefined,
|
||||||
|
})),
|
||||||
|
persistentStorage: created.persistentStorage,
|
||||||
|
resourceSizing: {
|
||||||
|
cpuLimit: created.resourceSizing.cpus !== undefined ? String(created.resourceSizing.cpus) : undefined,
|
||||||
|
memoryLimit: created.resourceSizing.memoryMB !== undefined ? `${created.resourceSizing.memoryMB}MB` : undefined,
|
||||||
|
},
|
||||||
|
errorMessage: created.errorMessage ?? undefined,
|
||||||
|
} satisfies ManagedDockerNodeInfo;
|
||||||
|
setDockerNodes((previous) => [...previous, normalized]);
|
||||||
|
return normalized;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dockerNodes,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
getContainerStatus,
|
||||||
|
getLogs,
|
||||||
|
create,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const service = {
|
|||||||
listContexts: vi.fn(),
|
listContexts: vi.fn(),
|
||||||
testConnection: vi.fn(),
|
testConnection: vi.fn(),
|
||||||
recreateContainer: vi.fn(),
|
recreateContainer: vi.fn(),
|
||||||
|
getContainerInfo: vi.fn(),
|
||||||
|
getContainerLogs: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockCentralInstance = {
|
const mockCentralInstance = {
|
||||||
@@ -99,6 +101,98 @@ describe("registerDockerNodeRoutes", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect((res.body as any).available).toBe(false);
|
expect((res.body as any).available).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("GET /api/docker/nodes returns enriched list", async () => {
|
||||||
|
mockCentralInstance.listManagedDockerNodes = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "mdn-1",
|
||||||
|
nodeId: "node-1",
|
||||||
|
name: "Docker One",
|
||||||
|
status: "running",
|
||||||
|
hostConfig: {},
|
||||||
|
envVars: {},
|
||||||
|
imageName: "runfusion/fusion",
|
||||||
|
imageTag: "latest",
|
||||||
|
volumeMounts: [],
|
||||||
|
persistentStorage: true,
|
||||||
|
resourceSizing: {},
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockCentralInstance.getNode.mockResolvedValue({ id: "node-1", name: "Linked", type: "remote", status: "online", maxConcurrent: 2, createdAt: "x", updatedAt: "x" });
|
||||||
|
const res = await request(app(), "GET", "/api/docker/nodes");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((res.body as any)[0].linkedNode.id).toBe("node-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/docker/nodes/:id/container-status handles success and errors", async () => {
|
||||||
|
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
|
||||||
|
service.getContainerInfo.mockResolvedValueOnce({ state: { running: true }, status: "running" });
|
||||||
|
const ok = await request(app(), "GET", "/api/docker/nodes/mdn-1/container-status");
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect((ok.body as any).status).toBe("running");
|
||||||
|
|
||||||
|
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce(undefined);
|
||||||
|
const notFound = await request(app(), "GET", "/api/docker/nodes/missing/container-status");
|
||||||
|
expect(notFound.status).toBe(404);
|
||||||
|
|
||||||
|
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "creating", hostConfig: {}, containerId: null });
|
||||||
|
const bad = await request(app(), "GET", "/api/docker/nodes/mdn-1/container-status");
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
|
||||||
|
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
|
||||||
|
service.getContainerInfo.mockRejectedValueOnce(new Error("down"));
|
||||||
|
const unavailable = await request(app(), "GET", "/api/docker/nodes/mdn-1/container-status");
|
||||||
|
expect(unavailable.status).toBe(503);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/docker/nodes/:id/logs clamps tail and handles errors", async () => {
|
||||||
|
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
|
||||||
|
service.getContainerLogs.mockResolvedValueOnce("hello");
|
||||||
|
const ok = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs?tail=5000");
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect(service.getContainerLogs).toHaveBeenCalledWith("c1", {}, { tail: 1000 });
|
||||||
|
|
||||||
|
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce(undefined);
|
||||||
|
const notFound = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs");
|
||||||
|
expect(notFound.status).toBe(404);
|
||||||
|
|
||||||
|
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "creating", hostConfig: {}, containerId: null });
|
||||||
|
const noContainer = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs");
|
||||||
|
expect(noContainer.status).toBe(400);
|
||||||
|
|
||||||
|
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({ id: "mdn-1", status: "running", hostConfig: {}, containerId: "c1" });
|
||||||
|
service.getContainerLogs.mockRejectedValueOnce(new Error("down"));
|
||||||
|
const bad = await request(app(), "GET", "/api/docker/nodes/mdn-1/logs");
|
||||||
|
expect(bad.status).toBe(503);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/docker/nodes/:id returns enriched node and 404", async () => {
|
||||||
|
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce({
|
||||||
|
id: "mdn-1",
|
||||||
|
nodeId: "node-1",
|
||||||
|
name: "Docker One",
|
||||||
|
status: "running",
|
||||||
|
hostConfig: {},
|
||||||
|
envVars: {},
|
||||||
|
imageName: "runfusion/fusion",
|
||||||
|
imageTag: "latest",
|
||||||
|
volumeMounts: [],
|
||||||
|
persistentStorage: true,
|
||||||
|
resourceSizing: {},
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
mockCentralInstance.getNode.mockResolvedValueOnce({ id: "node-1", name: "Linked", type: "remote", status: "online", maxConcurrent: 2, createdAt: "x", updatedAt: "x" });
|
||||||
|
const ok = await request(app(), "GET", "/api/docker/nodes/mdn-1");
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect((ok.body as any).linkedNode.id).toBe("node-1");
|
||||||
|
|
||||||
|
mockCentralInstance.getManagedDockerNode.mockResolvedValueOnce(undefined);
|
||||||
|
const missing = await request(app(), "GET", "/api/docker/nodes/missing");
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Mesh config routes", () => {
|
describe("Mesh config routes", () => {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { DockerExtraCli, DockerHostConfig, DockerVolumeMount, ManagedDockerNodeInput, FullProvisioningInput } from "@fusion/core";
|
import type {
|
||||||
|
DockerExtraCli,
|
||||||
|
DockerHostConfig,
|
||||||
|
DockerVolumeMount,
|
||||||
|
FullProvisioningInput,
|
||||||
|
ManagedDockerNode,
|
||||||
|
ManagedDockerNodeInput,
|
||||||
|
NodeConfig,
|
||||||
|
} from "@fusion/core";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||||
import type { ApiRouteRegistrar } from "./types.js";
|
import type { ApiRouteRegistrar } from "./types.js";
|
||||||
@@ -55,6 +63,33 @@ function sanitizeExtraClis(input: unknown): DockerExtraCli[] {
|
|||||||
return input as DockerExtraCli[];
|
return input as DockerExtraCli[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toManagedDockerNodeInfo(managedNode: ManagedDockerNode, linkedNode?: NodeConfig) {
|
||||||
|
return {
|
||||||
|
...managedNode,
|
||||||
|
hostConfig: {
|
||||||
|
type: managedNode.hostConfig.host || managedNode.hostConfig.context ? "remote" : "local",
|
||||||
|
host: managedNode.hostConfig.host,
|
||||||
|
context: managedNode.hostConfig.context,
|
||||||
|
tlsOptions: {
|
||||||
|
tlsVerify: managedNode.hostConfig.tlsVerify,
|
||||||
|
tlsCaPath: managedNode.hostConfig.tlsCaPath,
|
||||||
|
tlsCertPath: managedNode.hostConfig.tlsCertPath,
|
||||||
|
tlsKeyPath: managedNode.hostConfig.tlsKeyPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
volumeMounts: managedNode.volumeMounts.map((mount) => ({
|
||||||
|
hostPath: mount.hostPath,
|
||||||
|
containerPath: mount.containerPath,
|
||||||
|
readOnly: mount.mode === "ro" ? true : undefined,
|
||||||
|
})),
|
||||||
|
resourceSizing: {
|
||||||
|
cpuLimit: managedNode.resourceSizing?.cpus !== undefined ? String(managedNode.resourceSizing.cpus) : undefined,
|
||||||
|
memoryLimit: managedNode.resourceSizing?.memoryMB !== undefined ? `${managedNode.resourceSizing.memoryMB}MB` : undefined,
|
||||||
|
},
|
||||||
|
linkedNode: linkedNode ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||||
const { router, rethrowAsApiError } = ctx;
|
const { router, rethrowAsApiError } = ctx;
|
||||||
|
|
||||||
@@ -102,6 +137,132 @@ export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/docker/nodes", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
try {
|
||||||
|
const nodes = await central.listManagedDockerNodes();
|
||||||
|
const enriched = await Promise.all(nodes.map(async (managedNode) => {
|
||||||
|
const linkedNode = managedNode.nodeId ? await central.getNode(managedNode.nodeId) : undefined;
|
||||||
|
return toManagedDockerNodeInfo(managedNode, linkedNode);
|
||||||
|
}));
|
||||||
|
enriched.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
|
||||||
|
res.json(enriched);
|
||||||
|
} finally {
|
||||||
|
await central.close();
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
rethrowAsApiError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/docker/nodes/:managedId/container-status", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { CentralCore, DockerClientService } = await import("@fusion/core");
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
try {
|
||||||
|
const managedNode = await central.getManagedDockerNode(req.params.managedId);
|
||||||
|
if (!managedNode) {
|
||||||
|
throw notFound("Managed Docker node not found");
|
||||||
|
}
|
||||||
|
if (!managedNode.containerId) {
|
||||||
|
throw badRequest(`Node has no container yet (status: ${managedNode.status})`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const dockerService = new DockerClientService(managedNode.hostConfig);
|
||||||
|
const info = await dockerService.getContainerInfo(managedNode.containerId, managedNode.hostConfig);
|
||||||
|
if (!info) {
|
||||||
|
throw notFound("Container not found");
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
running: info.state.running,
|
||||||
|
status: info.status,
|
||||||
|
startedAt: info.state.startedAt,
|
||||||
|
finishedAt: info.state.finishedAt,
|
||||||
|
exitCode: info.state.exitCode,
|
||||||
|
error: info.state.error,
|
||||||
|
ports: info.ports,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
res.status(503).json({ error: `Docker unreachable: ${message}` });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await central.close();
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
rethrowAsApiError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/docker/nodes/:managedId/logs", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { CentralCore, DockerClientService } = await import("@fusion/core");
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
try {
|
||||||
|
const managedNode = await central.getManagedDockerNode(req.params.managedId);
|
||||||
|
if (!managedNode) {
|
||||||
|
throw notFound("Managed Docker node not found");
|
||||||
|
}
|
||||||
|
if (!managedNode.containerId) {
|
||||||
|
throw badRequest(`Node has no container yet (status: ${managedNode.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailValue = Number(req.query.tail ?? 100);
|
||||||
|
const tail = Number.isFinite(tailValue) ? Math.max(1, Math.min(1000, Math.floor(tailValue))) : 100;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dockerService = new DockerClientService(managedNode.hostConfig);
|
||||||
|
const logs = await dockerService.getContainerLogs(managedNode.containerId, managedNode.hostConfig, { tail });
|
||||||
|
res.json({ logs });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
res.status(503).json({ error: `Docker unreachable: ${message}` });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await central.close();
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
rethrowAsApiError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/docker/nodes/:managedId", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
try {
|
||||||
|
const node = await central.getManagedDockerNode(req.params.managedId);
|
||||||
|
if (!node) {
|
||||||
|
throw notFound("Managed Docker node not found");
|
||||||
|
}
|
||||||
|
const linkedNode = node.nodeId ? await central.getNode(node.nodeId) : undefined;
|
||||||
|
res.json(toManagedDockerNodeInfo(node, linkedNode));
|
||||||
|
} finally {
|
||||||
|
await central.close();
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
rethrowAsApiError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/docker-nodes", async (_req, res) => {
|
router.get("/docker-nodes", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
|||||||
Reference in New Issue
Block a user