diff --git a/src/types/ui-preferences.ts b/src/types/ui-preferences.ts index 1777775fe..7311111fe 100644 --- a/src/types/ui-preferences.ts +++ b/src/types/ui-preferences.ts @@ -43,6 +43,7 @@ export type UiEmptyStateVerbosity = "minimal" | "guided"; export type UiToolbarDensity = "icon" | "labeled" | "expanded"; export type UiFileViewMode = "grid" | "list"; export type UiDockerViewMode = "list" | "detail"; +export type UiDockerContainerLayout = "card" | "table"; export type UiHostEditorMode = "simple" | "full"; export interface UiChromePreferences { @@ -84,6 +85,7 @@ export interface UiFileManagerPreferences { export interface UiDockerPreferences { viewMode: UiDockerViewMode; + containerLayout: UiDockerContainerLayout; } export interface UiHostMetricsPreferences { @@ -228,7 +230,7 @@ export const PRESETS: Record, UiAreaPreferences> = { }, terminal: { toolbarDensity: "icon" }, fileManager: { viewMode: "grid", showHiddenFiles: false }, - docker: { viewMode: "list" }, + docker: { viewMode: "list", containerLayout: "card" }, hostMetrics: { enabledCards: SIMPLE_HOST_METRICS_CARDS, columns: 1 }, hostEditor: { mode: "simple" }, homepage: { enabledWidgets: null }, @@ -253,7 +255,7 @@ export const PRESETS: Record, UiAreaPreferences> = { terminal: { toolbarDensity: "labeled" }, // FileManager.tsx has always defaulted to grid when nothing is stored. fileManager: { viewMode: "grid", showHiddenFiles: false }, - docker: { viewMode: "list" }, + docker: { viewMode: "list", containerLayout: "card" }, // 3 is defaultLayoutFromWidgets's own default, i.e. today's behavior. hostMetrics: { enabledCards: BALANCED_HOST_METRICS_CARDS, columns: 3 }, hostEditor: { mode: "full" }, @@ -279,7 +281,7 @@ export const PRESETS: Record, UiAreaPreferences> = { terminal: { toolbarDensity: "expanded" }, // List packs more files and metadata per screen than the grid. fileManager: { viewMode: "list", showHiddenFiles: true }, - docker: { viewMode: "detail" }, + docker: { viewMode: "detail", containerLayout: "table" }, hostMetrics: { enabledCards: ADVANCED_HOST_METRICS_CARDS, columns: 4 }, hostEditor: { mode: "full" }, homepage: { enabledWidgets: null }, @@ -339,6 +341,7 @@ const AREA_SPECS: { }, docker: { viewMode: { kind: "enum", values: ["list", "detail"] }, + containerLayout: { kind: "enum", values: ["card", "table"] }, }, hostMetrics: { enabledCards: { kind: "stringArray" }, diff --git a/src/ui/features/docker/DockerManager.tsx b/src/ui/features/docker/DockerManager.tsx index 460bb5d58..328b98e65 100644 --- a/src/ui/features/docker/DockerManager.tsx +++ b/src/ui/features/docker/DockerManager.tsx @@ -9,6 +9,8 @@ import { AlertCircle, Box, ExternalLink, + Grid3X3, + List as ListIcon, RefreshCw, Search, } from "lucide-react"; @@ -27,12 +29,16 @@ import { getSSHHosts, } from "@/main-axios.ts"; 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 } from "@/contexts/UiPreferencesContext"; +import { + useAreaPreferences, + useUiPreferencesContext, +} from "@/contexts/UiPreferencesContext"; import { ConnectionLogProvider, useConnectionLog, @@ -71,6 +77,7 @@ function DockerManagerInner({ const { addLog, setLogs, clearLogs } = useConnectionLog(); const { currentTab, removeTab } = useTabsSafe(); const dockerPrefs = useAreaPreferences("docker"); + const uiPrefsCtx = useUiPreferencesContext(); const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); const [sessionId, setSessionId] = React.useState(null); const [containers, setContainers] = React.useState([]); @@ -78,6 +85,20 @@ function DockerManagerInner({ const [selectedContainer, setSelectedContainer] = React.useState< string | null >(null); + const [detailInitialTab, setDetailInitialTab] = React.useState< + "logs" | "stats" | "console" + >("logs"); + const [containerLayout, setContainerLayout] = React.useState< + "card" | "table" + >(dockerPrefs.containerLayout); + + const handleSetContainerLayout = React.useCallback( + (layout: "card" | "table") => { + setContainerLayout(layout); + uiPrefsCtx?.setOverride("docker", "containerLayout", layout); + }, + [uiPrefsCtx], + ); const [isConnecting, setIsConnecting] = React.useState(false); const [dockerValidation, setDockerValidation] = React.useState(null); @@ -674,6 +695,7 @@ function DockerManagerInner({ containers={containers} hostConfig={currentHostConfig} onBack={handleBack} + initialTab={detailInitialTab} /> ) : (
@@ -724,6 +746,29 @@ function DockerManagerInner({ +
+ + +
+
+ ) : containerLayout === "table" ? ( + { + setSelectedContainer(id); + setDetailInitialTab(tab ?? "logs"); + setViewMode("detail"); + }} + selectedContainerId={selectedContainer} + onRefresh={refreshContainers} + search={search} + statusFilter={statusFilter} + /> ) : ( { setSelectedContainer(id); + setDetailInitialTab("logs"); setViewMode("detail"); }} selectedContainerId={selectedContainer} diff --git a/src/ui/features/docker/components/ContainerDetail.tsx b/src/ui/features/docker/components/ContainerDetail.tsx index 30a8847b5..46ff43a64 100644 --- a/src/ui/features/docker/components/ContainerDetail.tsx +++ b/src/ui/features/docker/components/ContainerDetail.tsx @@ -17,25 +17,31 @@ import { ContainerStats } from "./ContainerStats.tsx"; import { ConsoleTerminal } from "./ConsoleTerminal.tsx"; import { DockerBadge } from "./ContainerCard.tsx"; +type DetailTab = "logs" | "stats" | "console"; + interface ContainerDetailProps { sessionId: string; containerId: string; containers: DockerContainer[]; hostConfig: SSHHost; onBack: () => void; + initialTab?: DetailTab; } -type DetailTab = "logs" | "stats" | "console"; - export function ContainerDetail({ sessionId, containerId, containers, hostConfig, onBack, + initialTab = "logs", }: ContainerDetailProps): React.ReactElement { const { t } = useTranslation(); - const [activeTab, setActiveTab] = React.useState("logs"); + const [activeTab, setActiveTab] = React.useState(initialTab); + + React.useEffect(() => { + setActiveTab(initialTab); + }, [containerId, initialTab]); const container = containers.find((c) => c.id === containerId); const containerName = container diff --git a/src/ui/features/docker/components/ContainerTable.tsx b/src/ui/features/docker/components/ContainerTable.tsx new file mode 100644 index 000000000..5d8e07179 --- /dev/null +++ b/src/ui/features/docker/components/ContainerTable.tsx @@ -0,0 +1,263 @@ +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 { Button } from "@/components/button.tsx"; +import type { DockerContainer } from "@/types"; +import { startDockerContainer, stopDockerContainer } from "@/main-axios.ts"; +import { DockerBadge } from "./ContainerCard.tsx"; + +type DetailTab = "logs" | "stats" | "console"; + +interface ContainerTableProps { + containers: DockerContainer[]; + sessionId: string; + onSelectContainer: (containerId: string, tab?: DetailTab) => void; + selectedContainerId?: string | null; + onRefresh?: () => void; + search?: string; + statusFilter?: string; +} + +export function ContainerTable({ + containers, + sessionId, + onSelectContainer, + selectedContainerId = null, + onRefresh, + search = "", + statusFilter = "all", +}: ContainerTableProps): React.ReactElement { + const { t } = useTranslation(); + const [pendingId, setPendingId] = React.useState(null); + + const filtered = React.useMemo(() => { + return containers.filter((c) => { + const name = c.name.startsWith("/") ? c.name.slice(1) : c.name; + const matchesSearch = + name.toLowerCase().includes(search.toLowerCase()) || + c.image.toLowerCase().includes(search.toLowerCase()) || + c.id.toLowerCase().includes(search.toLowerCase()); + return ( + matchesSearch && (statusFilter === "all" || c.state === statusFilter) + ); + }); + }, [containers, search, statusFilter]); + + const handleToggleRun = async ( + e: React.MouseEvent, + container: DockerContainer, + ) => { + e.stopPropagation(); + const containerName = container.name.startsWith("/") + ? container.name.slice(1) + : container.name; + setPendingId(container.id); + try { + if (container.state === "running") { + await stopDockerContainer(sessionId, container.id); + toast.success(t("docker.containerStopped", { name: containerName })); + } else { + await startDockerContainer(sessionId, container.id); + toast.success(t("docker.containerStarted", { name: containerName })); + } + onRefresh?.(); + } catch (err) { + toast.error( + container.state === "running" + ? t("docker.failedToStopContainer", { error: getErrorMessage(err) }) + : t("docker.failedToStartContainer", { + error: getErrorMessage(err), + }), + ); + } finally { + setPendingId(null); + } + }; + + if (containers.length === 0) { + return ( +
+ + + {t("docker.noContainersFound")} + + + {t("docker.noContainersFoundHint")} + +
+ ); + } + + if (filtered.length === 0) { + return ( +
+ + + {t("docker.noContainersMatchFilters")} + + + {t("docker.noContainersMatchFiltersHint")} + +
+ ); + } + + return ( +
+
+ + + + + + + + + + + + + {filtered.map((container) => { + const containerName = container.name.startsWith("/") + ? container.name.slice(1) + : container.name; + const portsList = (container.ports ?? "") + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + const isSelected = selectedContainerId === container.id; + const isPending = pendingId === container.id; + + return ( + onSelectContainer(container.id, "logs")} + className={`cursor-pointer transition-colors hover:bg-muted/40 ${ + isSelected ? "bg-accent-brand/5" : "" + }`} + > + + + + + + + + ); + })} + +
+ {t("docker.name")} + + {t("docker.image")} + + {t("docker.state")} + + {t("docker.ports")} + + {t("docker.status")} + + {t("docker.actions")} +
+
+ + + {containerName} + +
+
+ + {container.image} + + + + + {portsList.length > 0 ? ( +
+ {portsList.slice(0, 2).map((p) => ( + + {p} + + ))} + {portsList.length > 2 && ( + + +{portsList.length - 2} + + )} +
+ ) : ( + + {t("docker.noPorts")} + + )} +
+ {container.status} + +
e.stopPropagation()} + > + + + + +
+
+
+
+ ); +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 66ba836a6..f7ceeada6 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -3385,6 +3385,11 @@ "ports": "Ports", "noPorts": "No ports", "start": "Start", + "stop": "Stop", + "status": "Status", + "actions": "Actions", + "cardView": "Card view", + "listView": "List view", "confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.", "runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.", "loadingContainers": "Loading containers...",