feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
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";
@@ -15,8 +15,9 @@ import {
Search,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
import { logActivity, getSSHHosts } from "@/main-axios";
import {
connectDockerSession,
disconnectDockerSession,
@@ -25,9 +26,7 @@ import {
keepaliveDockerSession,
verifyDockerTOTP,
verifyDockerWarpgate,
logActivity,
getSSHHosts,
} from "@/main-axios.ts";
} from "./docker-api";
import { ContainerList } from "./components/ContainerList.tsx";
import { ContainerTable } from "./components/ContainerTable.tsx";
import { ContainerDetail } from "./components/ContainerDetail.tsx";
@@ -0,0 +1,97 @@
import { useState, useEffect } from "react";
import { Box } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
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";
function DockerWidget({
widget,
config,
}: WidgetComponentProps<DockerWidgetConfig>) {
const { t } = useTranslation();
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
const [loading, setLoading] = useState(true);
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) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<Box size={20} />
<span className="text-xs">{t("homepage.widgetNoHostSelected")}</span>
</div>
);
}
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>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
<div
className="flex-1 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<DockerManager
hostConfig={host as unknown as SSHHost}
title={host.name || host.ip}
isVisible={true}
isTopbarOpen={false}
embedded={true}
/>
</div>
</div>
);
}
/** Registered by the plugin while it runs. */
export const dockerWidget: WidgetTypeDefinition<DockerWidgetConfig> = {
id: "docker_widget",
name: "Docker Manager",
description: "Embedded Docker container manager for a configured host",
category: "system",
icon: <Box size={14} />,
defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
component: DockerWidget,
editFormComponent: DockerWidgetEditForm,
};
@@ -0,0 +1,18 @@
import type {
DockerWidgetConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { SingleHostEditForm } from "@/features/homepage/dialogs/SingleHostEditForm";
export function DockerWidgetEditForm({
config,
onChange,
}: WidgetEditFormProps<DockerWidgetConfig>) {
return (
<SingleHostEditForm
hostId={config.hostId}
onChange={(hostId) => onChange({ ...config, hostId })}
filter={(h) => !!(h.enableSsh && h.enableDocker)}
/>
);
}
@@ -0,0 +1,68 @@
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>
);
}
@@ -15,7 +15,7 @@ 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 } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
@@ -13,7 +13,7 @@ import {
RefreshCw,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer } from "@/types";
import {
startDockerContainer,
@@ -22,7 +22,7 @@ import {
pauseDockerContainer,
unpauseDockerContainer,
removeDockerContainer,
} from "@/main-axios.ts";
} from "../docker-api";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
interface ContainerCardProps {
@@ -10,7 +10,7 @@ import {
Settings,
Terminal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer, SSHHost } from "@/types";
import { LogViewer } from "./LogViewer.tsx";
import { ContainerStats } from "./ContainerStats.tsx";
@@ -1,6 +1,6 @@
import React from "react";
import { Box } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer } from "@/types";
import { ContainerCard } from "./ContainerCard.tsx";
@@ -10,8 +10,8 @@ import {
RefreshCw,
} from "lucide-react";
import type { DockerStats } from "@/types";
import { getContainerStats } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { getContainerStats } 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";
@@ -2,10 +2,10 @@ import { getErrorMessage } from "@/lib/error-message.js";
import React from "react";
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@/components/button.tsx";
import type { DockerContainer } from "@/types";
import { startDockerContainer, stopDockerContainer } from "@/main-axios.ts";
import { startDockerContainer, stopDockerContainer } from "../docker-api";
import { DockerBadge } from "./ContainerCard.tsx";
type DetailTab = "logs" | "stats" | "console";
@@ -5,9 +5,9 @@ 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 "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerLogOptions } from "@/types";
import { getContainerLogs, downloadContainerLogs } from "@/main-axios.ts";
import { getContainerLogs, downloadContainerLogs } from "../docker-api";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
import { Select2 } from "@/components/select2";
+345
View File
@@ -0,0 +1,345 @@
import axios from "axios";
import { dockerApi, handleApiError } from "@/main-axios";
import type {
DockerContainer,
DockerLogOptions,
DockerStats,
DockerValidation,
} from "@/types/index";
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, unknown>;
};
type ConnectErrorResponse = {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
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[];
requires_warpgate?: boolean;
url?: string;
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;
}
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");
}
}
export async function verifyDockerTOTP(
sessionId: string,
totpCode: string,
): Promise<{ status: string; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect-totp", {
sessionId,
totpCode,
});
return response.data;
} catch (error) {
throw handleApiError(error, "verify Docker TOTP");
}
}
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 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 },
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove Docker container");
}
}
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 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");
}
}
-67
View File
@@ -1,67 +0,0 @@
/**
* Frontend half of the docker plugin.
*
* Registers the container-management tab through the existing extension
* seams (registerRailItem / registerTabComponent) the same way
* ssh-terminal's frontend does, and the host-editor Docker tab through the
* new registerHostEditorTab seam built for this plugin.
*
* HostDockerTab itself stays in src/ui/sidebar/HostEditorFeatureTabs.tsx
* rather than moving into this plugin: it takes the same {form, setField}
* pair every other host editor tab does, and those types (HostEditorForm,
* the setField signature) are core types HostEditor.tsx owns, the same
* reason ssh-terminal leaves session-manager.ts in core rather than copying
* it here.
*
* register() is called when the plugin is enabled, unregister() when it is
* disabled. As of this change neither is actually invoked anywhere yet --
* see plugins/ssh-terminal/frontend/index.mjs and
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
* "there is no plugin loader yet" for the frontend half. This file follows
* the same shape so it is ready the moment that loader exists.
*/
export const id = "docker";
export const tabId = "docker";
export const hostEditorTabId = "docker";
export async function register({
registerRailItem,
registerTabComponent,
registerHostEditorTab,
icons,
}) {
registerTabComponent(tabId, () =>
import("./DockerManager.tsx").then((m) => ({
default: m.DockerManager,
})),
);
registerRailItem({
id: tabId,
icon: icons.Box,
labelKey: "nav.docker",
kind: "tab",
});
if (registerHostEditorTab) {
const { HostDockerTab } =
await import("../../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
registerHostEditorTab({
id: hostEditorTabId,
labelKey: "hosts.tabDocker",
icon: icons.Box,
component: HostDockerTab,
});
}
}
export async function unregister({
unregisterRailItem,
unregisterTabComponent,
unregisterHostEditorTab,
}) {
unregisterRailItem(tabId);
unregisterTabComponent(tabId);
unregisterHostEditorTab?.(hostEditorTabId);
}
+83
View File
@@ -0,0 +1,83 @@
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";
function DockerTab({ sshHost, label, isVisible }: TabProps) {
return (
<DockerManager
hostConfig={sshHost as unknown as SSHHost}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>
);
}
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 {
app.registerTab("docker", DockerTab, {
icon: Box,
titleKey: "nav.docker",
requiresHost: true,
noHostMessageKey: "docker.noHostSelected",
persistent: true,
activityTypes: ["docker"],
standalone: DockerStandalone,
preload: () => import("./DockerManager"),
});
app.registerHostAction({
id: "docker",
titleKey: "nav.docker",
icon: Box,
kind: "open",
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,
});
app.registerHomepageWidget(
dockerWidget as unknown as HomepageWidgetContribution,
);
app.registerSlotContribution("onboarding.features", {
actionId: "docker.feature",
titleKey: "onboarding.feature_docker",
descriptionKey: "onboarding.feature_docker_desc",
icon: Box as ComponentType<{ className?: string }>,
});
}