This is an automated email from the ASF dual-hosted git repository.

mpochatkin pushed a commit to branch IGNITE-27201
in repository https://gitbox.apache.org/repos/asf/ignite-3.git

commit 9647403cee955823e1806c8e991d196ee1bff571
Author: Pochatkin Mikhail <[email protected]>
AuthorDate: Wed Jan 7 19:03:22 2026 +0300

    IGNITE-27201 Add CLI command to inspect deployment unit file tree
---
 .../unit/ItNodeUnitStructureCommandTest.java       | 170 ++++++++++++++++++
 .../unit/ItNodeUnitStructureReplCommandTest.java}  |  25 ++-
 .../cli/call/node/unit/NodeUnitStructureCall.java  |  50 ++++++
 .../cli/call/unit/UnitStructureCallInput.java      |  80 +++++++++
 .../cli/commands/node/unit/NodeUnitCommand.java    |   2 +-
 .../commands/node/unit/NodeUnitReplCommand.java    |   2 +-
 .../node/unit/NodeUnitStructureCommand.java        |  70 ++++++++
 .../node/unit/NodeUnitStructureReplCommand.java    |  74 ++++++++
 .../cli/decorators/UnitStructureDecorator.java     | 121 +++++++++++++
 .../call/node/unit/NodeUnitStructureCallTest.java  | 161 +++++++++++++++++
 .../cli/decorators/UnitStructureDecoratorTest.java | 193 +++++++++++++++++++++
 .../internal/rest/api/deployment/UnitEntry.java    |  44 ++++-
 12 files changed, 980 insertions(+), 12 deletions(-)

diff --git 
a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitStructureCommandTest.java
 
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitStructureCommandTest.java
new file mode 100644
index 00000000000..7231daa375a
--- /dev/null
+++ 
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitStructureCommandTest.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.commands.unit;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.ignite.internal.cli.CliIntegrationTest;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/** Integration test for node unit structure command. */
+public class ItNodeUnitStructureCommandTest extends CliIntegrationTest {
+    private static String testFile;
+
+    private static Path testDirectory;
+
+    @BeforeAll
+    static void beforeAll() throws IOException {
+        testDirectory = 
Files.createDirectory(WORK_DIR.resolve("test-structure"));
+        testFile = 
Files.createFile(testDirectory.resolve("test.txt")).toString();
+        Files.createFile(testDirectory.resolve("test2.txt"));
+        Files.createFile(testDirectory.resolve("test3.txt"));
+    }
+
+    @Test
+    @DisplayName("Should display unit structure with tree view")
+    void structureTreeView() {
+        // Given deployed unit
+        String id = "test.structure.unit.1";
+        execute("cluster", "unit", "deploy", id, "--version", "1.0.0", 
"--path", testDirectory.toString());
+
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", id);
+            assertExitCodeIsZero();
+        });
+
+        // When get structure
+        execute("node", "unit", "structure", id, "--version", "1.0.0");
+
+        // Then
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                () -> assertOutputContains(id),
+                () -> assertOutputContains("test.txt"),
+                () -> assertOutputContains("test2.txt"),
+                () -> assertOutputContains("test3.txt"),
+                () -> assertOutputContains(" B)") // File size display
+        );
+    }
+
+    @Test
+    @DisplayName("Should display unit structure with plain view")
+    void structurePlainView() {
+        // Given deployed unit
+        String id = "test.structure.unit.2";
+        execute("cluster", "unit", "deploy", id, "--version", "1.0.0", 
"--path", testDirectory.toString());
+
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", id);
+            assertExitCodeIsZero();
+        });
+
+        // When get structure with plain option
+        execute("node", "unit", "structure", id, "--version", "1.0.0", 
"--plain");
+
+        // Then
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                () -> assertOutputContains("test.txt"),
+                () -> assertOutputContains("test2.txt"),
+                () -> assertOutputContains("test3.txt")
+        );
+    }
+
+    @Test
+    @DisplayName("Should display error when version is missing")
+    void structureVersionIsMandatory() {
+        // When get structure without version
+        execute("node", "unit", "structure", "test.unit.id");
+
+        // Then
+        assertAll(
+                () -> assertExitCodeIs(2),
+                () -> assertErrOutputContains("Missing required option: 
'--version=<version>'"),
+                this::assertOutputIsEmpty
+        );
+    }
+
+    @Test
+    @DisplayName("Should display error when unit does not exist")
+    void structureUnitNotFound() {
+        // When get structure of non-existing unit
+        execute("node", "unit", "structure", "non.existing.unit", "--version", 
"1.0.0");
+
+        // Then
+        assertAll(
+                this::assertExitCodeIsError,
+                () -> assertErrOutputContains("not found")
+        );
+    }
+
+    @Test
+    @DisplayName("Should display structure from file deployment")
+    void structureFromFile() {
+        // Given deployed unit from file
+        String id = "test.structure.unit.3";
+        execute("cluster", "unit", "deploy", id, "--version", "1.0.0", 
"--path", testFile);
+
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", id);
+            assertExitCodeIsZero();
+        });
+
+        // When get structure
+        execute("node", "unit", "structure", id, "--version", "1.0.0");
+
+        // Then
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                () -> assertOutputContains(id),
+                () -> assertOutputContains("test.txt")
+        );
+    }
+
+    @Test
+    @DisplayName("Should display file sizes in human readable format")
+    void structureWithFileSizes() {
+        // Given deployed unit
+        String id = "test.structure.unit.4";
+        execute("cluster", "unit", "deploy", id, "--version", "1.0.0", 
"--path", testDirectory.toString());
+
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", id);
+            assertExitCodeIsZero();
+        });
+
+        // When get structure
+        execute("node", "unit", "structure", id, "--version", "1.0.0");
+
+        // Then verify file sizes are displayed
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                () -> assertOutputContains(" B)") // Size in bytes
+        );
+    }
+}
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
 
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitStructureReplCommandTest.java
similarity index 58%
copy from 
modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
copy to 
modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitStructureReplCommandTest.java
index 00df44db495..d74f6f87ed5 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
+++ 
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitStructureReplCommandTest.java
@@ -15,12 +15,25 @@
  * limitations under the License.
  */
 
