mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: finish the docker plugin (B15)
This commit is contained in:
1 parent
0a3f05d99d
commit
993a91c899
167 files changed
+26299
-5136
No files matched your search
@@ -1,8 +1,8 @@
|
||||
import { ConnectionScreen, FullScreenAppWrapper } from "@termix/plugin-sdk/ui";
|
||||
import React from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { DockerManager } from "./DockerManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { toDockerHost } from "./types";
|
||||
|
||||
interface DockerAppProps {
|
||||
hostId?: string;
|
||||
@@ -37,7 +37,9 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
|
||||
return (
|
||||
<DockerManager
|
||||
hostConfig={hostConfig}
|
||||
host={toDockerHost(
|
||||
hostConfig as unknown as Record<string, unknown>,
|
||||
)}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Alert, AlertDescription } from "@/components/alert.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import {
|
||||
AlertCircle,
|
||||
Box,
|
||||
@@ -14,44 +9,46 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
useTranslation,
|
||||
useConnectionRetry,
|
||||
useHost,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
|
||||
import { logActivity, getSSHHosts } from "@/main-axios";
|
||||
import {
|
||||
connectDockerSession,
|
||||
disconnectDockerSession,
|
||||
listDockerContainers,
|
||||
validateDockerAvailability,
|
||||
keepaliveDockerSession,
|
||||
verifyDockerTOTP,
|
||||
verifyDockerWarpgate,
|
||||
} from "./docker-api";
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Card,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
Input,
|
||||
SSHAuthDialog,
|
||||
Select2,
|
||||
Separator,
|
||||
TOTPDialog,
|
||||
WarpgateDialog,
|
||||
logActivity,
|
||||
useAdaptivePolling,
|
||||
useAreaPreferences,
|
||||
useConnectionLog,
|
||||
useTabsSafe,
|
||||
useUiPreferencesContext,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { DockerApiError, useDockerApi, type ConnectResult } from "./docker-api";
|
||||
import {
|
||||
dockerEnabled,
|
||||
hostTitle,
|
||||
toDockerHost,
|
||||
type DockerContainer,
|
||||
type DockerHost,
|
||||
type DockerValidation,
|
||||
} from "./types";
|
||||
import { ContainerList } from "./components/ContainerList.tsx";
|
||||
import { ContainerTable } from "./components/ContainerTable.tsx";
|
||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
import {
|
||||
useAreaPreferences,
|
||||
useUiPreferencesContext,
|
||||
} from "@/contexts/UiPreferencesContext";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface DockerManagerProps {
|
||||
hostConfig?: SSHHost;
|
||||
host?: DockerHost;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
@@ -59,15 +56,21 @@ interface DockerManagerProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type ConnectionLogInput = Omit<LogEntry, "id" | "timestamp">;
|
||||
interface PromptState {
|
||||
kind: "totp" | "warpgate";
|
||||
sessionId: string;
|
||||
prompt?: string;
|
||||
isPassword?: boolean;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
}
|
||||
|
||||
interface DockerConnectionError {
|
||||
message?: string;
|
||||
connectionLogs?: ConnectionLogInput[];
|
||||
function newSessionId(): string {
|
||||
return `docker-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
function DockerManagerInner({
|
||||
hostConfig,
|
||||
host,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
@@ -75,11 +78,24 @@ function DockerManagerInner({
|
||||
onClose,
|
||||
}: DockerManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const { addLog, setLogs, clearLogs } = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
const dockerPrefs = useAreaPreferences("docker");
|
||||
const uiPrefsCtx = useUiPreferencesContext();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
|
||||
// The shell's host list keeps the host current after an edit; a
|
||||
// standalone window has no shell, so it keeps the host it was given.
|
||||
const liveRecord = useHost(host?.id);
|
||||
const currentHost = React.useMemo(
|
||||
() =>
|
||||
liveRecord
|
||||
? toDockerHost(liveRecord as unknown as Record<string, unknown>)
|
||||
: host,
|
||||
[liveRecord, host],
|
||||
);
|
||||
const enabled = dockerEnabled(currentHost);
|
||||
|
||||
const [sessionId, setSessionId] = React.useState<string | null>(null);
|
||||
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
|
||||
const containersSignatureRef = React.useRef("");
|
||||
@@ -105,23 +121,14 @@ function DockerManagerInner({
|
||||
React.useState<DockerValidation | null>(null);
|
||||
const [isValidating, setIsValidating] = React.useState(false);
|
||||
// Initial view follows the interface preset; switching it afterwards is a
|
||||
// per-session choice, same as before.
|
||||
// per-session choice.
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "detail">(
|
||||
dockerPrefs.viewMode,
|
||||
);
|
||||
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
|
||||
const [hasLoadedContainersOnce, setHasLoadedContainersOnce] =
|
||||
React.useState(false);
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
const [warpgateRequired, setWarpgateRequired] = React.useState(false);
|
||||
const [warpgateSessionId, setWarpgateSessionId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [warpgateUrl, setWarpgateUrl] = React.useState<string>("");
|
||||
const [warpgateSecurityKey, setWarpgateSecurityKey] =
|
||||
React.useState<string>("");
|
||||
const [prompt, setPrompt] = React.useState<PromptState | null>(null);
|
||||
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
|
||||
const [authReason, setAuthReason] = React.useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
@@ -131,214 +138,168 @@ function DockerManagerInner({
|
||||
const [retryCount, setRetryCount] = React.useState(0);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const logDockerActivity = async () => {
|
||||
if (
|
||||
!currentHostConfig?.id ||
|
||||
activityLoggedRef.current ||
|
||||
activityLoggingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
const logDockerActivity = () => {
|
||||
if (!currentHost?.id || activityLoggedRef.current) return;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName =
|
||||
currentHostConfig.name ||
|
||||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
await logActivity("docker", currentHostConfig.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log docker activity:", err);
|
||||
logActivity("docker", currentHost.id, hostTitle(currentHost)).catch(() => {
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
setContainers([]);
|
||||
setSelectedContainer(null);
|
||||
setSessionId(null);
|
||||
setDockerValidation(null);
|
||||
setViewMode("list");
|
||||
setContainers([]);
|
||||
setSelectedContainer(null);
|
||||
setSessionId(null);
|
||||
setDockerValidation(null);
|
||||
setViewMode("list");
|
||||
}, [host?.id]);
|
||||
|
||||
const retryRef = React.useRef<ReturnType<typeof useConnectionRetry> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const validate = async (sid: string, clearAfter: boolean) => {
|
||||
setSessionId(sid);
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const validation = await docker.validate(sid);
|
||||
setDockerValidation(validation);
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
retryRef.current?.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
if (clearAfter) setTimeout(() => clearLogs(), 1000);
|
||||
retryRef.current?.markConnected();
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [hostConfig?.id]);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
/** Acts on one step of a connect, whichever request it answered. */
|
||||
const applyConnectResult = async (
|
||||
sid: string,
|
||||
result: ConnectResult,
|
||||
clearAfter: boolean,
|
||||
) => {
|
||||
if (result.connectionLogs) setLogs(result.connectionLogs);
|
||||
|
||||
if (result.requires_warpgate) {
|
||||
setPrompt({
|
||||
kind: "warpgate",
|
||||
sessionId: sid,
|
||||
url: result.url || "",
|
||||
securityKey: result.securityKey || "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.requires_totp) {
|
||||
if (result.retry) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.totpVerificationFailed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
setPrompt({
|
||||
kind: "totp",
|
||||
sessionId: sid,
|
||||
prompt: result.prompt || t("docker.verificationCodePrompt"),
|
||||
isPassword: result.isPassword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.status === "auth_required") {
|
||||
setAuthReason(
|
||||
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
|
||||
);
|
||||
setShowAuthDialog(true);
|
||||
return;
|
||||
}
|
||||
setPrompt(null);
|
||||
await validate(sid, clearAfter);
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
const reportFailure = (error: unknown, fallback: string) => {
|
||||
if (error instanceof DockerApiError && error.connectionLogs?.length) {
|
||||
setLogs(error.connectionLogs);
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error instanceof Error ? error.message : fallback,
|
||||
});
|
||||
}
|
||||
retryRef.current?.markFailed();
|
||||
};
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
const connect = async (credentials?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
}) => {
|
||||
if (!currentHost?.id) return;
|
||||
const sid = newSessionId();
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const result = await docker.connect(sid, currentHost.id, credentials);
|
||||
await applyConnectResult(sid, result, !credentials);
|
||||
} catch (error) {
|
||||
reportFailure(error, t("docker.connectionFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initializingRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const initSession = async () => {
|
||||
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializingRef.current) return;
|
||||
initializingRef.current = true;
|
||||
|
||||
if (sessionId) {
|
||||
initializingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
clearLogs();
|
||||
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
const result = await connectDockerSession(sid, currentHostConfig.id, {
|
||||
useSocks5: currentHostConfig.useSocks5,
|
||||
socks5Host: currentHostConfig.socks5Host,
|
||||
socks5Port: currentHostConfig.socks5Port,
|
||||
socks5Username: currentHostConfig.socks5Username,
|
||||
socks5Password: currentHostConfig.socks5Password,
|
||||
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(sid);
|
||||
setWarpgateUrl(result.url || "");
|
||||
setWarpgateSecurityKey(result.securityKey || "");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sid);
|
||||
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setShowAuthDialog(true);
|
||||
setAuthReason(
|
||||
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
|
||||
);
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionId(sid);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(sid);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
setTimeout(() => clearLogs(), 1000);
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
} catch (error) {
|
||||
const dockerError = error as DockerConnectionError;
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
|
||||
if (Array.isArray(dockerError.connectionLogs)) {
|
||||
setLogs(dockerError.connectionLogs);
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: dockerError.message || t("docker.connectionFailed"),
|
||||
});
|
||||
}
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
initSession();
|
||||
if (!currentHost?.id || !enabled) return;
|
||||
if (initializingRef.current || sessionId) return;
|
||||
initializingRef.current = true;
|
||||
clearLogs();
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
initializingRef.current = false;
|
||||
if (sessionId) {
|
||||
disconnectDockerSession(sessionId).catch(() => {
|
||||
// Silently handle disconnect errors
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, currentHostConfig?.enableDocker, retryCount]);
|
||||
}, [currentHost?.id, enabled, retryCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
return () => {
|
||||
docker.disconnect(sessionId).catch(() => {});
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible) return;
|
||||
|
||||
const keepalive = setInterval(
|
||||
() => {
|
||||
keepaliveDockerSession(sessionId).catch(() => {
|
||||
// Silently handle keepalive errors
|
||||
});
|
||||
docker.keepalive(sessionId).catch(() => {});
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
return () => clearInterval(keepalive);
|
||||
}, [sessionId, isVisible]);
|
||||
|
||||
const refreshContainers = React.useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
setContainers(data);
|
||||
setContainers(await docker.listContainers(sessionId, true));
|
||||
} catch {
|
||||
// Silently handle polling errors
|
||||
// the next poll tries again
|
||||
}
|
||||
}, [sessionId]);
|
||||
}, [sessionId, docker]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHasLoadedContainersOnce(false);
|
||||
@@ -350,7 +311,7 @@ function DockerManagerInner({
|
||||
setIsLoadingContainers((loading) =>
|
||||
hasLoadedContainersOnce ? loading : true,
|
||||
);
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
const data = await docker.listContainers(sessionId, true);
|
||||
const signature = JSON.stringify(data);
|
||||
const changed = signature !== containersSignatureRef.current;
|
||||
containersSignatureRef.current = signature;
|
||||
@@ -378,124 +339,48 @@ function DockerManagerInner({
|
||||
setSelectedContainer(null);
|
||||
}, []);
|
||||
|
||||
const handleTotpSubmit = async (code: string) => {
|
||||
if (!totpSessionId || !code) return;
|
||||
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const result = await verifyDockerTOTP(totpSessionId, code);
|
||||
|
||||
if (result?.status === "success") {
|
||||
setTotpRequired(false);
|
||||
setTotpPrompt("");
|
||||
setSessionId(totpSessionId);
|
||||
setTotpSessionId(null);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(totpSessionId);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("TOTP verification failed:", error);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.totpVerificationFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
const closeTab = () => {
|
||||
if (currentTab !== null) removeTab(currentTab);
|
||||
};
|
||||
|
||||
const handleTotpCancel = () => {
|
||||
setTotpRequired(false);
|
||||
setTotpSessionId(null);
|
||||
setTotpPrompt("");
|
||||
setIsConnecting(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
const handleTotpSubmit = async (code: string) => {
|
||||
if (!prompt || prompt.kind !== "totp" || !code) return;
|
||||
const sid = prompt.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
await applyConnectResult(sid, await docker.answerTotp(sid, code), false);
|
||||
} catch (error) {
|
||||
reportFailure(error, t("docker.totpVerificationFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateContinue = async () => {
|
||||
if (!warpgateSessionId) return;
|
||||
|
||||
if (!prompt || prompt.kind !== "warpgate") return;
|
||||
const sid = prompt.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const result = await verifyDockerWarpgate(warpgateSessionId);
|
||||
|
||||
if (result?.status === "success") {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
setSessionId(warpgateSessionId);
|
||||
setWarpgateSessionId(null);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(warpgateSessionId);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
}
|
||||
await applyConnectResult(sid, await docker.continueWarpgate(sid), false);
|
||||
} catch (error) {
|
||||
console.error("Warpgate verification failed:", error);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.warpgateVerificationFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
reportFailure(error, t("docker.warpgateVerificationFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateCancel = () => {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateSessionId(null);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
const handlePromptCancel = () => {
|
||||
const sid = prompt?.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
if (sid) docker.disconnect(sid).catch(() => {});
|
||||
closeTab();
|
||||
};
|
||||
|
||||
const handleWarpgateOpenUrl = () => {
|
||||
if (warpgateUrl) {
|
||||
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
if (prompt?.url) window.open(prompt.url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleAuthSubmit = async (credentials: {
|
||||
@@ -503,75 +388,12 @@ function DockerManagerInner({
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
|
||||
setShowAuthDialog(false);
|
||||
setIsConnecting(true);
|
||||
|
||||
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
const result = await connectDockerSession(sid, currentHostConfig.id, {
|
||||
userProvidedPassword: credentials.password,
|
||||
userProvidedSshKey: credentials.sshKey,
|
||||
userProvidedKeyPassword: credentials.keyPassword,
|
||||
useSocks5: currentHostConfig.useSocks5,
|
||||
socks5Host: currentHostConfig.socks5Host,
|
||||
socks5Port: currentHostConfig.socks5Port,
|
||||
socks5Username: currentHostConfig.socks5Username,
|
||||
socks5Password: currentHostConfig.socks5Password,
|
||||
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(sid);
|
||||
setWarpgateUrl(result.url || "");
|
||||
setWarpgateSecurityKey(result.securityKey || "N/A");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sid);
|
||||
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setShowAuthDialog(true);
|
||||
setAuthReason("auth_failed");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionId(sid);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(sid);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
} catch (error) {
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("docker.connectionFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
await connect({
|
||||
userProvidedPassword: credentials.password,
|
||||
userProvidedSshKey: credentials.sshKey,
|
||||
userProvidedKeyPassword: credentials.keyPassword,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuthCancel = () => {
|
||||
@@ -592,15 +414,10 @@ function DockerManagerInner({
|
||||
connect: () => {
|
||||
handleRetry();
|
||||
},
|
||||
enabled:
|
||||
!!currentHostConfig?.enableDocker &&
|
||||
!totpRequired &&
|
||||
!warpgateRequired &&
|
||||
!showAuthDialog,
|
||||
enabled: enabled && !prompt && !showAuthDialog,
|
||||
autoStart: false,
|
||||
});
|
||||
const dockerConnectRetryRef = React.useRef(dockerConnectRetry);
|
||||
dockerConnectRetryRef.current = dockerConnectRetry;
|
||||
retryRef.current = dockerConnectRetry;
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = 8;
|
||||
@@ -619,9 +436,9 @@ function DockerManagerInner({
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||
: "bg-canvas text-foreground border-2 border-edge overflow-hidden";
|
||||
|
||||
if (!currentHostConfig?.enableDocker) {
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
@@ -649,6 +466,40 @@ function DockerManagerInner({
|
||||
);
|
||||
}
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
<TOTPDialog
|
||||
isOpen={prompt?.kind === "totp"}
|
||||
prompt={prompt?.prompt ?? ""}
|
||||
mode={prompt?.isPassword ? "password" : "totp"}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handlePromptCancel}
|
||||
/>
|
||||
<WarpgateDialog
|
||||
isOpen={prompt?.kind === "warpgate"}
|
||||
url={prompt?.url ?? ""}
|
||||
securityKey={prompt?.securityKey ?? ""}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handlePromptCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
/>
|
||||
{currentHost && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authReason}
|
||||
onSubmit={handleAuthSubmit}
|
||||
onCancel={handleAuthCancel}
|
||||
hostInfo={{
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username ?? "",
|
||||
name: currentHost.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isConnecting || isValidating) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
@@ -662,6 +513,7 @@ function DockerManagerInner({
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
/>
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -689,12 +541,12 @@ function DockerManagerInner({
|
||||
{viewMode === "detail" &&
|
||||
sessionId &&
|
||||
selectedContainer &&
|
||||
currentHostConfig ? (
|
||||
currentHost ? (
|
||||
<ContainerDetail
|
||||
sessionId={sessionId}
|
||||
containerId={selectedContainer}
|
||||
containers={containers}
|
||||
hostConfig={currentHostConfig}
|
||||
hostConfig={currentHost}
|
||||
onBack={handleBack}
|
||||
initialTab={detailInitialTab}
|
||||
/>
|
||||
@@ -833,34 +685,7 @@ function DockerManagerInner({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
/>
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateRequired}
|
||||
url={warpgateUrl}
|
||||
securityKey={warpgateSecurityKey}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handleWarpgateCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
/>
|
||||
{currentHostConfig && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authReason}
|
||||
onSubmit={handleAuthSubmit}
|
||||
onCancel={handleAuthCancel}
|
||||
hostInfo={{
|
||||
ip: currentHostConfig.ip,
|
||||
port: currentHostConfig.port,
|
||||
username: currentHostConfig.username,
|
||||
name: currentHostConfig.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dialogs}
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={t("docker.connecting")}
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useHost, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
import { DockerWidgetEditForm } from "./DockerWidgetEditForm";
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
WidgetTypeDefinition,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { DockerManager } from "./DockerManager";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { WidgetTitle } from "@/features/homepage/widgets/WidgetTitle";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type DockerWidgetConfig,
|
||||
type WidgetComponentProps,
|
||||
type WidgetDefinition,
|
||||
} from "./homepage";
|
||||
import { dockerEnabled, hostTitle, toDockerHost } from "./types";
|
||||
|
||||
function DockerWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const record = useHost(config.hostId || undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const found = hosts.find(
|
||||
(h) => h.id === config.hostId && h.enableDocker,
|
||||
);
|
||||
setHost(found ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostId]);
|
||||
|
||||
if (!config.hostId) {
|
||||
if (!config.hostId || !record || !dockerEnabled(record)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<Box size={20} />
|
||||
@@ -47,22 +27,7 @@ function DockerWidget({
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.widgetNoHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const host = toDockerHost(record as unknown as Record<string, unknown>);
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
|
||||
@@ -71,8 +36,8 @@ function DockerWidget({
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DockerManager
|
||||
hostConfig={host as unknown as SSHHost}
|
||||
title={host.name || host.ip}
|
||||
host={host}
|
||||
title={hostTitle(host)}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
@@ -83,7 +48,7 @@ function DockerWidget({
|
||||
}
|
||||
|
||||
/** Registered by the plugin while it runs. */
|
||||
export const dockerWidget: WidgetTypeDefinition<DockerWidgetConfig> = {
|
||||
export const dockerWidget: WidgetDefinition<DockerWidgetConfig> = {
|
||||
id: "docker_widget",
|
||||
name: "Docker Manager",
|
||||
description: "Embedded Docker container manager for a configured host",
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "@/features/homepage/dialogs/SingleHostEditForm";
|
||||
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerWidgetConfig, WidgetEditFormProps } from "./homepage";
|
||||
import { dockerEnabled } from "./types";
|
||||
|
||||
export function DockerWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
const options = hosts.filter(
|
||||
(host) => host.enableSsh !== false && dockerEnabled(host),
|
||||
);
|
||||
|
||||
return (
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableDocker)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<Select2
|
||||
value={config.hostId || ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: Number(e.target.value) })
|
||||
}
|
||||
className="h-8 text-xs border border-border bg-background px-2"
|
||||
>
|
||||
<option value="">{t("homepage.selectHost")}</option>
|
||||
{options.map((host) => (
|
||||
<option key={host.id} value={host.id}>
|
||||
{host.name || host.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Box } from "lucide-react";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import type { HostEditorForm } from "@/sidebar/HostEditorData";
|
||||
|
||||
type SetHostField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
export function HostDockerTab({
|
||||
form,
|
||||
setField,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dockerConfig = form.dockerConfig ?? { runtime: "docker" as const };
|
||||
const runtime =
|
||||
dockerConfig.runtime === "podman"
|
||||
? ("podman" as const)
|
||||
: ("docker" as const);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.dockerIntegration")}
|
||||
icon={<Box className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableDockerMonitor")}
|
||||
description={t("hosts.enableDockerMonitorDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.enableDocker}
|
||||
onChange={(v) => setField("enableDocker", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.enableDocker && (
|
||||
<SettingRow
|
||||
label={t("hosts.containerRuntime")}
|
||||
description={t("hosts.containerRuntimeDesc")}
|
||||
>
|
||||
<Select2
|
||||
value={runtime}
|
||||
onChange={(e) =>
|
||||
setField("dockerConfig", {
|
||||
...dockerConfig,
|
||||
runtime: e.target.value as "docker" | "podman",
|
||||
})
|
||||
}
|
||||
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="docker">
|
||||
{t("hosts.containerRuntimeDocker")}
|
||||
</option>
|
||||
<option value="podman">
|
||||
{t("hosts.containerRuntimePodman")}
|
||||
</option>
|
||||
</Select2>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,42 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
RobustClipboardProvider,
|
||||
Select2,
|
||||
TERMINAL_FONTS,
|
||||
copyToClipboard,
|
||||
ensureTerminalFontsLoaded,
|
||||
isElectron,
|
||||
pluginWsUrl,
|
||||
readFromClipboard,
|
||||
resolveConnectionOrigin,
|
||||
resolveTermixThemeColors,
|
||||
useAppTheme as useTheme,
|
||||
useConnectionLog,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { DockerHost } from "../types";
|
||||
import React from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
||||
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { Card, CardContent } from "@/components/card.tsx";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
|
||||
import { pluginWsUrl } from "@/lib/plugin-transport";
|
||||
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { isElectron } from "@/main-axios.ts";
|
||||
import {
|
||||
useTranslation,
|
||||
useConnectionRetry,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { resolveTermixThemeColors } from "@/lib/terminal-look/terminal-theme";
|
||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
import { ensureTerminalFontsLoaded } from "@/lib/terminal-look/terminal-global-styles";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
|
||||
interface ConsoleTerminalProps {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerState: string;
|
||||
hostConfig: SSHHost;
|
||||
hostConfig: DockerHost;
|
||||
}
|
||||
|
||||
export function ConsoleTerminal(
|
||||
@@ -58,7 +61,12 @@ function ConsoleTerminalInner({
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
|
||||
const terminalConfig = React.useMemo(
|
||||
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
|
||||
() => ({
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(hostConfig.terminalConfig as Partial<
|
||||
typeof DEFAULT_TERMINAL_CONFIG
|
||||
> | null),
|
||||
}),
|
||||
[hostConfig.terminalConfig],
|
||||
);
|
||||
|
||||
@@ -321,7 +329,11 @@ function ConsoleTerminalInner({
|
||||
JSON.stringify({
|
||||
type: "connect",
|
||||
data: {
|
||||
hostConfig,
|
||||
hostConfig: {
|
||||
id: hostConfig.id,
|
||||
syncId: hostConfig.syncId ?? null,
|
||||
ip: hostConfig.ip,
|
||||
},
|
||||
containerId,
|
||||
shell: selectedShell,
|
||||
cols,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { Button, Card, useConfirmation } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Box,
|
||||
Play,
|
||||
@@ -14,16 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import {
|
||||
startDockerContainer,
|
||||
stopDockerContainer,
|
||||
restartDockerContainer,
|
||||
pauseDockerContainer,
|
||||
unpauseDockerContainer,
|
||||
removeDockerContainer,
|
||||
} from "../docker-api";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
|
||||
interface ContainerCardProps {
|
||||
container: DockerContainer;
|
||||
@@ -61,6 +52,7 @@ export function ContainerCard({
|
||||
}: ContainerCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const docker = useDockerApi();
|
||||
const [isStarting, setIsStarting] = React.useState(false);
|
||||
const [isStopping, setIsStopping] = React.useState(false);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
@@ -82,7 +74,7 @@ export function ContainerCard({
|
||||
e.stopPropagation();
|
||||
setIsStarting(true);
|
||||
try {
|
||||
await startDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "start");
|
||||
toast.success(t("docker.containerStarted", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
@@ -100,7 +92,7 @@ export function ContainerCard({
|
||||
e.stopPropagation();
|
||||
setIsStopping(true);
|
||||
try {
|
||||
await stopDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "stop");
|
||||
toast.success(t("docker.containerStopped", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
@@ -118,7 +110,7 @@ export function ContainerCard({
|
||||
e.stopPropagation();
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
await restartDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "restart");
|
||||
toast.success(t("docker.containerRestarted", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
@@ -137,10 +129,10 @@ export function ContainerCard({
|
||||
setIsPausing(true);
|
||||
try {
|
||||
if (container.state === "paused") {
|
||||
await unpauseDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "unpause");
|
||||
toast.success(t("docker.containerUnpaused", { name: containerName }));
|
||||
} else {
|
||||
await pauseDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "pause");
|
||||
toast.success(t("docker.containerPaused", { name: containerName }));
|
||||
}
|
||||
onRefresh?.();
|
||||
@@ -166,7 +158,7 @@ export function ContainerCard({
|
||||
async () => {
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
await removeDockerContainer(
|
||||
await docker.removeContainer(
|
||||
sessionId,
|
||||
container.id,
|
||||
container.state === "running",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button, Card, Separator } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer, DockerHost } from "../types";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerContainer, SSHHost } from "@/types";
|
||||
import { LogViewer } from "./LogViewer.tsx";
|
||||
import { ContainerStats } from "./ContainerStats.tsx";
|
||||
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
|
||||
@@ -23,7 +21,7 @@ interface ContainerDetailProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containers: DockerContainer[];
|
||||
hostConfig: SSHHost;
|
||||
hostConfig: DockerHost;
|
||||
onBack: () => void;
|
||||
initialTab?: DetailTab;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import { ContainerCard } from "./ContainerCard.tsx";
|
||||
|
||||
interface ContainerListProps {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { SectionCard, useAdaptivePolling } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerStats } from "../types";
|
||||
import React from "react";
|
||||
import {
|
||||
Activity,
|
||||
@@ -9,12 +11,9 @@ import {
|
||||
Network,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { DockerStats } from "@/types";
|
||||
import { getContainerStats } from "../docker-api";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
|
||||
interface ContainerStatsProps {
|
||||
sessionId: string;
|
||||
@@ -30,6 +29,7 @@ export function ContainerStats({
|
||||
containerState,
|
||||
}: ContainerStatsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [stats, setStats] = React.useState<DockerStats | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
@@ -41,7 +41,7 @@ export function ContainerStats({
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getContainerStats(sessionId, containerId);
|
||||
const data = await docker.stats(sessionId, containerId);
|
||||
const previous = statsRef.current;
|
||||
const changed =
|
||||
!previous ||
|
||||
@@ -58,7 +58,7 @@ export function ContainerStats({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, containerState, t]);
|
||||
}, [sessionId, containerId, containerState, t, docker]);
|
||||
|
||||
useAdaptivePolling(
|
||||
fetchStats,
|
||||
@@ -246,7 +246,7 @@ export function ContainerStats({
|
||||
</span>
|
||||
<DockerBadge
|
||||
state={
|
||||
containerState as import("@/types").DockerContainer["state"]
|
||||
containerState as import("../types").DockerContainer["state"]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import { startDockerContainer, stopDockerContainer } from "../docker-api";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
|
||||
type DetailTab = "logs" | "stats" | "console";
|
||||
@@ -30,6 +30,7 @@ export function ContainerTable({
|
||||
statusFilter = "all",
|
||||
}: ContainerTableProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [pendingId, setPendingId] = React.useState<string | null>(null);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
@@ -56,10 +57,10 @@ export function ContainerTable({
|
||||
setPendingId(container.id);
|
||||
try {
|
||||
if (container.state === "running") {
|
||||
await stopDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "stop");
|
||||
toast.success(t("docker.containerStopped", { name: containerName }));
|
||||
} else {
|
||||
await startDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "start");
|
||||
toast.success(t("docker.containerStarted", { name: containerName }));
|
||||
}
|
||||
onRefresh?.();
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select2,
|
||||
Separator,
|
||||
useAdaptivePolling,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { DockerLogOptions } from "../types";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Download, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerLogOptions } from "@/types";
|
||||
import { getContainerLogs, downloadContainerLogs } from "../docker-api";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
|
||||
interface LogViewerProps {
|
||||
sessionId: string;
|
||||
@@ -60,6 +62,7 @@ export function LogViewer({
|
||||
containerName,
|
||||
}: LogViewerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [rawLogs, setRawLogs] = React.useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
@@ -78,7 +81,7 @@ export function LogViewer({
|
||||
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
|
||||
timestamps: showTimestamps,
|
||||
};
|
||||
const data = await getContainerLogs(sessionId, containerId, options);
|
||||
const data = await docker.logs(sessionId, containerId, options);
|
||||
const next = data.logs.split("\n").filter(Boolean);
|
||||
const changed =
|
||||
next.length !== rawLogsRef.current.length ||
|
||||
@@ -89,11 +92,13 @@ export function LogViewer({
|
||||
}
|
||||
return changed;
|
||||
} catch (error) {
|
||||
toast.error(`Failed to fetch logs: ${getErrorMessage(error)}`);
|
||||
toast.error(
|
||||
t("docker.failedToFetchLogs", { error: getErrorMessage(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, tailLines, showTimestamps]);
|
||||
}, [sessionId, containerId, tailLines, showTimestamps, docker, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchLogs();
|
||||
@@ -120,9 +125,10 @@ export function LogViewer({
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const blob = await downloadContainerLogs(sessionId, containerId, {
|
||||
const data = await docker.logs(sessionId, containerId, {
|
||||
timestamps: showTimestamps,
|
||||
});
|
||||
const blob = new Blob([data.logs], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -133,7 +139,9 @@ export function LogViewer({
|
||||
document.body.removeChild(a);
|
||||
toast.success(t("docker.logsDownloaded"));
|
||||
} catch (error) {
|
||||
toast.error(`Failed to download logs: ${getErrorMessage(error)}`);
|
||||
toast.error(
|
||||
t("docker.failedToDownloadLogs", { error: getErrorMessage(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
|
||||
@@ -1,345 +1,170 @@
|
||||
import axios from "axios";
|
||||
import { dockerApi, handleApiError } from "@/main-axios";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
type PluginApiClient,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
DockerContainer,
|
||||
DockerLogOptions,
|
||||
DockerStats,
|
||||
DockerValidation,
|
||||
} from "@/types/index";
|
||||
} from "./types";
|
||||
|
||||
type ApiConnectionLog = {
|
||||
export interface ApiConnectionLog {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: string;
|
||||
stage: ConnectionStage;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
details?: string;
|
||||
}
|
||||
|
||||
type ConnectErrorResponse = {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
/** One step of a connect: connected, a prompt to answer, or auth needed. */
|
||||
export interface ConnectResult {
|
||||
success?: boolean;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function connectDockerSession(
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
config?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
},
|
||||
): Promise<{
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
requires_totp?: boolean;
|
||||
prompt?: string;
|
||||
isPassword?: boolean;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
retry?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
url?: string;
|
||||
url?: string | null;
|
||||
securityKey?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect", {
|
||||
sessionId,
|
||||
hostId,
|
||||
...config,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.status === "auth_required"
|
||||
) {
|
||||
return error.response.data;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
}
|
||||
|
||||
/** An API failure carrying the server's message and connection logs. */
|
||||
export class DockerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly connectionLogs?: ApiConnectionLog[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DockerApiError";
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(error: unknown, action: string): DockerApiError {
|
||||
const response = (
|
||||
error as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
};
|
||||
};
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_totp
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_warpgate
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.connectionLogs
|
||||
) {
|
||||
const data = error.response.data;
|
||||
const errorWithLogs = new Error(
|
||||
data.error || data.message || error.message,
|
||||
);
|
||||
Object.assign(errorWithLogs, {
|
||||
connectionLogs: data.connectionLogs,
|
||||
});
|
||||
throw errorWithLogs;
|
||||
}
|
||||
throw handleApiError(error, "connect to Docker SSH session");
|
||||
}
|
||||
)?.response;
|
||||
const data = response?.data;
|
||||
const message =
|
||||
data?.error ||
|
||||
data?.message ||
|
||||
(error instanceof Error ? error.message : `Failed to ${action}`);
|
||||
return new DockerApiError(message, response?.status, data?.connectionLogs);
|
||||
}
|
||||
|
||||
export async function verifyDockerTOTP(
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
async function call<T>(
|
||||
action: string,
|
||||
request: () => Promise<{ data: T }>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
return response.data;
|
||||
return (await request()).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify Docker TOTP");
|
||||
throw apiError(error, action);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyDockerWarpgate(
|
||||
sessionId: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect-warpgate", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify Docker Warpgate");
|
||||
}
|
||||
}
|
||||
export type ContainerAction =
|
||||
"start" | "stop" | "restart" | "pause" | "unpause";
|
||||
|
||||
export async function disconnectDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "disconnect from Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function keepaliveDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "keepalive Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerSessionStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; connected: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker session status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateDockerAvailability(
|
||||
sessionId: string,
|
||||
): Promise<DockerValidation> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/validate/${sessionId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "validate Docker availability");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDockerContainers(
|
||||
sessionId: string,
|
||||
all: boolean = true,
|
||||
): Promise<DockerContainer[]> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/containers/${sessionId}`, {
|
||||
params: { all },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list Docker containers");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerContainerDetails(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerContainer> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker container details");
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/start`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "start Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/stop`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "stop Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/restart`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "restart Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function pauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/pause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "pause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/unpause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unpause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
force: boolean = false,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.delete(
|
||||
`/containers/${sessionId}/${containerId}/remove`,
|
||||
{
|
||||
params: { force },
|
||||
export function createDockerApi(api: PluginApiClient) {
|
||||
return {
|
||||
connect: (
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
credentials?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove Docker container");
|
||||
}
|
||||
) =>
|
||||
call<ConnectResult>("connect to Docker", () =>
|
||||
api.post("/ssh/connect", { sessionId, hostId, ...credentials }),
|
||||
),
|
||||
|
||||
answerTotp: (sessionId: string, totpCode: string) =>
|
||||
call<ConnectResult>("verify the code", () =>
|
||||
api.post("/ssh/connect-totp", { sessionId, totpCode }),
|
||||
),
|
||||
|
||||
continueWarpgate: (sessionId: string) =>
|
||||
call<ConnectResult>("complete Warpgate authentication", () =>
|
||||
api.post("/ssh/connect-warpgate", { sessionId }),
|
||||
),
|
||||
|
||||
disconnect: (sessionId: string) =>
|
||||
call("disconnect from Docker", () =>
|
||||
api.post("/ssh/disconnect", { sessionId }),
|
||||
),
|
||||
|
||||
keepalive: (sessionId: string) =>
|
||||
call("keep the Docker session alive", () =>
|
||||
api.post("/ssh/keepalive", { sessionId }),
|
||||
),
|
||||
|
||||
validate: (sessionId: string) =>
|
||||
call<DockerValidation>("validate Docker", () =>
|
||||
api.get(`/validate/${sessionId}`),
|
||||
),
|
||||
|
||||
listContainers: (sessionId: string, all = true) =>
|
||||
call<DockerContainer[]>("list containers", () =>
|
||||
api.get(`/containers/${sessionId}`, { params: { all } }),
|
||||
),
|
||||
|
||||
containerAction: (
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
action: ContainerAction,
|
||||
) =>
|
||||
call<{ success: boolean; message: string }>(
|
||||
`${action} the container`,
|
||||
() => api.post(`/containers/${sessionId}/${containerId}/${action}`),
|
||||
),
|
||||
|
||||
removeContainer: (sessionId: string, containerId: string, force = false) =>
|
||||
call<{ success: boolean; message: string }>("remove the container", () =>
|
||||
api.delete(`/containers/${sessionId}/${containerId}/remove`, {
|
||||
params: { force },
|
||||
}),
|
||||
),
|
||||
|
||||
logs: (
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
) =>
|
||||
call<{ logs: string }>("load container logs", () =>
|
||||
api.get(`/containers/${sessionId}/${containerId}/logs`, {
|
||||
params: options,
|
||||
}),
|
||||
),
|
||||
|
||||
stats: (sessionId: string, containerId: string) =>
|
||||
call<DockerStats>("load container stats", () =>
|
||||
api.get(`/containers/${sessionId}/${containerId}/stats`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<{ logs: string }> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: options,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container logs");
|
||||
}
|
||||
}
|
||||
export type DockerApi = ReturnType<typeof createDockerApi>;
|
||||
|
||||
export async function downloadContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: { ...options, download: true },
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "download container logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerStats(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerStats> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/stats`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container stats");
|
||||
}
|
||||
/** The Docker API on this plugin's own client. */
|
||||
export function useDockerApi(): DockerApi {
|
||||
const api = usePluginApi();
|
||||
return useMemo(() => createDockerApi(api), [api]);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/** The homepage's grid step, in pixels. */
|
||||
export const GRID_SIZE = 30;
|
||||
|
||||
export interface DockerWidgetConfig {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
/** What the homepage hands a widget; only the fields this plugin reads. */
|
||||
export interface WidgetComponentProps<C> {
|
||||
widget: { title?: string | null };
|
||||
config: C;
|
||||
}
|
||||
|
||||
export interface WidgetEditFormProps<C> {
|
||||
config: C;
|
||||
onChange: (config: C) => void;
|
||||
}
|
||||
|
||||
export type WidgetDefinition<C> = Omit<
|
||||
HomepageWidgetContribution<C>,
|
||||
"defaultConfig"
|
||||
> & { defaultConfig: C };
|
||||
@@ -2,21 +2,20 @@ import type { ComponentType } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import type {
|
||||
HomepageWidgetContribution,
|
||||
HostEditorSectionProps,
|
||||
StandaloneViewProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { DockerManager } from "./DockerManager";
|
||||
import DockerApp from "./DockerApp";
|
||||
import { HostDockerTab } from "./HostDockerTab";
|
||||
import { dockerWidget } from "./DockerWidget";
|
||||
import { dockerEnabled, toDockerHost } from "./types";
|
||||
|
||||
function DockerTab({ sshHost, label, isVisible }: TabProps) {
|
||||
function DockerTab({ host, sshHost, label, isVisible }: TabProps) {
|
||||
const record = (host ?? sshHost) as Record<string, unknown> | undefined;
|
||||
return (
|
||||
<DockerManager
|
||||
hostConfig={sshHost as unknown as SSHHost}
|
||||
host={record ? toDockerHost(record) : undefined}
|
||||
title={label}
|
||||
isVisible={isVisible}
|
||||
isTopbarOpen={false}
|
||||
@@ -29,16 +28,13 @@ function DockerStandalone({ hostId }: StandaloneViewProps) {
|
||||
return <DockerApp hostId={hostId} />;
|
||||
}
|
||||
|
||||
function DockerHostSection({ form, setField }: HostEditorSectionProps) {
|
||||
return (
|
||||
<HostDockerTab
|
||||
form={form}
|
||||
setField={setField as Parameters<typeof HostDockerTab>[0]["setField"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
// Host actions filter synchronously, so the permission is read once here.
|
||||
let canUse = true;
|
||||
void app.hasPermission("use").then((allowed) => {
|
||||
canUse = allowed;
|
||||
});
|
||||
|
||||
app.registerTab("docker", DockerTab, {
|
||||
icon: Box,
|
||||
titleKey: "nav.docker",
|
||||
@@ -58,16 +54,7 @@ export function activate(app: TermixApp): void {
|
||||
order: 30,
|
||||
tabType: "docker",
|
||||
copyUrlView: "docker",
|
||||
when: (host) => !!host.enableSsh && !!host.enableDocker,
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: "docker",
|
||||
group: "ssh",
|
||||
titleKey: "hosts.tabDocker",
|
||||
icon: Box,
|
||||
order: 30,
|
||||
component: DockerHostSection,
|
||||
when: (host) => canUse && !!host.enableSsh && dockerEnabled(host),
|
||||
});
|
||||
|
||||
app.registerHomepageWidget(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface DockerContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state:
|
||||
| "created"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "restarting"
|
||||
| "removing"
|
||||
| "exited"
|
||||
| "dead";
|
||||
ports: string;
|
||||
created: string;
|
||||
command?: string;
|
||||
labels?: Record<string, string>;
|
||||
networks?: string[];
|
||||
mounts?: string[];
|
||||
}
|
||||
|
||||
export interface DockerStats {
|
||||
cpu: string;
|
||||
memoryUsed: string;
|
||||
memoryLimit: string;
|
||||
memoryPercent: string;
|
||||
netInput: string;
|
||||
netOutput: string;
|
||||
blockRead: string;
|
||||
blockWrite: string;
|
||||
pids?: string;
|
||||
}
|
||||
|
||||
export interface DockerLogOptions {
|
||||
tail?: number;
|
||||
timestamps?: boolean;
|
||||
since?: string;
|
||||
until?: string;
|
||||
follow?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerValidation {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
runtime?: "docker" | "podman";
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** What the Docker views read from a host. */
|
||||
export interface DockerHost {
|
||||
id: number;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
terminalConfig?: Record<string, unknown> | null;
|
||||
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
|
||||
}
|
||||
|
||||
/** Any host record the shell or an API hands over, as a DockerHost. */
|
||||
export function toDockerHost(record: Record<string, unknown>): DockerHost {
|
||||
return {
|
||||
...(record as unknown as DockerHost),
|
||||
id: Number(record.id),
|
||||
};
|
||||
}
|
||||
|
||||
function dockerSettings(
|
||||
host: object | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
const all = (host as { pluginSettings?: unknown } | null | undefined)
|
||||
?.pluginSettings as
|
||||
Record<string, Record<string, unknown> | undefined> | undefined;
|
||||
return all?.docker ?? {};
|
||||
}
|
||||
|
||||
export function dockerEnabled(host: object | null | undefined): boolean {
|
||||
return dockerSettings(host).enableDocker === true;
|
||||
}
|
||||
|
||||
export function hostTitle(host: DockerHost): string {
|
||||
return host.name || `${host.username ?? ""}@${host.ip}`;
|
||||
}
|
||||
Reference in new issue
Block a user