diff --git a/src/components/EntityDetailPanel.test.tsx b/src/components/EntityDetailPanel.test.tsx
new file mode 100644
index 0000000..4ed4dc4
--- /dev/null
+++ b/src/components/EntityDetailPanel.test.tsx
@@ -0,0 +1,131 @@
+// Copyright 2026 bburda
+//
+// Licensed 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.
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { EntityDetailPanel } from './EntityDetailPanel';
+
+// Mock heavy child components - we only care about the top-level routing
+// (header + tab bar + tab content selection) for these tests.
+vi.mock('@/components/DataPanel', () => ({ DataPanel: () =>
}));
+vi.mock('@/components/ConfigurationPanel', () => ({ ConfigurationPanel: () => }));
+vi.mock('@/components/OperationsPanel', () => ({ OperationsPanel: () => }));
+vi.mock('@/components/AreasPanel', () => ({ AreasPanel: () => }));
+vi.mock('@/components/AppsPanel', () => ({ AppsPanel: () => }));
+vi.mock('@/components/FunctionsPanel', () => ({ FunctionsPanel: () => }));
+vi.mock('@/components/ServerInfoPanel', () => ({ ServerInfoPanel: () => }));
+vi.mock('@/components/FaultsDashboard', () => ({ FaultsDashboard: () => }));
+vi.mock('@/components/UpdatesDashboard', () => ({ UpdatesDashboard: () => }));
+vi.mock('@/components/EmptyState', () => ({ EmptyState: () => }));
+vi.mock('@/components/EntityDetailSkeleton', () => ({ EntityDetailSkeleton: () => }));
+vi.mock('@/components/ResourceTabs', async () => {
+ const actual = await vi.importActual('@/components/ResourceTabs');
+ return {
+ ...actual,
+ renderResourceTabContent: (tab: string) => ,
+ };
+});
+
+const mockPrefetchResourceCounts = vi.fn();
+const mockFetchEntityData = vi.fn();
+const mockSelectEntity = vi.fn();
+const mockRefreshSelectedEntity = vi.fn();
+
+let storeState: Record = {};
+
+vi.mock('@/lib/store', () => ({
+ useAppStore: vi.fn((selector: (s: Record) => unknown) => selector(storeState)),
+ // The breadcrumb builder resolves segment types via findNode; these tests
+ // don't load a tree, so it always falls back to position-based inference.
+ findNode: () => null,
+}));
+
+function setStore(overrides: Record) {
+ storeState = {
+ selectedPath: null,
+ selectedEntity: null,
+ rootEntities: [],
+ isLoadingDetails: false,
+ isRefreshing: false,
+ isConnected: true,
+ selectEntity: mockSelectEntity,
+ refreshSelectedEntity: mockRefreshSelectedEntity,
+ prefetchResourceCounts: mockPrefetchResourceCounts,
+ fetchEntityData: mockFetchEntityData,
+ ...overrides,
+ };
+}
+
+describe('EntityDetailPanel - nested entity types', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockPrefetchResourceCounts.mockResolvedValue({ data: 0, operations: 0, configurations: 0, faults: 0, logs: 0 });
+ mockFetchEntityData.mockResolvedValue([]);
+ });
+
+ it('renders resource tabs and fetches counts as components for subcomponent entity', async () => {
+ setStore({
+ selectedPath: '/server/area1/component1/planning-ecu',
+ selectedEntity: {
+ id: 'planning-ecu',
+ name: 'planning-ecu',
+ type: 'subcomponent',
+ },
+ });
+
+ render( {}} />);
+
+ // Bug repro: subcomponent should fetch resource counts using the
+ // 'components' entity type (gateway routes subcomponents through
+ // /api/v1/components/{id}/...).
+ await waitFor(() => {
+ expect(mockPrefetchResourceCounts).toHaveBeenCalledWith('components', 'planning-ecu', expect.anything());
+ });
+
+ // Bug repro: subcomponent should render the resource tab bar
+ // (Data / Operations / Config / Faults / Logs) just like a component.
+ expect(screen.getByRole('button', { name: /Data/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Operations/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Config/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Faults/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Logs/ })).toBeInTheDocument();
+
+ // The fallback "No detailed information available" must not appear.
+ expect(screen.queryByText(/No detailed information available/i)).not.toBeInTheDocument();
+ });
+
+ it('renders the area panel and fetches counts as areas for subarea entity', async () => {
+ setStore({
+ selectedPath: '/server/area1/zone-a',
+ selectedEntity: {
+ id: 'zone-a',
+ name: 'zone-a',
+ type: 'subarea',
+ },
+ });
+
+ render( {}} />);
+
+ // Bug repro: subarea should fetch resource counts using the 'areas'
+ // entity type (gateway routes subareas through /api/v1/areas/{id}/...).
+ await waitFor(() => {
+ expect(mockPrefetchResourceCounts).toHaveBeenCalledWith('areas', 'zone-a', expect.anything());
+ });
+
+ // Bug repro: subarea should render the area panel just like an area,
+ // not the "No detailed information available" fallback.
+ expect(screen.getByTestId('areas-panel')).toBeInTheDocument();
+ expect(screen.queryByText(/No detailed information available/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx
index 12baed2..ca03e14 100644
--- a/src/components/EntityDetailPanel.tsx
+++ b/src/components/EntityDetailPanel.tsx
@@ -31,7 +31,7 @@ import { FunctionsPanel } from '@/components/FunctionsPanel';
import { ServerInfoPanel } from '@/components/ServerInfoPanel';
import { FaultsDashboard } from '@/components/FaultsDashboard';
import { UpdatesDashboard } from '@/components/UpdatesDashboard';
-import { useAppStore, type AppState } from '@/lib/store';
+import { useAppStore, findNode, type AppState } from '@/lib/store';
import type { ComponentTopic, Parameter, SovdResourceEntityType } from '@/lib/types';
type ComponentTab = ResourceTabId;
@@ -53,10 +53,12 @@ function getEntityTypeForApi(entityType: string | undefined): SovdResourceEntity
case 'app':
return 'apps';
case 'component':
+ case 'subcomponent':
return 'components';
case 'function':
return 'functions';
case 'area':
+ case 'subarea':
return 'areas';
default:
return 'components'; // default fallback
@@ -71,8 +73,10 @@ function getBreadcrumbIcon(type: string) {
case 'server':
return ;
case 'area':
+ case 'subarea':
return ;
case 'component':
+ case 'subcomponent':
return ;
case 'app':
return ;
@@ -382,6 +386,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
const {
selectedPath,
selectedEntity,
+ rootEntities,
isLoadingDetails,
isRefreshing,
isConnected,
@@ -393,6 +398,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
useShallow((state: AppState) => ({
selectedPath: state.selectedPath,
selectedEntity: state.selectedEntity,
+ rootEntities: state.rootEntities,
isLoadingDetails: state.isLoadingDetails,
isRefreshing: state.isRefreshing,
isConnected: state.isConnected,
@@ -444,9 +450,9 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
}
const entityId = selectedEntity.id;
- const isComponent = selectedEntity.type === 'component';
+ const isComponent = selectedEntity.type === 'component' || selectedEntity.type === 'subcomponent';
const isApp = selectedEntity.type === 'app';
- const isArea = selectedEntity.type === 'area';
+ const isArea = selectedEntity.type === 'area' || selectedEntity.type === 'subarea';
const isFunction = selectedEntity.type === 'function';
// Only fetch counts for entity types that have resources
@@ -554,8 +560,8 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
// Entity detail view
if (selectedEntity) {
const isTopic = selectedEntity.type === 'topic';
- const isComponent = selectedEntity.type === 'component';
- const isArea = selectedEntity.type === 'area';
+ const isComponent = selectedEntity.type === 'component' || selectedEntity.type === 'subcomponent';
+ const isArea = selectedEntity.type === 'area' || selectedEntity.type === 'subarea';
const isApp = selectedEntity.type === 'app';
const isFunction = selectedEntity.type === 'function';
const isServer = selectedEntity.type === 'server';
@@ -626,8 +632,10 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
case 'server':
return ;
case 'area':
+ case 'subarea':
return ;
case 'component':
+ case 'subcomponent':
return ;
case 'app':
return ;
@@ -644,8 +652,10 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
case 'server':
return 'bg-primary/10';
case 'area':
+ case 'subarea':
return 'bg-cyan-100 dark:bg-cyan-900';
case 'component':
+ case 'subcomponent':
return 'bg-indigo-100 dark:bg-indigo-900';
case 'app':
return 'bg-emerald-100 dark:bg-emerald-900';
@@ -656,23 +666,30 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
}
};
- // Build breadcrumb from path with type inference
+ // Build breadcrumb from path. Prefer the real entity type from the
+ // loaded tree so nested types (subarea / subcomponent) and deeper
+ // hierarchies get the correct icon; fall back to position-based
+ // inference for segments not present in the tree.
const breadcrumbs = pathParts.map((part, index) => {
const breadcrumbPath = '/' + pathParts.slice(0, index + 1).join('/');
// Decode URL-encoded parts for display
const decodedPart = decodeURIComponent(part);
- // Infer type from path position: server -> area -> component -> app/folder
let type: string;
if (part === 'server') {
type = 'server';
- } else if (index === 1) {
- type = 'area';
- } else if (index === 2) {
- type = 'component';
} else if (['data', 'operations', 'configurations', 'faults', 'resources'].includes(part)) {
type = 'folder';
} else {
- type = 'app';
+ const node = findNode(rootEntities, breadcrumbPath);
+ if (node) {
+ type = node.type;
+ } else if (index === 1) {
+ type = 'area';
+ } else if (index === 2) {
+ type = 'component';
+ } else {
+ type = 'app';
+ }
}
return {
label: decodedPart,
diff --git a/src/components/SearchCommand.tsx b/src/components/SearchCommand.tsx
index 994ee4e..e04efe5 100644
--- a/src/components/SearchCommand.tsx
+++ b/src/components/SearchCommand.tsx
@@ -39,8 +39,10 @@ function flattenTree(nodes: EntityTreeNode[]): EntityTreeNode[] {
function getEntityIcon(type: string) {
switch (type) {
case 'area':
+ case 'subarea':
return Layers;
case 'component':
+ case 'subcomponent':
return Box;
case 'app':
return Cpu;
@@ -57,8 +59,10 @@ function getEntityIcon(type: string) {
function getEntityColorClass(type: string): string {
switch (type) {
case 'area':
+ case 'subarea':
return 'text-cyan-500';
case 'component':
+ case 'subcomponent':
return 'text-indigo-500';
case 'app':
return 'text-emerald-500';
@@ -154,7 +158,7 @@ export function SearchCommand({ open, onOpenChange }: SearchCommandProps) {
No entities found.
{/* Group by entity type */}
- {['area', 'component', 'app', 'function'].map((type) => {
+ {['area', 'subarea', 'component', 'subcomponent', 'app', 'function'].map((type) => {
const typeEntities = filteredEntities.filter((e) => e.type === type);
if (typeEntities.length === 0) return null;