Search code examples
reactjsdockerdocker-desktop

How to correctly display the stream output of a container in docker desktop extension


As shown here using docker extension API's you can stream the output of a container but when I try to store the data.stdout string for each line of log it simply keep changing like if it's a object reference....I even tried to copy the string using data.stdout.slice() or transforming it in JSON using JSON.stringify(data.stdout) in order to get a new object with different reference but doesn't work :/

...
const[logs,setLogs]=useState<string[]>([]);
...
ddClient.docker.cli.exec('logs', ['-f', data.Id], {
    stream: {
      onOutput(data): void {
        console.log(data.stdout);
        setLogs([...logs, data.stdout]);
      },
      onError(error: unknown): void {
        ddClient.desktopUI.toast.error('An error occurred');
        console.log(error);
      },
      onClose(exitCode) {
        console.log("onClose with exit code " + exitCode);
      },
      splitOutputLines: true,
    },
  });

Solution

  • Docker extension team member here. I am not a React expert, so I might be wrong in my explanation, but I think that the issue is linked to how React works.

    In your component, the call ddClient.docker.cli.exec('logs', ['-f'], ...) updates the value of the logs state every time some data are streamed from the backend. This update makes the component to re-render and execute everything once again. Thus, you will have many calls to ddClient.docker.cli.exec executed. And it will grow exponentially.

    The problem is that, since there are many ddClient.docker.cli.exec calls, there are as many calls to setLogs that update the logs state simultaneously. But spreading the logs value does not guarantee the value is the last set.

    You can try the following to move out the Docker Extensions SDK of the picture. It does exactly the same thing: it updates the content state inside the setTimeout callback every 400ms. Since the setTimeout is ran on every render and never cleared, there will be many updates of content simultaneously.

    let counter = 0;
    
    export function App() {
    
      const [content, setContent] = React.useState<string[]>([]);
    
      setTimeout(() => {
        counter++;
        setContent([...content, "\n " + counter]);
      }, 400);
    
      return (<div>{content}</div>);
    }
    

    You will see weird results :)

    Here is how to do what you want to do:

    const ddClient = createDockerDesktopClient();
    
    export function App() {
    
      const [logs, setLogs] = React.useState<string[]>([]);
    
      useEffect(() => {
        const listener = ddClient.docker.cli.exec('logs', ['-f', "xxxx"], {
          stream: {
            onOutput(data): void {
              console.log(data.stdout);
              setLogs((current) => [...current, data.stdout]);
            },
            onError(error: unknown): void {
              ddClient.desktopUI.toast.error('An error occurred');
              console.log(error);
            },
            onClose(exitCode) {
              console.log("onClose with exit code " + exitCode);
            },
            splitOutputLines: true,
          },
        });
    
        return () => {
          listener.close();
        }
      }, []);
    
      return (
        <>
          <h1>Logs</h1>
          <div>{logs}</div>
        </>
      );
    }
    

    What happens here is

    • the call to ddClient.docker.cli.exec is done in an useEffect
    • the effect returns a function that closes the connection so that when the component has unmounted the connection to the spawned command is closed
    • the effect takes an empty array as second parameter so that it is called only when the component is mounted (so not at every re-renders)
    • instead of reading logs a callback is provided to the setLogs function to make sure the latest value contained in data.stdout is added to the freshest version of the state

    To try it, make sure you update the array of parameters of the logs command.