Files
fusion/packages/dashboard/app/components/DockerTargetSelector.tsx
Fusion fca870f4f5 feat(FN-3109): add Docker connectivity service and target selector UI
This merge adds Docker connectivity management to Fusion (FN-3109), introducing a Docker client service in `@fusion/core` with typed interfaces for daemon connection configuration (host, socket path, TLS). The dashboard gains a `DockerTargetSelector` component for choosing between local socket and T

Fusion-Task-Id: FN-3109
2026-05-03 07:47:45 -07:00

129 lines
5.6 KiB
TypeScript

import { RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { DockerHostConfig } from "@fusion/core";
import { useDockerTargets } from "../hooks/useDockerTargets";
import { DockerTlsConfig } from "./DockerTlsConfig";
import "./DockerTargetSelector.css";
type TargetMode = "local" | "context" | "host";
interface DockerTargetSelectorProps {
value?: DockerHostConfig;
onChange: (config: DockerHostConfig) => void;
onError?: (error: string) => void;
}
export function DockerTargetSelector({ value, onChange, onError }: DockerTargetSelectorProps) {
const initialMode: TargetMode = value?.context ? "context" : value?.host ? "host" : "local";
const [mode, setMode] = useState<TargetMode>(initialMode);
const [selectedContext, setSelectedContext] = useState(value?.context ?? "");
const [host, setHost] = useState(value?.host ?? "");
const [localStatus, setLocalStatus] = useState<string | null>(null);
const { contexts, isLoadingContexts, contextsError, loadContexts, testConnection, isTestingConnection, lastTestResult, checkLocalDocker, isCheckingLocal } = useDockerTargets();
const tlsValue = useMemo(
() => ({
tlsVerify: value?.tlsVerify,
tlsCaPath: value?.tlsCaPath,
tlsCertPath: value?.tlsCertPath,
tlsKeyPath: value?.tlsKeyPath,
}),
[value],
);
useEffect(() => {
if (mode === "local") {
onChange({});
return;
}
if (mode === "context") {
void loadContexts().catch((error) => onError?.(error instanceof Error ? error.message : String(error)));
onChange(selectedContext ? { context: selectedContext } : {});
return;
}
onChange({ host, ...tlsValue });
}, [mode]);
const updateTls = useCallback(
(tls: Pick<DockerHostConfig, "tlsVerify" | "tlsCaPath" | "tlsCertPath" | "tlsKeyPath">) => {
if (mode !== "host") return;
onChange({ host, ...tls });
},
[host, mode, onChange],
);
return (
<div className="docker-target-selector">
<div className="docker-target-selector__modes" role="group" aria-label="Docker target mode">
<button type="button" className={`btn btn-sm ${mode === "local" ? "docker-target-selector__mode-active" : ""}`} onClick={() => {
setMode("local");
void checkLocalDocker()
.then((result) => setLocalStatus(result.available ? `Docker is available${result.version ? ` (${result.version})` : ""}` : `Docker not found${result.error ? `: ${result.error}` : ""}`))
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setLocalStatus(`Docker not found: ${message}`);
onError?.(message);
});
}}>Local Docker</button>
<button type="button" className={`btn btn-sm ${mode === "context" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("context")}>Docker Context</button>
<button type="button" className={`btn btn-sm ${mode === "host" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("host")}>Remote Host</button>
</div>
{mode === "local" && localStatus && <div className="docker-target-selector__status">{localStatus}</div>}
{mode === "context" && (
<div className="docker-target-selector__panel">
<div className="docker-target-selector__context-row">
<select className="select" value={selectedContext} onChange={(event) => {
const next = event.target.value;
setSelectedContext(next);
onChange(next ? { context: next } : {});
}}>
<option value="">Select context</option>
{contexts.map((context) => (
<option key={context.name} value={context.name}>{context.name}{context.isCurrentContext ? " (current)" : ""}{context.dockerHost ? `${context.dockerHost}` : ""}</option>
))}
</select>
<button type="button" className="btn btn-sm btn-icon" onClick={() => void loadContexts()} disabled={isLoadingContexts} aria-label="Refresh contexts"><RefreshCw size={14} /></button>
</div>
{contextsError && <div className="docker-target-selector__error">{contextsError}</div>}
</div>
)}
{mode === "host" && (
<div className="docker-target-selector__panel">
<div className="docker-target-selector__field">
<label htmlFor="docker-target-selector-host">Docker Host</label>
<input
id="docker-target-selector-host"
className="input"
placeholder="tcp://host:2376"
value={host}
onChange={(event) => {
const next = event.target.value;
setHost(next);
onChange({ host: next, ...tlsValue });
}}
/>
</div>
<DockerTlsConfig value={tlsValue} onChange={updateTls} />
</div>
)}
<button type="button" className="btn btn-sm" onClick={() => void testConnection(mode === "local" ? undefined : mode === "context" ? { context: selectedContext } : { host, ...tlsValue })} disabled={isTestingConnection || isCheckingLocal}>
{isTestingConnection ? "Testing..." : "Test Connection"}
</button>
{lastTestResult && (
<div className={lastTestResult.success ? "docker-target-selector__success" : "docker-target-selector__error"}>
{lastTestResult.success
? `Connected${lastTestResult.dockerVersion ? ` (Docker ${lastTestResult.dockerVersion})` : ""}`
: lastTestResult.error ?? "Connection failed"}
</div>
)}
</div>
);
}