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

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


The following commit(s) were added to refs/heads/main by this push:
     new 95f9aeb7393 IGNITE-27201 Add CLI command to inspect deployment unit 
file tree (#7356)
95f9aeb7393 is described below

commit 95f9aeb7393800ceb4951d108151edb114f13aed
Author: Mikhail <[email protected]>
AuthorDate: Thu Jan 15 18:17:11 2026 +0300

    IGNITE-27201 Add CLI command to inspect deployment unit file tree (#7356)
---
 .../unit/ItNodeUnitInspectCommandTest.java         | 387 +++++++++++++++++++++
 .../unit/ItNodeUnitInspectReplCommandTest.java}    |  25 +-
 .../call/cluster/unit/ZipDeploymentContent.java    |  20 +-
 .../cli/call/node/unit/NodeUnitInspectCall.java    |  50 +++
 .../cli/call/unit/UnitInspectCallInput.java        |  80 +++++
 .../cli/commands/node/unit/NodeUnitCommand.java    |   2 +-
 .../commands/node/unit/NodeUnitInspectCommand.java |  70 ++++
 .../node/unit/NodeUnitInspectReplCommand.java      |  74 ++++
 .../commands/node/unit/NodeUnitReplCommand.java    |   4 +-
 .../cli/decorators/UnitInspectDecorator.java       | 121 +++++++
 .../call/node/unit/NodeUnitInspectCallTest.java    | 161 +++++++++
 .../cli/decorators/UnitInspectDecoratorTest.java   | 179 ++++++++++
 modules/rest-api/build.gradle                      |   2 +
 .../internal/rest/api/deployment/UnitEntry.java    |  49 ++-
 14 files changed, 1206 insertions(+), 18 deletions(-)

diff --git 
a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitInspectCommandTest.java
 
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitInspectCommandTest.java
new file mode 100644
index 00000000000..f0021e5daf9
--- /dev/null
+++ 
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitInspectCommandTest.java
@@ -0,0 +1,387 @@
+/*
+ * 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 inspect command. */
+public class ItNodeUnitInspectCommandTest extends CliIntegrationTest {
+    private static String testFile;
+
+    private static Path testDirectory;
+
+    private static Path recursiveDirectory;
+
+    private static Path emptyFolderDirectory;
+
+    @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"));
+
+        // Files with special characters
+        Files.createFile(testDirectory.resolve("file with spaces.txt"));
+        Files.createFile(testDirectory.resolve("file-with-dashes.txt"));
+        Files.createFile(testDirectory.resolve("file_with_underscores.txt"));
+        Files.createFile(testDirectory.resolve("file.multiple.dots.txt"));
+        Files.createFile(testDirectory.resolve("file(with)parentheses.txt"));
+        Files.createFile(testDirectory.resolve("UPPERCASE.TXT"));
+        Files.createFile(testDirectory.resolve("MixedCase.Txt"));
+
+        // Subdirectory with files
+        Path subDir = 
Files.createDirectory(testDirectory.resolve("sub-directory"));
+        Files.createFile(subDir.resolve("nested file.txt"));
+        Files.createFile(subDir.resolve("nested-file.txt"));
+
+        // Deeply nested directory
+        Path deepDir = 
Files.createDirectories(testDirectory.resolve("level1").resolve("level2"));
+        Files.createFile(deepDir.resolve("deep file.txt"));
+
+        // Setup for recursive deployment test with 3-level structure
+        recursiveDirectory = 
Files.createDirectory(WORK_DIR.resolve("test-recursive"));
+
+        // Level 1 - root files with different naming conventions
+        Files.createFile(recursiveDirectory.resolve("root-config.xml"));
+        Files.createFile(recursiveDirectory.resolve("ROOT_README.md"));
+        Files.createFile(recursiveDirectory.resolve("root file.txt"));
+
+        // Level 2 - subdirectories with different naming styles
+        Path srcDir = Files.createDirectory(recursiveDirectory.resolve("src"));
+        Files.createFile(srcDir.resolve("Main.java"));
+        Files.createFile(srcDir.resolve("Helper Class.java"));
+        Files.createFile(srcDir.resolve("util_functions.py"));
+
+        Path resourcesDir = 
Files.createDirectory(recursiveDirectory.resolve("resources-dir"));
+        Files.createFile(resourcesDir.resolve("application.properties"));
+        Files.createFile(resourcesDir.resolve("messages_en.properties"));
+
+        Path libDir = 
Files.createDirectory(recursiveDirectory.resolve("lib_folder"));
+        Files.createFile(libDir.resolve("dependency-1.0.jar"));
+        Files.createFile(libDir.resolve("NATIVE LIB.so"));
+
+        // Level 3 - deeply nested with various cases
+        Path srcSubDir = 
Files.createDirectory(srcDir.resolve("com.example.app"));
+        Files.createFile(srcSubDir.resolve("Application.java"));
+        Files.createFile(srcSubDir.resolve("Service Impl.java"));
+        Files.createFile(srcSubDir.resolve("data_model.java"));
+
+        Path resourceSubDir = 
Files.createDirectory(resourcesDir.resolve("i18n"));
+        Files.createFile(resourceSubDir.resolve("messages_ru.properties"));
+        Files.createFile(resourceSubDir.resolve("MESSAGES_DE.properties"));
+
+        Path libSubDir = Files.createDirectory(libDir.resolve("native-libs"));
+        Files.createFile(libSubDir.resolve("lib_native.dll"));
+        Files.createFile(libSubDir.resolve("Native Lib.dylib"));
+
+        // Setup for empty folder test
+        emptyFolderDirectory = 
Files.createDirectory(WORK_DIR.resolve("test-empty-folder"));
+        Files.createFile(emptyFolderDirectory.resolve("existing-file.txt"));
+        Files.createDirectory(emptyFolderDirectory.resolve("empty-folder"));
+        Path nonEmptyFolder = 
Files.createDirectory(emptyFolderDirectory.resolve("non-empty-folder"));
+        Files.createFile(nonEmptyFolder.resolve("file-in-folder.txt"));
+        Files.createDirectory(nonEmptyFolder.resolve("nested-empty-folder"));
+    }
+
+    @Test
+    @DisplayName("Should display unit structure with tree view")
+    void structureTreeView() {
+        // Given deployed unit with recursive option to include subdirectories
+        String id = "test.structure.unit.1";
+        execute("cluster", "unit", "deploy", id, "--version", "1.0.0", 
"--path", testDirectory.toString(), "--recursive");
+
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", id);
+            assertExitCodeIsZero();
+        });
+
+        // When get structure
+        execute("node", "unit", "inspect", id, "--version", "1.0.0");
+
+        // Then
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                () -> assertOutputContains(id),
+                () -> assertOutputContains("test.txt"),
+                () -> assertOutputContains("test2.txt"),
+                () -> assertOutputContains("test3.txt"),
+                () -> assertOutputContains("file with spaces.txt"),
+                () -> assertOutputContains("file-with-dashes.txt"),
+                () -> assertOutputContains("file_with_underscores.txt"),
+                () -> assertOutputContains("file.multiple.dots.txt"),
+                () -> assertOutputContains("file(with)parentheses.txt"),
+                () -> assertOutputContains("UPPERCASE.TXT"),
+                () -> assertOutputContains("MixedCase.Txt"),
+                () -> assertOutputContains("sub-directory"),
+                () -> assertOutputContains("nested file.txt"),
+                () -> assertOutputContains("nested-file.txt"),
+                () -> assertOutputContains("level1"),
+                () -> assertOutputContains("level2"),
+                () -> assertOutputContains("deep file.txt"),
+                () -> assertOutputContains(" B)") // File size display
+        );
+    }
+
+    @Test
+    @DisplayName("Should display unit structure with plain view")
+    void structurePlainView() {
+        // Given deployed unit with recursive option to include subdirectories
+        String id = "test.structure.unit.2";
+        execute("cluster", "unit", "deploy", id, "--version", "1.0.0", 
"--path", testDirectory.toString(), "--recursive");
+
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", id);
+            assertExitCodeIsZero();
+        });
+
+        // When get structure with plain option
+        execute("node", "unit", "inspect", id, "--version", "1.0.0", 
"--plain");
+
+        // Then
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                () -> assertOutputContains("test.txt"),
+                () -> assertOutputContains("test2.txt"),
+                () -> assertOutputContains("test3.txt"),
+                () -> assertOutputContains("file with spaces.txt"),
+                () -> assertOutputContains("file-with-dashes.txt"),
+                () -> assertOutputContains("file_with_underscores.txt"),
+                () -> assertOutputContains("file.multiple.dots.txt"),
+                () -> assertOutputContains("file(with)parentheses.txt"),
+                () -> assertOutputContains("sub-directory"),
+                () -> assertOutputContains("level1"),
+                () -> assertOutputContains("level2")
+        );
+    }
+
+    @Test
+    @DisplayName("Should display error when version is missing")
+    void structureVersionIsMandatory() {
+        // When get structure without version
+        execute("node", "unit", "inspect", "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", "inspect", "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", "inspect", 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", "inspect", id, "--version", "1.0.0");
+
+        // Then verify file sizes are displayed
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                () -> assertOutputContains(" B)") // Size in bytes
+        );
+    }
+
+    @Test
+    @DisplayName("Should display 3-level structure with recursive deployment")
+    void structureWithRecursiveDeployment() {
+        // Given deployed unit with recursive option
+        String id = "test.recursive.unit.1";
+        execute("cluster", "unit", "deploy", id, "--version", "1.0.0", 
"--path", recursiveDirectory.toString(), "--recursive");
+
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", id);
+            assertExitCodeIsZero();
+        });
+
+        // When get structure
+        execute("node", "unit", "inspect", id, "--version", "1.0.0");
+
+        // Then verify all 3 levels are present with different naming cases
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                // Level 1 - root files
+                () -> assertOutputContains("root-config.xml"),
+                () -> assertOutputContains("ROOT_README.md"),
+                () -> assertOutputContains("root file.txt"),
+                // Level 2 - directories and their files
+                () -> assertOutputContains("src"),
+                () -> assertOutputContains("Main.java"),
+                () -> assertOutputContains("Helper Class.java"),
+                () -> assertOutputContains("util_functions.py"),
+                () -> assertOutputContains("resources-dir"),
+                () -> assertOutputContains("application.properties"),
+                () -> assertOutputContains("messages_en.properties"),
+                () -> assertOutputContains("lib_folder"),
+                () -> assertOutputContains("dependency-1.0.jar"),
+                () -> assertOutputContains("NATIVE LIB.so"),
+                // Level 3 - deeply nested directories and files
+                () -> assertOutputContains("com.example.app"),
+                () -> assertOutputContains("Application.java"),
+                () -> assertOutputContains("Service Impl.java"),
+                () -> assertOutputContains("data_model.java"),
+                () -> assertOutputContains("i18n"),
+                () -> assertOutputContains("messages_ru.properties"),
+                () -> assertOutputContains("MESSAGES_DE.properties"),
+                () -> assertOutputContains("native-libs"),
+                () -> assertOutputContains("lib_native.dll"),
+                () -> assertOutputContains("Native Lib.dylib")
+        );
+    }
+
+    @Test
+    @DisplayName("Should display 3-level structure with recursive deployment 
in plain view")
+    void structureWithRecursiveDeploymentPlainView() {
+        // Given deployed unit with recursive option
+        String id = "test.recursive.unit.2";
+        execute("cluster", "unit", "deploy", id, "--version", "1.0.0", 
"--path", recursiveDirectory.toString(), "--recursive");
+
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", id);
+            assertExitCodeIsZero();
+        });
+
+        // When get structure with plain view
+        execute("node", "unit", "inspect", id, "--version", "1.0.0", 
"--plain");
+
+        // Then verify nested paths are displayed correctly
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                // Verify nested paths include parent directories
+                () -> assertOutputContains("src/Main.java"),
+                () -> 
assertOutputContains("src/com.example.app/Application.java"),
+                () -> 
assertOutputContains("resources-dir/i18n/messages_ru.properties"),
+                () -> 
assertOutputContains("lib_folder/native-libs/lib_native.dll")
+        );
+    }
+
+    @Test
+    @DisplayName("Should display structure with empty folders using recursive 
deployment")
+    void structureWithEmptyFolders() {
+        // Given deployed unit with recursive option containing empty folders
+        String id = "test.empty.folder.unit.1";
+        execute("cluster", "unit", "deploy", id, "--version", "1.0.0", 
"--path", emptyFolderDirectory.toString(), "--recursive");
+
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", id);
+            assertExitCodeIsZero();
+        });
+
+        // When get structure
+        execute("node", "unit", "inspect", id, "--version", "1.0.0");
+
+        // Then verify structure includes empty folders
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                // Root level file
+                () -> assertOutputContains("existing-file.txt"),
+                // Empty folder at root level
+                () -> assertOutputContains("empty-folder"),
+                // Non-empty folder with contents
+                () -> assertOutputContains("non-empty-folder"),
+                () -> assertOutputContains("file-in-folder.txt"),
+                // Nested empty folder
+                () -> assertOutputContains("nested-empty-folder")
+        );
+    }
+
+    @Test
+    @DisplayName("Should display structure with empty folders in plain view")
+    void structureWithEmptyFoldersPlainView() {
+        // Given deployed unit with recursive option containing empty folders
+        String id = "test.empty.folder.unit.2";
+        execute("cluster", "unit", "deploy", id, "--version", "1.0.0", 
"--path", emptyFolderDirectory.toString(), "--recursive");
+
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", id);
+            assertExitCodeIsZero();
+        });
+
+        // When get structure with plain view
+        execute("node", "unit", "inspect", id, "--version", "1.0.0", 
"--plain");
+
+        // Then verify files are shown with correct paths
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                () -> assertOutputContains("existing-file.txt"),
+                () -> 
assertOutputContains("non-empty-folder/file-in-folder.txt")
+        );
+    }
+}
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/ItNodeUnitInspectReplCommandTest.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/ItNodeUnitInspectReplCommandTest.java
index 00df44db495..404a6315861 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/ItNodeUnitInspectReplCommandTest.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 inspect command in REPL mode. */
+class ItNodeUnitInspectReplCommandTest extends ItNodeUnitInspectCommandTest {
+    @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/cluster/unit/ZipDeploymentContent.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/ZipDeploymentContent.java
index c1be70f23ab..763a5525699 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/ZipDeploymentContent.java
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/ZipDeploymentContent.java
@@ -57,13 +57,21 @@ class ZipDeploymentContent implements DeploymentContent {
         try (OutputStream os = Files.newOutputStream(zipPath);
                 ZipOutputStream zos = new ZipOutputStream(os)) {
             try (Stream<Path> stream = Files.walk(sourceDir)) {
-                stream.filter(Files::isRegularFile).forEach(filePath -> {
-                    Path relativePath = sourceDir.relativize(filePath);
+                stream.filter(path -> !path.equals(sourceDir)).forEach(path -> 
{
+                    Path relativePath = sourceDir.relativize(path);
                     try {
-                        ZipEntry zipEntry = new 
ZipEntry(relativePath.toString().replace('\\', '/'));
-                        zos.putNextEntry(zipEntry);
-                        Files.copy(filePath, zos);
-                        zos.closeEntry();
+                        if (Files.isDirectory(path)) {
+                            // Add directory entry (with trailing slash)
+                            ZipEntry zipEntry = new 
ZipEntry(relativePath.toString().replace('\\', '/') + "/");
+                            zos.putNextEntry(zipEntry);
+                            zos.closeEntry();
+                        } else {
+                            // Add regular file entry
+                            ZipEntry zipEntry = new 
ZipEntry(relativePath.toString().replace('\\', '/'));
+                            zos.putNextEntry(zipEntry);
+                            Files.copy(path, zos);
+                            zos.closeEntry();
+                        }
                     } catch (IOException e) {
                         throw sneakyThrow(e);
                     }
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCall.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCall.java
new file mode 100644
index 00000000000..726f8b8314a
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCall.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.UnitInspectCallInput;
+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;
+
+/** Inspect unit on the node call. */
+@Singleton
+public class NodeUnitInspectCall implements Call<UnitInspectCallInput, 
UnitFolder> {
+    private final ApiClientFactory clientFactory;
+
+    public NodeUnitInspectCall(ApiClientFactory clientFactory) {
+        this.clientFactory = clientFactory;
+    }
+
+    @Override
+    public CallOutput<UnitFolder> execute(UnitInspectCallInput 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/UnitInspectCallInput.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/unit/UnitInspectCallInput.java
new file mode 100644
index 00000000000..63abbb069f0
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/unit/UnitInspectCallInput.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 inspect call. */
+public class UnitInspectCallInput implements CallInput {
+
+    private final String unitId;
+
+    private final String version;
+
+    private final String url;
+
+    private UnitInspectCallInput(String unitId, String version, String url) {
+        this.unitId = unitId;
+        this.version = version;
+        this.url = url;
+    }
+
+    public static UnitInspectCallInputBuilder builder() {
+        return new UnitInspectCallInputBuilder();
+    }
+
+    public String unitId() {
+        return unitId;
+    }
+
+    public String version() {
+        return version;
+    }
+
+    public String url() {
+        return url;
+    }
+
+    /** Builder for {@link UnitInspectCallInput}. */
+    public static class UnitInspectCallInputBuilder {
+        private String unitId;
+
+        private String version;
+
+        private String url;
+
+        public UnitInspectCallInputBuilder unitId(String unitId) {
+            this.unitId = unitId;
+            return this;
+        }
+
+        public UnitInspectCallInputBuilder version(String version) {
+            this.version = version;
+            return this;
+        }
+
+        public UnitInspectCallInputBuilder url(String url) {
+            this.url = url;
+            return this;
+        }
+
+        public UnitInspectCallInput build() {
+            return new UnitInspectCallInput(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..f8a01f49d63 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, 
NodeUnitInspectCommand.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/NodeUnitInspectCommand.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectCommand.java
new file mode 100644
index 00000000000..8c474073b7d
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectCommand.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.NodeUnitInspectCall;
+import org.apache.ignite.internal.cli.call.unit.UnitInspectCallInput;
+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.UnitInspectDecorator;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/** Command to inspect deployment unit. */
+@Command(name = "inspect", description = "Inspects the structure of a deployed 
unit")
+public class NodeUnitInspectCommand 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 NodeUnitInspectCall call;
+
+    @Override
+    public Integer call() throws Exception {
+        return runPipeline(CallExecutionPipeline.builder(call)
+                .inputProvider(() -> UnitInspectCallInput.builder()
+                        .unitId(unitId)
+                        .version(version)
+                        .url(nodeUrl.getNodeUrl())
+                        .build())
+                .decorator(new UnitInspectDecorator(plain))
+                
.exceptionHandler(ClusterNotInitializedExceptionHandler.createHandler("Cannot 
inspect unit"))
+        );
+    }
+}
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectReplCommand.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectReplCommand.java
new file mode 100644
index 00000000000..b0be2291d7e
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectReplCommand.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.NodeUnitInspectCall;
+import org.apache.ignite.internal.cli.call.unit.UnitInspectCallInput;
+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.UnitInspectDecorator;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/** Command to inspect deployment unit in REPL mode. */
+@Command(name = "inspect", description = "Inspects the structure of a deployed 
unit")
+public class NodeUnitInspectReplCommand 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 NodeUnitInspectCall call;
+
+    @Inject
+    private ConnectToClusterQuestion question;
+
+    @Override
+    public void run() {
+        runFlow(question.askQuestionIfNotConnected(nodeUrl.getNodeUrl())
+                .map(url -> UnitInspectCallInput.builder()
+                        .unitId(unitId)
+                        .version(version)
+                        .url(url)
+                        .build())
+                .then(Flows.fromCall(call))
+                
.exceptionHandler(ClusterNotInitializedExceptionHandler.createReplHandler("Cannot
 inspect unit"))
+                .print(new UnitInspectDecorator(plain))
+        );
+    }
+}
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..016e44636ce 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,8 @@ 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, 
NodeUnitInspectReplCommand.class},
+        description = "Manages deployment units")
 public class NodeUnitReplCommand extends BaseCommand {
 }
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecorator.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecorator.java
new file mode 100644
index 00000000000..6a5559b3d58
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecorator.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 inspect result as a tree. */
+public class UnitInspectDecorator 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 UnitInspectDecorator(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())
+                        .append(" (").append(readableSize(file.getSize(), 
false)).append(')')
+                        .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())
+                        .append(' ').append(file.getSize())
+                        .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/NodeUnitInspectCallTest.java
 
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCallTest.java
new file mode 100644
index 00000000000..5d6fab0a2f8
--- /dev/null
+++ 
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCallTest.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.UnitInspectCallInput;
+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 NodeUnitInspectCallTest {
+
+    private String url;
+
+    @Inject
+    private NodeUnitInspectCall 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
+        UnitInspectCallInput input = UnitInspectCallInput.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
+        UnitInspectCallInput input = UnitInspectCallInput.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
+        UnitInspectCallInput input = UnitInspectCallInput.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/UnitInspectDecoratorTest.java
 
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecoratorTest.java
new file mode 100644
index 00000000000..f9c302abda8
--- /dev/null
+++ 
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecoratorTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.is;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+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 UnitInspectDecoratorTest {
+
+    @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));
+
+        UnitInspectDecorator decorator = new UnitInspectDecorator(false);
+
+        // When
+        String output = decorator.decorate(folder).toTerminalString();
+
+        // Then
+        String expected = "test-unit\n"
+                + "+-- file1.txt (100 B)\n"
+                + "\\-- file2.txt (200 B)\n";
+        assertThat(output, is(expected));
+    }
+
+    @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));
+
+        UnitInspectDecorator decorator = new UnitInspectDecorator(false);
+
+        // When
+        String output = decorator.decorate(rootFolder).toTerminalString();
+
+        // Then
+        String expected = "root\n"
+                + "\\-- subfolder\n"
+                + "    \\-- nested.txt (50 B)\n";
+        assertThat(output, is(expected));
+    }
+
+    @Test
+    @DisplayName("Plain view should display file paths")
+    void plainViewWithFiles() {
+        // Given
+        UnitFolder folder = createFolderWithFiles("test-unit",
+                createFile("file1.txt", 100),
+                createFile("file2.txt", 200));
+
+        UnitInspectDecorator decorator = new UnitInspectDecorator(true);
+
+        // When
+        String output = decorator.decorate(folder).toTerminalString();
+
+        // Then
+        String expected = "file1.txt 100\n"
+                + "file2.txt 200\n";
+        assertThat(output, is(expected));
+    }
+
+    @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));
+
+        UnitInspectDecorator decorator = new UnitInspectDecorator(true);
+
+        // When
+        String output = decorator.decorate(rootFolder).toTerminalString();
+
+        // Then
+        String expected = "subfolder/nested.txt 50\n";
+        assertThat(output, is(expected));
+    }
+
+    @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));
+
+        UnitInspectDecorator decorator = new UnitInspectDecorator(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());
+
+        UnitInspectDecorator decorator = new UnitInspectDecorator(false);
+
+        // When
+        String output = decorator.decorate(folder).toTerminalString();
+
+        // Then
+        assertThat(output, is("empty-folder\n"));
+    }
+
+    private static UnitFolder createFolderWithFiles(String name, UnitFile... 
files) {
+        List<UnitEntry> children = 
Arrays.stream(files).map(UnitEntry::new).collect(Collectors.toList());
+
+        return new UnitFolder()
+                .type(UnitFolder.TypeEnum.FOLDER)
+                .name(name)
+                .children(children);
+    }
+
+    private static UnitFile createFile(String name, long size) {
+        return new UnitFile()
+                .type(UnitFile.TypeEnum.FILE)
+                .name(name)
+                .size(size);
+    }
+}
diff --git a/modules/rest-api/build.gradle b/modules/rest-api/build.gradle
index e6264a7d023..abc7b98b935 100644
--- a/modules/rest-api/build.gradle
+++ b/modules/rest-api/build.gradle
@@ -26,6 +26,8 @@ dependencies {
     annotationProcessor libs.micronaut.inject.annotation.processor
     annotationProcessor libs.micronaut.openapi
 
+    compileOnly libs.spotbugs.annotations
+
     implementation project(':ignite-api')
     implementation project(':ignite-core')
     implementation project(':ignite-configuration-api')
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..932b14a7578 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
@@ -19,10 +19,12 @@ package org.apache.ignite.internal.rest.api.deployment;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonGetter;
+import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.annotation.JsonSubTypes;
 import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
 import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
 import java.util.List;
@@ -39,7 +41,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 +80,12 @@ 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 {
+        @SuppressFBWarnings(value = "SS_SHOULD_BE_STATIC", justification = 
"Instance field required for JSON serialization")
+        @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 +107,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 +150,23 @@ 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 {
+        @SuppressFBWarnings(value = "SS_SHOULD_BE_STATIC", justification = 
"Instance field required for JSON serialization")
+        @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)
+        @JsonInclude(JsonInclude.Include.ALWAYS)
         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 +180,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 +211,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