From 9a21fcac61b989684875841f3580810d41e18474 Mon Sep 17 00:00:00 2001 From: jamaljsr <1356600+jamaljsr@users.noreply.github.com> Date: Sat, 15 Oct 2022 18:01:59 -0400 Subject: [PATCH 1/2] fix(deps): change antd Modal visible props to open --- src/components/common/AdvancedOptionsModal.tsx | 2 +- src/components/common/ImageUpdatesModal.tsx | 2 +- src/components/common/StatusBadge.tsx | 10 +++++++++- .../designer/bitcoind/actions/SendOnChainModal.tsx | 2 +- .../designer/lightning/actions/ChangeBackendModal.tsx | 2 +- .../designer/lightning/actions/CreateInvoiceModal.tsx | 2 +- .../designer/lightning/actions/OpenChannelModal.tsx | 2 +- .../designer/lightning/actions/PayInvoiceModal.tsx | 2 +- src/components/home/DetectDockerModal.tsx | 2 +- src/components/nodeImages/CustomImageModal.tsx | 2 +- src/components/nodeImages/ManagedImageModal.tsx | 2 +- 11 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/common/AdvancedOptionsModal.tsx b/src/components/common/AdvancedOptionsModal.tsx index 4eb21e734..4873da591 100644 --- a/src/components/common/AdvancedOptionsModal.tsx +++ b/src/components/common/AdvancedOptionsModal.tsx @@ -43,7 +43,7 @@ const AdvancedOptionsModal: React.FC = ({ network }) => { return ( hideAdvancedOptions()} destroyOnClose cancelText={l('cancelBtn')} diff --git a/src/components/common/ImageUpdatesModal.tsx b/src/components/common/ImageUpdatesModal.tsx index 91f86f100..60908a700 100644 --- a/src/components/common/ImageUpdatesModal.tsx +++ b/src/components/common/ImageUpdatesModal.tsx @@ -107,7 +107,7 @@ const ImageUpdatesModal: React.FC = ({ onClose }) => { title={l('title')} onCancel={onClose} destroyOnClose - visible + open width={600} centered cancelText={l('closeBtn')} diff --git a/src/components/common/StatusBadge.tsx b/src/components/common/StatusBadge.tsx index 6b783340a..b70c60355 100644 --- a/src/components/common/StatusBadge.tsx +++ b/src/components/common/StatusBadge.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import styled from '@emotion/styled'; import { Badge, Tooltip } from 'antd'; import { Status } from 'shared/types'; @@ -20,6 +21,13 @@ const badgeStatuses: BadgeStatus = { [Status.Error]: 'error', }; +const Styled = { + Text: styled.span` + display: inline-block; + margin-left: 8px; + `, +}; + const StatusBadge: React.SFC = ({ status, text }) => { const { t } = useTranslation(); return ( @@ -27,7 +35,7 @@ const StatusBadge: React.SFC = ({ status, text }) => { - {text} + {text} ); }; diff --git a/src/components/designer/bitcoind/actions/SendOnChainModal.tsx b/src/components/designer/bitcoind/actions/SendOnChainModal.tsx index 76eb3a833..06cdf6c66 100644 --- a/src/components/designer/bitcoind/actions/SendOnChainModal.tsx +++ b/src/components/designer/bitcoind/actions/SendOnChainModal.tsx @@ -57,7 +57,7 @@ const SendOnChainModal: React.FC = ({ network }) => { <> hideSendOnChain()} destroyOnClose cancelText={l('cancelBtn')} diff --git a/src/components/designer/lightning/actions/ChangeBackendModal.tsx b/src/components/designer/lightning/actions/ChangeBackendModal.tsx index d48580163..ee260a0f3 100644 --- a/src/components/designer/lightning/actions/ChangeBackendModal.tsx +++ b/src/components/designer/lightning/actions/ChangeBackendModal.tsx @@ -85,7 +85,7 @@ const ChangeBackendModal: React.FC = ({ network }) => { <> hideChangeBackend()} destroyOnClose cancelText={l('cancelBtn')} diff --git a/src/components/designer/lightning/actions/CreateInvoiceModal.tsx b/src/components/designer/lightning/actions/CreateInvoiceModal.tsx index a72bca2d8..61ef54bc7 100644 --- a/src/components/designer/lightning/actions/CreateInvoiceModal.tsx +++ b/src/components/designer/lightning/actions/CreateInvoiceModal.tsx @@ -111,7 +111,7 @@ const CreateInvoiceModal: React.FC = ({ network }) => { <> hideCreateInvoice()} destroyOnClose footer={invoice ? null : undefined} diff --git a/src/components/designer/lightning/actions/OpenChannelModal.tsx b/src/components/designer/lightning/actions/OpenChannelModal.tsx index 51472f8c2..5bcadfdf6 100644 --- a/src/components/designer/lightning/actions/OpenChannelModal.tsx +++ b/src/components/designer/lightning/actions/OpenChannelModal.tsx @@ -159,7 +159,7 @@ const OpenChannelModal: React.FC = ({ network }) => { <> hideOpenChannel()} destroyOnClose cancelText={l('cancelBtn')} diff --git a/src/components/designer/lightning/actions/PayInvoiceModal.tsx b/src/components/designer/lightning/actions/PayInvoiceModal.tsx index 77a2a83a4..3ae65a815 100644 --- a/src/components/designer/lightning/actions/PayInvoiceModal.tsx +++ b/src/components/designer/lightning/actions/PayInvoiceModal.tsx @@ -44,7 +44,7 @@ const PayInvoiceModal: React.FC = ({ network }) => { return ( hidePayInvoice()} destroyOnClose cancelText={l('cancelBtn')} diff --git a/src/components/home/DetectDockerModal.tsx b/src/components/home/DetectDockerModal.tsx index 085aa16b7..c9f252a10 100644 --- a/src/components/home/DetectDockerModal.tsx +++ b/src/components/home/DetectDockerModal.tsx @@ -66,7 +66,7 @@ const DetectDockerModal: React.FC = () => { return ( = ({ image, onClose }) => { return ( = ({ image, onClose }) => { return ( Date: Sat, 15 Oct 2022 18:35:23 -0400 Subject: [PATCH 2/2] fix(docker): add ability to specify custom paths --- .../home/DetectDockerModal.spec.tsx | 47 +++++++++++++ src/components/home/DetectDockerModal.tsx | 67 +++++++++++++++++-- src/i18n/locales/en-US.json | 3 + src/lib/docker/dockerService.spec.ts | 30 +++++++++ src/lib/docker/dockerService.ts | 66 +++++++++++++----- src/lib/settings/settingsService.spec.ts | 4 ++ src/store/models/app.spec.ts | 8 +++ src/store/models/app.ts | 12 ++++ src/types/index.ts | 7 ++ src/utils/tests/renderWithProviders.tsx | 1 + 10 files changed, 223 insertions(+), 22 deletions(-) diff --git a/src/components/home/DetectDockerModal.spec.tsx b/src/components/home/DetectDockerModal.spec.tsx index e27aba588..434e41874 100644 --- a/src/components/home/DetectDockerModal.spec.tsx +++ b/src/components/home/DetectDockerModal.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { shell } from 'electron'; import { fireEvent } from '@testing-library/dom'; +import { waitFor } from '@testing-library/react'; import os from 'os'; import { injections, renderWithProviders } from 'utils/tests'; import DetectDockerModal, { dockerLinks } from './DetectDockerModal'; @@ -127,4 +128,50 @@ describe('DetectDockerModal component', () => { expect(await findByText('Docker Error')).toBeInTheDocument(); expect(await findByText('test-error')).toBeInTheDocument(); }); + + it('should display the correct placeholders', () => { + mockOS.platform.mockReturnValue('darwin'); + const { getByText, getByLabelText } = renderComponent(); + fireEvent.click(getByText('Specify custom paths for Docker and Compose files')); + expect(getByLabelText('Path to Docker Unix Socket')).toHaveAttribute( + 'placeholder', + '/var/run/docker.sock', + ); + expect(getByLabelText('Path to docker-compose executable')).toHaveAttribute( + 'placeholder', + '/usr/local/bin/docker-compose', + ); + }); + + it('should display the correct placeholders on windows', () => { + mockOS.platform.mockReturnValue('win32'); + const { getByText, getByLabelText } = renderComponent(); + fireEvent.click(getByText('Specify custom paths for Docker and Compose files')); + expect(getByLabelText('Path to Docker Unix Socket')).toHaveAttribute( + 'placeholder', + '//./pipe/docker_engine', + ); + expect(getByLabelText('Path to docker-compose executable')).toHaveAttribute( + 'placeholder', + 'C:\\Program Files\\Docker Toolbox\\docker-compose', + ); + }); + + it('should accept custom docker paths', () => { + mockDockerService.getVersions.mockResolvedValue({ docker: '', compose: '' }); + const { getByText, getByLabelText } = renderComponent(); + fireEvent.click(getByText('Specify custom paths for Docker and Compose files')); + expect(getByText('Path to Docker Unix Socket')).toBeInTheDocument(); + expect(getByText('Path to docker-compose executable')).toBeInTheDocument(); + fireEvent.change(getByLabelText('Path to Docker Unix Socket'), { + target: { value: '/test/docker.sock' }, + }); + fireEvent.change(getByLabelText('Path to docker-compose executable'), { + target: { value: '/test/docker-compose' }, + }); + fireEvent.click(getByText('Check Again')); + waitFor(() => { + expect(mockDockerService.setPaths).toBeCalledWith('a', 'b'); + }); + }); }); diff --git a/src/components/home/DetectDockerModal.tsx b/src/components/home/DetectDockerModal.tsx index c9f252a10..0b22b751b 100644 --- a/src/components/home/DetectDockerModal.tsx +++ b/src/components/home/DetectDockerModal.tsx @@ -1,8 +1,13 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useCallback, useState } from 'react'; import { useAsyncCallback } from 'react-async-hook'; -import { AppleOutlined, DownloadOutlined, WindowsOutlined } from '@ant-design/icons'; +import { + AppleOutlined, + DownloadOutlined, + SettingOutlined, + WindowsOutlined, +} from '@ant-design/icons'; import styled from '@emotion/styled'; -import { Button, Modal, Result } from 'antd'; +import { Button, Form, Input, Modal, Result } from 'antd'; import { usePrefixedTranslation } from 'hooks'; import { useStoreActions, useStoreState } from 'store'; import { getPolarPlatform, PolarPlatform } from 'utils/system'; @@ -19,6 +24,11 @@ const Styled = { width: 70%; margin: auto; `, + CustomizeSection: styled.div<{ collapsed?: boolean }>` + overflow: hidden; + max-height: ${props => (props.collapsed ? '0' : '300px')}; + transition: max-height 0.5s; + `, }; export const dockerLinks: Record> = { @@ -45,12 +55,25 @@ const buttonIcons: Record = { const DetectDockerModal: React.FC = () => { const platform = getPolarPlatform(); const { l } = usePrefixedTranslation('cmps.home.DetectDockerModal'); + const [form] = Form.useForm(); + const [collapsed, setCollapsed] = useState(true); const { dockerVersions: { docker, compose }, + settings: { customDockerPaths }, } = useStoreState(s => s.app); - const { openInBrowser, getDockerVersions, notify } = useStoreActions(s => s.app); + const { openInBrowser, getDockerVersions, notify, updateSettings } = useStoreActions( + s => s.app, + ); + const toggleCustomize = useCallback(() => setCollapsed(v => !v), []); const checkAsync = useAsyncCallback(async () => { try { + const { dockerSocketPath, composeFilePath } = form.getFieldsValue(); + await updateSettings({ + customDockerPaths: { + dockerSocketPath, + composeFilePath, + }, + }); await getDockerVersions({ throwErr: true }); } catch (error: any) { notify({ message: l('dockerError'), error }); @@ -99,6 +122,42 @@ const DetectDockerModal: React.FC = () => { /> + + +
+ + + + + + +
+
+ +
); diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index bcea9836f..3e90ed7c4 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -262,6 +262,9 @@ "cmps.home.DetectDockerModal.description": "To start a Lightning Network, you must have Docker and Docker Compose installed and running on this computer.", "cmps.home.DetectDockerModal.download": "Download", "cmps.home.DetectDockerModal.versionsTitle": "Installed Docker Versions", + "cmps.home.DetectDockerModal.customize": "Specify custom paths for Docker and Compose files", + "cmps.home.DetectDockerModal.dockerSocketPath": "Path to Docker Unix Socket", + "cmps.home.DetectDockerModal.composeFilePath": "Path to docker-compose executable", "cmps.home.DetectDockerModal.checkAgain": "Check Again", "cmps.home.GetStarted.title": "Let's get started!", "cmps.home.GetStarted.createBtn": "Create a Lightning Network", diff --git a/src/lib/docker/dockerService.spec.ts b/src/lib/docker/dockerService.spec.ts index c7fd1df87..d65414922 100644 --- a/src/lib/docker/dockerService.spec.ts +++ b/src/lib/docker/dockerService.spec.ts @@ -66,6 +66,36 @@ describe('DockerService', () => { ); }); + it('should use the provided custom docker paths', async () => { + dockerService.setPaths('/test/docker.sock', '/test/docker-compose'); + await dockerService.getVersions(); + expect(composeMock.version).toBeCalledWith( + expect.objectContaining({ + executablePath: '/test/docker-compose', + }), + undefined, + ); + expect(mockDockerode.prototype.constructor).toBeCalledWith({ + socketPath: '/test/docker.sock', + }); + }); + + it('should use default custom docker paths', async () => { + dockerService.setPaths('', ''); + await dockerService.getVersions(); + expect(composeMock.version).toBeCalledWith( + expect.not.objectContaining({ + executablePath: '', + }), + undefined, + ); + expect(mockDockerode.prototype.constructor).toBeCalledWith( + expect.not.objectContaining({ + socketPath: '/test/docker.sock', + }), + ); + }); + describe('detecting versions', () => { const dockerVersion = mockDockerode.prototype.version; const composeVersion = composeMock.version; diff --git a/src/lib/docker/dockerService.ts b/src/lib/docker/dockerService.ts index e97f47a5c..c7cdae67c 100644 --- a/src/lib/docker/dockerService.ts +++ b/src/lib/docker/dockerService.ts @@ -3,7 +3,7 @@ import { debug, info } from 'electron-log'; import { copy, ensureDir } from 'fs-extra'; import { join } from 'path'; import * as compose from 'docker-compose'; -import Dockerode from 'dockerode'; +import Dockerode, { DockerOptions } from 'dockerode'; import yaml from 'js-yaml'; import os from 'os'; import { @@ -24,6 +24,27 @@ import { isLinux } from 'utils/system'; import ComposeFile from './composeFile'; class DockerService implements DockerLibrary { + /** The path to the Docker socket file */ + private dockerSocketPath?: string; + /** The path to the `docker-compose` CLI executable */ + private composeFilePath?: string; + + /** A `Dockerode` instance */ + get docker() { + const opts: DockerOptions = {}; + if (this.dockerSocketPath) opts.socketPath = this.dockerSocketPath; + return new Dockerode(opts); + } + + /** + * Store the custom docker paths so that these values won't need to be passed + * for every function that is called + */ + setPaths(dockerSocketPath: string, composeFilePath: string) { + this.dockerSocketPath = dockerSocketPath || undefined; + this.composeFilePath = composeFilePath || undefined; + } + /** * Gets the versions of docker and docker-compose installed * @param throwOnError set to true to throw an Error if detection fails @@ -33,7 +54,7 @@ class DockerService implements DockerLibrary { try { debug('fetching docker version'); - const dockerVersion = await new Dockerode().version(); + const dockerVersion = await this.docker.version(); debug(`Result: ${JSON.stringify(dockerVersion)}`); versions.docker = dockerVersion.Version; } catch (error: any) { @@ -43,7 +64,7 @@ class DockerService implements DockerLibrary { try { debug('getting docker-compose version'); - const composeVersion = await this.execute(compose.version, this.getArgs()); + const composeVersion = await this.execute(compose.version, this.getOpts()); debug(`Result: ${JSON.stringify(composeVersion)}`); versions.compose = composeVersion.out.trim(); } catch (error: any) { @@ -60,7 +81,7 @@ class DockerService implements DockerLibrary { async getImages(): Promise { try { debug('fetching docker images'); - const allImages = await new Dockerode().listImages(); + const allImages = await this.docker.listImages(); debug(`All Images: ${JSON.stringify(allImages)}`); const imageNames = ([] as string[]) .concat(...allImages.map(i => i.RepoTags || [])) @@ -119,7 +140,7 @@ class DockerService implements DockerLibrary { info(`Starting docker containers for ${network.name}`); info(` - path: ${network.path}`); - const result = await this.execute(compose.upAll, this.getArgs(network)); + const result = await this.execute(compose.upAll, this.getOpts(network)); info(`Network started:\n ${result.out || result.err}`); } @@ -130,7 +151,7 @@ class DockerService implements DockerLibrary { async stop(network: Network) { info(`Stopping docker containers for ${network.name}`); info(` - path: ${network.path}`); - const result = await this.execute(compose.down, this.getArgs(network)); + const result = await this.execute(compose.down, this.getOpts(network)); info(`Network stopped:\n ${result.out || result.err}`); } @@ -147,7 +168,7 @@ class DockerService implements DockerLibrary { info(`Starting docker container for ${node.name}`); info(` - path: ${network.path}`); - const result = await this.execute(compose.upOne, node.name, this.getArgs(network)); + const result = await this.execute(compose.upOne, node.name, this.getOpts(network)); info(`Container started:\n ${result.out || result.err}`); } @@ -159,7 +180,7 @@ class DockerService implements DockerLibrary { async stopNode(network: Network, node: CommonNode) { info(`Stopping docker container for ${node.name}`); info(` - path: ${network.path}`); - const result = await this.execute(compose.stopOne, node.name, this.getArgs(network)); + const result = await this.execute(compose.stopOne, node.name, this.getOpts(network)); info(`Container stopped:\n ${result.out || result.err}`); } @@ -171,19 +192,19 @@ class DockerService implements DockerLibrary { async removeNode(network: Network, node: CommonNode) { info(`Stopping docker container for ${node.name}`); info(` - path: ${network.path}`); - let result = await this.execute(compose.stopOne, node.name, this.getArgs(network)); + let result = await this.execute(compose.stopOne, node.name, this.getOpts(network)); info(`Container stopped:\n ${result.out || result.err}`); info(`Removing stopped docker containers`); // the `any` cast is used because `rm` is the only method on compose that takes the // IDockerComposeOptions as the first param and a spread for the remaining - result = await this.execute(compose.rm as any, this.getArgs(network), node.name); + result = await this.execute(compose.rm as any, this.getOpts(network), node.name); info(`Removed:\n ${result.out || result.err}`); } /** * Saves the given networks to disk - * @param networks the list of networks to save + * @param data the list of networks to save */ async saveNetworks(data: NetworksFile) { const json = JSON.stringify(data, null, 2); @@ -236,7 +257,8 @@ class DockerService implements DockerLibrary { /** * Helper method to trap and format exceptions thrown and * @param cmd the compose function to call - * @param args the arguments to the compose function + * @param arg1 the first argument to the compose function + * @param arg2 the second argument to the compose function */ private async execute( cmd: (arg1: A, arg2?: B) => Promise, @@ -255,8 +277,11 @@ class DockerService implements DockerLibrary { } } - private getArgs(network?: Network) { - const args = { + /** + * Returns options for all docker compose calls + */ + private getOpts(network?: Network) { + const opts: compose.IDockerComposeOptions = { cwd: network ? network.path : __dirname, env: { ...process.env, @@ -264,20 +289,25 @@ class DockerService implements DockerLibrary { }, }; + if (this.composeFilePath) { + opts.executablePath = this.composeFilePath; + } + if (isLinux()) { const { uid, gid } = os.userInfo(); debug(`env: uid=${uid} gid=${gid}`); - args.env = { - ...args.env, + opts.env = { + ...opts.env, // add user/group id's to env so that file permissions on the // docker volumes are set correctly. containers cannot write // to disk on linux if permissions aren't set correctly USERID: `${uid}`, GROUPID: `${gid}`, - }; + } as NodeJS.ProcessEnv; } - return args; + debug('docker-compose options', opts); + return opts; } private async ensureDirs(network: Network, nodes: CommonNode[]) { diff --git a/src/lib/settings/settingsService.spec.ts b/src/lib/settings/settingsService.spec.ts index feee81464..bf2ea5619 100644 --- a/src/lib/settings/settingsService.spec.ts +++ b/src/lib/settings/settingsService.spec.ts @@ -20,6 +20,10 @@ describe('SettingsService', () => { checkForUpdatesOnStartup: false, theme: 'dark', nodeImages: { custom: [], managed: [] }, + customDockerPaths: { + dockerSocketPath: '', + composeFilePath: '', + }, }; }); diff --git a/src/store/models/app.spec.ts b/src/store/models/app.spec.ts index 8e21a22ed..7d480c488 100644 --- a/src/store/models/app.spec.ts +++ b/src/store/models/app.spec.ts @@ -42,6 +42,10 @@ describe('App model', () => { checkForUpdatesOnStartup: false, theme: 'dark', nodeImages: { custom: [], managed: [] }, + customDockerPaths: { + dockerSocketPath: '', + composeFilePath: '', + }, }); mockRepoService.load.mockResolvedValue({ ...defaultRepoState, @@ -83,6 +87,10 @@ describe('App model', () => { checkForUpdatesOnStartup: true, theme: 'dark', nodeImages: { custom: [], managed: [] }, + customDockerPaths: { + dockerSocketPath: '', + composeFilePath: '', + }, }); }); diff --git a/src/store/models/app.ts b/src/store/models/app.ts index f8161221e..d879cdd31 100644 --- a/src/store/models/app.ts +++ b/src/store/models/app.ts @@ -79,6 +79,10 @@ const appModel: AppModel = { managed: [], custom: [], }, + customDockerPaths: { + dockerSocketPath: '', + composeFilePath: '', + }, }, dockerVersions: { docker: '', compose: '' }, dockerImages: [], @@ -129,6 +133,10 @@ const appModel: AppModel = { actions.setSettings(settings); await getI18n().changeLanguage(settings.lang); changeTheme(settings.theme || 'dark'); + if (settings.customDockerPaths) { + const { dockerSocketPath, composeFilePath } = settings.customDockerPaths; + injections.dockerService.setPaths(dockerSocketPath, composeFilePath); + } } }), updateSettings: thunk(async (actions, updates, { injections, getState }) => { @@ -137,6 +145,10 @@ const appModel: AppModel = { await injections.settingsService.save(settings); if (updates.lang) await getI18n().changeLanguage(settings.lang); if (updates.theme) changeTheme(updates.theme); + if (updates.customDockerPaths) { + const { dockerSocketPath, composeFilePath } = updates.customDockerPaths; + injections.dockerService.setPaths(dockerSocketPath, composeFilePath); + } }), updateManagedImage: thunk(async (actions, node, { getState }) => { const { nodeImages } = getState().settings; diff --git a/src/types/index.ts b/src/types/index.ts index 99d66b69d..8cad7921c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -55,6 +55,12 @@ export interface AppSettings { managed: ManagedImage[]; custom: CustomImage[]; }; + customDockerPaths: { + /** The path to the Docker socket file */ + dockerSocketPath: string; + /** The path to the `docker-compose` CLI executable */ + composeFilePath: string; + }; } export interface SettingsInjection { @@ -102,6 +108,7 @@ export interface DockerRepoUpdates { } export interface DockerLibrary { + setPaths: (dockerSocketPath: string, composeFilePath: string) => void; getVersions: (throwOnError?: boolean) => Promise; getImages: () => Promise; saveComposeFile: (network: Network) => Promise; diff --git a/src/utils/tests/renderWithProviders.tsx b/src/utils/tests/renderWithProviders.tsx index c4da2ebe3..3721733b2 100644 --- a/src/utils/tests/renderWithProviders.tsx +++ b/src/utils/tests/renderWithProviders.tsx @@ -29,6 +29,7 @@ export const injections: StoreInjections = { save: jest.fn(), }, dockerService: { + setPaths: jest.fn(), getVersions: jest.fn(), getImages: jest.fn(), saveComposeFile: jest.fn(),