-package org.apache.ignite.internal.cli.commands.node.unit;
+package org.apache.ignite.internal.cli.commands.unit;
 
-import org.apache.ignite.internal.cli.commands.BaseCommand;
-import picocli.CommandLine.Command;
+import org.apache.ignite.internal.cli.commands.TopLevelCliReplCommand;
+import org.junit.jupiter.api.BeforeEach;
 
-/** Manages deployment units on node level. */
-@Command(name = "unit", subcommands = NodeUnitListCommand.class, description = 
"Manages deployment units")
-public class NodeUnitCommand extends BaseCommand {
+/** Integration test for node unit structure command in REPL mode. */
+class ItNodeUnitStructureReplCommandTest extends 
ItNodeUnitStructureCommandTest {
+    @BeforeEach
+    void connect() {
+        connect(NODE_URL);
+    }
+
+    @Override
+    protected Class<?> getCommandClass() {
+        return TopLevelCliReplCommand.class;
+    }
+
+    @Override
+    protected int errorExitCode() {
+        return 0;
+    }
 }
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitStructureCall.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitStructureCall.java
new file mode 100644
index 00000000000..392a7afc67c
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitStructureCall.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.call.node.unit;
+
+import jakarta.inject.Singleton;
+import org.apache.ignite.internal.cli.call.unit.UnitStructureCallInput;
+import org.apache.ignite.internal.cli.core.call.Call;
+import org.apache.ignite.internal.cli.core.call.CallOutput;
+import org.apache.ignite.internal.cli.core.call.DefaultCallOutput;
+import org.apache.ignite.internal.cli.core.exception.IgniteCliApiException;
+import org.apache.ignite.internal.cli.core.rest.ApiClientFactory;
+import org.apache.ignite.rest.client.api.DeploymentApi;
+import org.apache.ignite.rest.client.invoker.ApiException;
+import org.apache.ignite.rest.client.model.UnitFolder;
+
+/** Get unit structure on the node call. */
+@Singleton
+public class NodeUnitStructureCall implements Call<UnitStructureCallInput, 
UnitFolder> {
+    private final ApiClientFactory clientFactory;
+
+    public NodeUnitStructureCall(ApiClientFactory clientFactory) {
+        this.clientFactory = clientFactory;
+    }
+
+    @Override
+    public CallOutput<UnitFolder> execute(UnitStructureCallInput input) {
+        try {
+            DeploymentApi api = new 
DeploymentApi(clientFactory.getClient(input.url()));
+            UnitFolder structure = api.unitContent(input.unitId(), 
input.version());
+            return DefaultCallOutput.success(structure);
+        } catch (ApiException e) {
+            return DefaultCallOutput.failure(new IgniteCliApiException(e, 
input.url()));
+        }
+    }
+}
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/unit/UnitStructureCallInput.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/unit/UnitStructureCallInput.java
new file mode 100644
index 00000000000..e5e4c7e0260
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/unit/UnitStructureCallInput.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.call.unit;
+
+import org.apache.ignite.internal.cli.core.call.CallInput;
+
+/** Input for unit structure call. */
+public class UnitStructureCallInput implements CallInput {
+
+    private final String unitId;
+
+    private final String version;
+
+    private final String url;
+
+    private UnitStructureCallInput(String unitId, String version, String url) {
+        this.unitId = unitId;
+        this.version = version;
+        this.url = url;
+    }
+
+    public static UnitStructureCallInputBuilder builder() {
+        return new UnitStructureCallInputBuilder();
+    }
+
+    public String unitId() {
+        return unitId;
+    }
+
+    public String version() {
+        return version;
+    }
+
+    public String url() {
+        return url;
+    }
+
+    /** Builder for {@link UnitStructureCallInput}. */
+    public static class UnitStructureCallInputBuilder {
+        private String unitId;
+
+        private String version;
+
+        private String url;
+
+        public UnitStructureCallInputBuilder unitId(String unitId) {
+            this.unitId = unitId;
+            return this;
+        }
+
+        public UnitStructureCallInputBuilder version(String version) {
+            this.version = version;
+            return this;
+        }
+
+        public UnitStructureCallInputBuilder url(String url) {
+            this.url = url;
+            return this;
+        }
+
+        public UnitStructureCallInput build() {
+            return new UnitStructureCallInput(unitId, version, url);
+        }
+    }
+}
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
index 00df44db495..96d1076ed8d 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
@@ -21,6 +21,6 @@ import org.apache.ignite.internal.cli.commands.BaseCommand;
 import picocli.CommandLine.Command;
 
 /** Manages deployment units on node level. */
-@Command(name = "unit", subcommands = NodeUnitListCommand.class, description = 
"Manages deployment units")
+@Command(name = "unit", subcommands = {NodeUnitListCommand.class, 
NodeUnitStructureCommand.class}, description = "Manages deployment units")
 public class NodeUnitCommand extends BaseCommand {
 }
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitReplCommand.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitReplCommand.java
index 013e9b3558d..9f364215e92 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitReplCommand.java
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitReplCommand.java
@@ -21,6 +21,6 @@ import org.apache.ignite.internal.cli.commands.BaseCommand;
 import picocli.CommandLine.Command;
 
 /** Manages deployment units on node level in REPL mode. */
-@Command(name = "unit", subcommands = NodeUnitListReplCommand.class, 
description = "Manages deployment units")
+@Command(name = "unit", subcommands = {NodeUnitListReplCommand.class, 
NodeUnitStructureReplCommand.class}, description = "Manages deployment units")
 public class NodeUnitReplCommand extends BaseCommand {
 }
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureCommand.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureCommand.java
new file mode 100644
index 00000000000..91491bc9c3f
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureCommand.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.commands.node.unit;
+
+import static 
org.apache.ignite.internal.cli.commands.Options.Constants.PLAIN_OPTION;
+import static 
org.apache.ignite.internal.cli.commands.Options.Constants.PLAIN_OPTION_DESC;
+import static 
org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_VERSION_OPTION_DESC;
+import static 
org.apache.ignite.internal.cli.commands.Options.Constants.VERSION_OPTION;
+
+import jakarta.inject.Inject;
+import java.util.concurrent.Callable;
+import org.apache.ignite.internal.cli.call.node.unit.NodeUnitStructureCall;
+import org.apache.ignite.internal.cli.call.unit.UnitStructureCallInput;
+import org.apache.ignite.internal.cli.commands.BaseCommand;
+import org.apache.ignite.internal.cli.commands.node.NodeUrlProfileMixin;
+import org.apache.ignite.internal.cli.core.call.CallExecutionPipeline;
+import 
org.apache.ignite.internal.cli.core.exception.handler.ClusterNotInitializedExceptionHandler;
+import org.apache.ignite.internal.cli.decorators.UnitStructureDecorator;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/** Command to show deployment unit structure. */
+@Command(name = "structure", description = "Shows the structure of a deployed 
unit")
+public class NodeUnitStructureCommand extends BaseCommand implements 
Callable<Integer> {
+
+    @Parameters(index = "0", description = "Deployment unit id")
+    private String unitId;
+
+    @Option(names = VERSION_OPTION, description = UNIT_VERSION_OPTION_DESC, 
required = true)
+    private String version;
+
+    @Mixin
+    private NodeUrlProfileMixin nodeUrl;
+
+    @Option(names = PLAIN_OPTION, description = PLAIN_OPTION_DESC)
+    private boolean plain;
+
+    @Inject
+    private NodeUnitStructureCall call;
+
+    @Override
+    public Integer call() throws Exception {
+        return runPipeline(CallExecutionPipeline.builder(call)
+                .inputProvider(() -> UnitStructureCallInput.builder()
+                        .unitId(unitId)
+                        .version(version)
+                        .url(nodeUrl.getNodeUrl())
+                        .build())
+                .decorator(new UnitStructureDecorator(plain))
+                
.exceptionHandler(ClusterNotInitializedExceptionHandler.createHandler("Cannot 
get unit structure"))
+        );
+    }
+}
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureReplCommand.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureReplCommand.java
new file mode 100644
index 00000000000..c5c46ca724c
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureReplCommand.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.commands.node.unit;
+
+import static 
org.apache.ignite.internal.cli.commands.Options.Constants.PLAIN_OPTION;
+import static 
org.apache.ignite.internal.cli.commands.Options.Constants.PLAIN_OPTION_DESC;
+import static 
org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_VERSION_OPTION_DESC;
+import static 
org.apache.ignite.internal.cli.commands.Options.Constants.VERSION_OPTION;
+
+import jakarta.inject.Inject;
+import org.apache.ignite.internal.cli.call.node.unit.NodeUnitStructureCall;
+import org.apache.ignite.internal.cli.call.unit.UnitStructureCallInput;
+import org.apache.ignite.internal.cli.commands.BaseCommand;
+import org.apache.ignite.internal.cli.commands.node.NodeUrlMixin;
+import 
org.apache.ignite.internal.cli.commands.questions.ConnectToClusterQuestion;
+import 
org.apache.ignite.internal.cli.core.exception.handler.ClusterNotInitializedExceptionHandler;
+import org.apache.ignite.internal.cli.core.flow.builder.Flows;
+import org.apache.ignite.internal.cli.decorators.UnitStructureDecorator;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/** Command to show deployment unit structure in REPL mode. */
+@Command(name = "structure", description = "Shows the structure of a deployed 
unit")
+public class NodeUnitStructureReplCommand extends BaseCommand implements 
Runnable {
+
+    @Parameters(index = "0", description = "Deployment unit id")
+    private String unitId;
+
+    @Option(names = VERSION_OPTION, description = UNIT_VERSION_OPTION_DESC, 
required = true)
+    private String version;
+
+    @Mixin
+    private NodeUrlMixin nodeUrl;
+
+    @Option(names = PLAIN_OPTION, description = PLAIN_OPTION_DESC)
+    private boolean plain;
+
+    @Inject
+    private NodeUnitStructureCall call;
+
+    @Inject
+    private ConnectToClusterQuestion question;
+
+    @Override
+    public void run() {
+        runFlow(question.askQuestionIfNotConnected(nodeUrl.getNodeUrl())
+                .map(url -> UnitStructureCallInput.builder()
+                        .unitId(unitId)
+                        .version(version)
+                        .url(url)
+                        .build())
+                .then(Flows.fromCall(call))
+                
.exceptionHandler(ClusterNotInitializedExceptionHandler.createReplHandler("Cannot
 get unit structure"))
+                .print(new UnitStructureDecorator(plain))
+        );
+    }
+}
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecorator.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecorator.java
new file mode 100644
index 00000000000..f2e5c9fae90
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecorator.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.decorators;
+
+import static org.apache.ignite.internal.util.IgniteUtils.readableSize;
+
+import java.util.List;
+import org.apache.ignite.internal.cli.core.decorator.Decorator;
+import org.apache.ignite.internal.cli.core.decorator.TerminalOutput;
+import org.apache.ignite.rest.client.model.UnitEntry;
+import org.apache.ignite.rest.client.model.UnitFile;
+import org.apache.ignite.rest.client.model.UnitFolder;
+
+/** Decorates unit structure as a tree. */
+public class UnitStructureDecorator implements Decorator<UnitFolder, 
TerminalOutput> {
+
+    private static final String TREE_BRANCH = "+-- ";
+    private static final String TREE_LAST = "\\-- ";
+    private static final String TREE_VERTICAL = "|   ";
+    private static final String TREE_SPACE = "    ";
+
+    private final boolean plain;
+
+    public UnitStructureDecorator(boolean plain) {
+        this.plain = plain;
+    }
+
+    @Override
+    public TerminalOutput decorate(UnitFolder data) {
+        StringBuilder sb = new StringBuilder();
+
+        if (plain) {
+            renderPlain(data, sb, "");
+        } else {
+            sb.append(data.getName()).append("\n");
+            renderTree(data.getChildren(), sb, "");
+        }
+
+        String result = sb.toString();
+        return () -> result;
+    }
+
+    private static void renderTree(List<UnitEntry> children, StringBuilder sb, 
String prefix) {
+        if (children == null || children.isEmpty()) {
+            return;
+        }
+
+        for (int i = 0; i < children.size(); i++) {
+            boolean isLast = i == children.size() - 1;
+            UnitEntry child = children.get(i);
+
+            Object actualInstance = child.getActualInstance();
+            if (actualInstance == null) {
+                continue;
+            }
+
+            String connector = isLast ? TREE_LAST : TREE_BRANCH;
+
+            if (actualInstance instanceof UnitFile) {
+                UnitFile file = (UnitFile) actualInstance;
+                sb.append(prefix).append(connector).append(file.getName());
+                sb.append(" (").append(readableSize(file.getSize(), 
false)).append(")");
+                sb.append("\n");
+            } else if (actualInstance instanceof UnitFolder) {
+                UnitFolder folder = (UnitFolder) actualInstance;
+                
sb.append(prefix).append(connector).append(folder.getName()).append("\n");
+                String newPrefix = prefix + (isLast ? TREE_SPACE : 
TREE_VERTICAL);
+                renderTree(folder.getChildren(), sb, newPrefix);
+            }
+        }
+    }
+
+    private static void renderPlain(UnitFolder folder, StringBuilder sb, 
String prefix) {
+        if (folder == null) {
+            return;
+        }
+
+        List<UnitEntry> children = folder.getChildren();
+        if (children == null || children.isEmpty()) {
+            return;
+        }
+
+        for (UnitEntry child : children) {
+            Object actualInstance = child.getActualInstance();
+            if (actualInstance == null) {
+                continue;
+            }
+
+            if (actualInstance instanceof UnitFile) {
+                UnitFile file = (UnitFile) actualInstance;
+                sb.append(prefix);
+                if (!prefix.isEmpty()) {
+                    sb.append("/");
+                }
+                sb.append(file.getName());
+                sb.append(" ").append(file.getSize());
+                sb.append("\n");
+            } else if (actualInstance instanceof UnitFolder) {
+                UnitFolder subFolder = (UnitFolder) actualInstance;
+                String newPrefix = prefix.isEmpty() ? subFolder.getName() : 
prefix + "/" + subFolder.getName();
+                renderPlain(subFolder, sb, newPrefix);
+            }
+        }
+    }
+
+}
diff --git 
a/modules/cli/src/test/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitStructureCallTest.java
 
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitStructureCallTest.java
new file mode 100644
index 00000000000..d974d012be9
--- /dev/null
+++ 
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitStructureCallTest.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.call.node.unit;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import jakarta.inject.Inject;
+import org.apache.ignite.internal.cli.call.unit.UnitStructureCallInput;
+import org.apache.ignite.internal.cli.core.call.CallOutput;
+import org.apache.ignite.rest.client.model.UnitFolder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@MicronautTest(rebuildContext = true)
+@WireMockTest
+class NodeUnitStructureCallTest {
+
+    private String url;
+
+    @Inject
+    private NodeUnitStructureCall call;
+
+    @BeforeEach
+    void setUp(WireMockRuntimeInfo wmRuntimeInfo) {
+        url = wmRuntimeInfo.getHttpBaseUrl();
+    }
+
+    @Test
+    @DisplayName("Should return unit structure with files")
+    void unitStructureWithFiles() {
+        // Given
+        String unitId = "test.unit";
+        String version = "1.0.0";
+
+        String json = "{"
+                + "\"type\":\"folder\","
+                + "\"name\":\"test.unit-1.0.0\","
+                + "\"size\":300,"
+                + "\"children\":["
+                + "{\"type\":\"file\",\"name\":\"file1.txt\",\"size\":100},"
+                + "{\"type\":\"file\",\"name\":\"file2.txt\",\"size\":200}"
+                + "]"
+                + "}";
+
+        
stubFor(get(urlEqualTo("/management/v1/deployment/node/units/structure/" + 
unitId + "/" + version))
+                .willReturn(ok(json)));
+
+        // When
+        UnitStructureCallInput input = UnitStructureCallInput.builder()
+                .unitId(unitId)
+                .version(version)
+                .url(url)
+                .build();
+
+        CallOutput<UnitFolder> output = call.execute(input);
+
+        // Then
+        assertThat(output.hasError(), is(false));
+        assertThat(output.body(), is(notNullValue()));
+        assertThat(output.body().getName(), is("test.unit-1.0.0"));
+        assertThat(output.body().getChildren(), hasSize(2));
+    }
+
+    @Test
+    @DisplayName("Should return error when unit not found")
+    void unitNotFound() {
+        // Given
+        String unitId = "non.existing.unit";
+        String version = "1.0.0";
+
+        
stubFor(get(urlEqualTo("/management/v1/deployment/node/units/structure/" + 
unitId + "/" + version))
+                
.willReturn(com.github.tomakehurst.wiremock.client.WireMock.notFound()
+                        .withBody("{\"title\":\"Not 
Found\",\"status\":404,\"detail\":\"Unit not found\"}")));
+
+        // When
+        UnitStructureCallInput input = UnitStructureCallInput.builder()
+                .unitId(unitId)
+                .version(version)
+                .url(url)
+                .build();
+
+        CallOutput<UnitFolder> output = call.execute(input);
+
+        // Then
+        assertThat(output.hasError(), is(true));
+        assertThat(output.errorCause().getMessage(), containsString("404"));
+    }
+
+    @Test
+    @DisplayName("Should return unit structure with nested folders")
+    void unitStructureWithNestedFolders() {
+        // Given
+        String unitId = "test.unit";
+        String version = "1.0.0";
+
+        String json = "{"
+                + "\"type\":\"folder\","
+                + "\"name\":\"test.unit-1.0.0\","
+                + "\"size\":50,"
+                + "\"children\":["
+                + "{"
+                + "\"type\":\"folder\","
+                + "\"name\":\"subfolder\","
+                + "\"size\":50,"
+                + "\"children\":["
+                + "{\"type\":\"file\",\"name\":\"nested.txt\",\"size\":50}"
+                + "]"
+                + "}"
+                + "]"
+                + "}";
+
+        
stubFor(get(urlEqualTo("/management/v1/deployment/node/units/structure/" + 
unitId + "/" + version))
+                .willReturn(ok(json)));
+
+        // When
+        UnitStructureCallInput input = UnitStructureCallInput.builder()
+                .unitId(unitId)
+                .version(version)
+                .url(url)
+                .build();
+
+        CallOutput<UnitFolder> output = call.execute(input);
+
+        // Then
+        assertThat(output.hasError(), is(false));
+        assertThat(output.body(), is(notNullValue()));
+        assertThat(output.body().getName(), is("test.unit-1.0.0"));
+        assertThat(output.body().getChildren(), hasSize(1));
+
+        UnitFolder nested = (UnitFolder) 
output.body().getChildren().get(0).getActualInstance();
+        assertThat(nested.getName(), is("subfolder"));
+        assertThat(nested.getChildren(), hasSize(1));
+    }
+}
diff --git 
a/modules/cli/src/test/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecoratorTest.java
 
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecoratorTest.java
new file mode 100644
index 00000000000..b5e7001a6fc
--- /dev/null
+++ 
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecoratorTest.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.cli.decorators;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.not;
+
+import java.util.List;
+import org.apache.ignite.rest.client.model.UnitEntry;
+import org.apache.ignite.rest.client.model.UnitFile;
+import org.apache.ignite.rest.client.model.UnitFolder;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class UnitStructureDecoratorTest {
+
+    @Test
+    @DisplayName("Tree view should display folder with files")
+    void treeViewWithFiles() {
+        // Given
+        UnitFolder folder = createFolderWithFiles("test-unit",
+                createFile("file1.txt", 100),
+                createFile("file2.txt", 200));
+
+        UnitStructureDecorator decorator = new UnitStructureDecorator(false);
+
+        // When
+        String output = decorator.decorate(folder).toTerminalString();
+
+        // Then
+        assertThat(output, containsString("test-unit"));
+        assertThat(output, containsString("file1.txt"));
+        assertThat(output, containsString("file2.txt"));
+        assertThat(output, containsString("100 B"));
+        assertThat(output, containsString("200 B"));
+        assertThat(output, containsString("+--"));
+        assertThat(output, containsString("\\--"));
+    }
+
+    @Test
+    @DisplayName("Tree view should display nested folders")
+    void treeViewWithNestedFolders() {
+        // Given
+        UnitFolder subFolder = createFolderWithFiles("subfolder",
+                createFile("nested.txt", 50));
+
+        UnitEntry subFolderEntry = new UnitEntry();
+        subFolderEntry.setActualInstance(subFolder);
+
+        UnitFolder rootFolder = new UnitFolder()
+                .type(UnitFolder.TypeEnum.FOLDER)
+                .name("root")
+                .children(List.of(subFolderEntry));
+
+        UnitStructureDecorator decorator = new UnitStructureDecorator(false);
+
+        // When
+        String output = decorator.decorate(rootFolder).toTerminalString();
+
+        // Then
+        assertThat(output, containsString("root"));
+        assertThat(output, containsString("subfolder"));
+        assertThat(output, containsString("nested.txt"));
+        assertThat(output, containsString("\\--"));
+        assertThat(output, containsString("    \\--")); // Nested indent
+    }
+
+    @Test
+    @DisplayName("Plain view should display file paths")
+    void plainViewWithFiles() {
+        // Given
+        UnitFolder folder = createFolderWithFiles("test-unit",
+                createFile("file1.txt", 100),
+                createFile("file2.txt", 200));
+
+        UnitStructureDecorator decorator = new UnitStructureDecorator(true);
+
+        // When
+        String output = decorator.decorate(folder).toTerminalString();
+
+        // Then
+        assertThat(output, containsString("file1.txt"));
+        assertThat(output, containsString("file2.txt"));
+        assertThat(output, containsString("100"));
+        assertThat(output, containsString("200"));
+        // Should not contain tree characters in plain mode
+        assertThat(output, not(containsString("├──")));
+        assertThat(output, not(containsString("└──")));
+    }
+
+    @Test
+    @DisplayName("Plain view should display nested paths")
+    void plainViewWithNestedFolders() {
+        // Given
+        UnitFolder subFolder = createFolderWithFiles("subfolder",
+                createFile("nested.txt", 50));
+
+        UnitEntry subFolderEntry = new UnitEntry();
+        subFolderEntry.setActualInstance(subFolder);
+
+        UnitFolder rootFolder = new UnitFolder()
+                .type(UnitFolder.TypeEnum.FOLDER)
+                .name("root")
+                .children(List.of(subFolderEntry));
+
+        UnitStructureDecorator decorator = new UnitStructureDecorator(true);
+
+        // When
+        String output = decorator.decorate(rootFolder).toTerminalString();
+
+        // Then
+        assertThat(output, containsString("subfolder/nested.txt"));
+        assertThat(output, containsString("50"));
+    }
+
+    @Test
+    @DisplayName("Should format file sizes correctly")
+    void fileSizeFormatting() {
+        // Given
+        UnitFolder folder = createFolderWithFiles("test-unit",
+                createFile("small.txt", 512),
+                createFile("medium.txt", 2048),
+                createFile("large.txt", 1024 * 1024 * 2));
+
+        UnitStructureDecorator decorator = new UnitStructureDecorator(false);
+
+        // When
+        String output = decorator.decorate(folder).toTerminalString();
+
+        // Then
+        assertThat(output, containsString("512 B"));
+        assertThat(output, containsString("KiB"));
+        assertThat(output, containsString("MiB"));
+    }
+
+    @Test
+    @DisplayName("Should handle empty folder")
+    void emptyFolder() {
+        // Given
+        UnitFolder folder = new UnitFolder()
+                .type(UnitFolder.TypeEnum.FOLDER)
+                .name("empty-folder")
+                .children(List.of());
+
+        UnitStructureDecorator decorator = new UnitStructureDecorator(false);
+
+        // When
+        String output = decorator.decorate(folder).toTerminalString();
+
+        // Then
+        assertThat(output, containsString("empty-folder"));
+    }
+
+    private UnitFolder createFolderWithFiles(String name, UnitFile... files) {
+        List<UnitEntry> children = new java.util.ArrayList<>();
+        long totalSize = 0;
+
+        for (UnitFile file : files) {
+            UnitEntry entry = new UnitEntry();
+            entry.setActualInstance(file);
+            children.add(entry);
+            totalSize += file.getSize();
+        }
+
+        return new UnitFolder()
+                .type(UnitFolder.TypeEnum.FOLDER)
+                .name(name)
+                .children(children);
+    }
+
+    private UnitFile createFile(String name, long size) {
+        return new UnitFile()
+                .type(UnitFile.TypeEnum.FILE)
+                .name(name)
+                .size(size);
+    }
+}
diff --git 
a/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/UnitEntry.java
 
b/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/UnitEntry.java
index 0616ca2f810..e56fdc9a722 100644
--- 
a/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/UnitEntry.java
+++ 
b/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/UnitEntry.java
@@ -39,7 +39,11 @@ import 
org.apache.ignite.internal.rest.api.deployment.UnitEntry.UnitFolder;
  * <p>The JSON representation includes a discriminator field "type" that can 
be either
  * "file" or "folder" to distinguish between the two implementations.
  */
-@Schema(description = "Unit content entry.")
+@Schema(
+        description = "Unit content entry.",
+        oneOf = {UnitFile.class, UnitFolder.class},
+        discriminatorProperty = "type"
+)
 @JsonTypeInfo(
         use = JsonTypeInfo.Id.NAME,
         include = JsonTypeInfo.As.PROPERTY,
@@ -74,8 +78,11 @@ public interface UnitEntry {
      * <p>This nested class implements {@link UnitEntry} and is serialized with
      * a "type": "file" discriminator in JSON responses.
      */
-    @Schema(description = "Unit content file.")
+    @Schema(name = "UnitFile", description = "Unit content file.")
     public class UnitFile implements UnitEntry {
+        @Schema(description = "Entry type discriminator.", requiredMode = 
RequiredMode.REQUIRED, allowableValues = "file")
+        private final String type = "file";
+
         @Schema(description = "Unit content file name.", requiredMode = 
RequiredMode.REQUIRED)
         private final String name;
 
@@ -97,6 +104,16 @@ public interface UnitEntry {
             this.size = size;
         }
 
+        /**
+         * Returns the type discriminator.
+         *
+         * @return the type string "file"
+         */
+        @JsonGetter("type")
+        public String type() {
+            return type;
+        }
+
         /**
          * Returns the name of this file.
          *
@@ -130,14 +147,21 @@ public interface UnitEntry {
      *
      * <p>The size of a folder is calculated as the sum of all its children's 
sizes.
      */
-    @Schema(description = "Unit content folder.")
+    @Schema(name = "UnitFolder", description = "Unit content folder.")
     public class UnitFolder implements UnitEntry {
+        @Schema(description = "Entry type discriminator.", requiredMode = 
RequiredMode.REQUIRED, allowableValues = "folder")
+        private final String type = "folder";
+
         @Schema(description = "Unit content folder name.", requiredMode = 
RequiredMode.REQUIRED)
         private final String name;
 
         @Schema(description = "Unit content folder elements.", requiredMode = 
RequiredMode.REQUIRED)
         private final List<UnitEntry> children;
 
+        @Schema(description = "Total size of folder contents in bytes 
(computed as sum of all children).",
+                requiredMode = RequiredMode.REQUIRED, accessMode = 
Schema.AccessMode.READ_ONLY)
+        private final long size;
+
         /**
          * Creates a new folder entry for JSON deserialization.
          *
@@ -151,6 +175,17 @@ public interface UnitEntry {
         ) {
             this.name = name;
             this.children = children;
+            this.size = children == null ? 0 : 
children.stream().mapToLong(UnitEntry::size).sum();
+        }
+
+        /**
+         * Returns the type discriminator.
+         *
+         * @return the type string "folder"
+         */
+        @JsonGetter("type")
+        public String type() {
+            return type;
         }
 
         /**
@@ -171,9 +206,10 @@ public interface UnitEntry {
          *
          * @return the total folder size in bytes
          */
+        @JsonGetter("size")
         @Override
         public long size() {
-            return children.stream().mapToLong(UnitEntry::size).sum();
+            return size;
         }
 
         /**


Reply via email to