Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 310 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc -b --emitDeclarationOnly false --noEmit",
Expand All @@ -31,6 +33,9 @@
]
},
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
Expand All @@ -39,6 +44,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@selfpatch/ros2-medkit-client-ts": "^0.6.0",
"@tailwindcss/vite": "^4.1.14",
"@uiw/react-codemirror": "^4.25.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
Expand All @@ -53,6 +59,7 @@
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@playwright/test": "^1.62.0",
"@tailwindcss/postcss": "^4.1.14",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
Expand Down
19 changes: 9 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,19 @@ function App() {
}
}, [selectedPath]);

// Auto-connect on mount if we have a stored URL (once only)
// Auto-connect on mount if we have a stored URL (once only). The ref guard
// alone survives React Strict Mode's mount-cleanup-remount cycle in dev;
// deferring the call via setTimeout previously let the Strict Mode cleanup
// cancel it before it ever fired, so connect() was never actually called.
useEffect(() => {
if (!serverUrl || isConnected || autoConnectAttempted.current) return;
autoConnectAttempted.current = true;

const timeoutId = setTimeout(() => {
connect(serverUrl).then((success) => {
if (!success) {
setShowConnectionDialog(true);
}
});
}, 0);

return () => clearTimeout(timeoutId);
connect(serverUrl).then((success) => {
if (!success) {
setShowConnectionDialog(true);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Expand Down
76 changes: 76 additions & 0 deletions src/components/AppsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// 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 userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@/components/ui/tooltip';
import { AppsPanel } from './AppsPanel';

vi.mock('@/components/ScriptsPanel', () => ({
ScriptsPanel: ({ entityId, entityType }: { entityId: string; entityType: string }) => (
<div data-testid="scripts-panel">{`${entityType}:${entityId}`}</div>
),
}));

const mockState = {
selectEntity: vi.fn(),
configurations: new Map<string, unknown[]>(),
fetchEntityData: vi.fn().mockResolvedValue([]),
fetchEntityOperations: vi.fn().mockResolvedValue([]),
listEntityFaults: vi.fn().mockResolvedValue({ items: [], count: 0 }),
scriptsSupported: false,
// EntityStatusControl reads these from the store; inert values keep the
// control mounted without reaching the network.
client: null,
statusByEntity: {},
actuationByEntity: {},
watchEntityStatus: vi.fn(() => () => {}),
};

vi.mock('@/lib/store', () => ({
useAppStore: vi.fn((selector) => selector(mockState)),
entityStatusKey: (entityType: string, entityId: string) => `${entityType}:${entityId}`,
}));

function renderAppsPanel(overrides: Partial<typeof mockState> = {}) {
Object.assign(mockState, { scriptsSupported: false }, overrides);
// App.tsx wraps the tree in a TooltipProvider, and the panel renders a
// tooltip for any lifecycle action the current status does not allow.
return render(
<TooltipProvider>
<AppsPanel appId="talker" appName="Talker" path="/server/ecu/talker" />
</TooltipProvider>
);
}

describe('AppsPanel scripts tab', () => {
beforeEach(() => vi.clearAllMocks());

it('hides the Scripts tab when the gateway does not report the capability', async () => {
renderAppsPanel({ scriptsSupported: false });
// Let the mount-time loadAppData effect settle before asserting, so its
// state updates don't land after the test body returns (act() warning).
await waitFor(() => {
expect(screen.queryByText(/Loading app resources/i)).not.toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /scripts/i })).not.toBeInTheDocument();
});

it('shows the Scripts tab and renders its content when the capability is reported', async () => {
renderAppsPanel({ scriptsSupported: true });
await userEvent.click(screen.getByRole('button', { name: /scripts/i }));
expect(screen.getByTestId('scripts-panel')).toHaveTextContent('apps:talker');
});
});
36 changes: 25 additions & 11 deletions src/components/AppsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useShallow } from 'zustand/shallow';
import { AlertTriangle, Box, ChevronRight, Cpu, Database, FileCode, Network, Settings, Zap } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
Expand All @@ -9,6 +9,7 @@ import {
RESOURCE_TABS,
renderResourceTabContent,
isResourceTabId,
SCRIPTS_TAB,
type ResourceTabId,
} from '@/components/ResourceTabs';
import { EntityStatusControl } from '@/components/EntityStatusControl';
Expand All @@ -22,7 +23,7 @@ interface TabConfig {
icon: typeof Database;
}

const APP_TABS: TabConfig[] = [{ id: 'overview', label: 'Overview', icon: Cpu }, ...RESOURCE_TABS];
const BASE_APP_TABS: TabConfig[] = [{ id: 'overview', label: 'Overview', icon: Cpu }, ...RESOURCE_TABS];

interface AppsPanelProps {
appId: string;
Expand Down Expand Up @@ -51,16 +52,29 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI
const [faults, setFaults] = useState<Fault[]>([]);
const [isLoading, setIsLoading] = useState(false);

const { selectEntity, configurations, fetchEntityData, fetchEntityOperations, listEntityFaults } = useAppStore(
useShallow((state) => ({
selectEntity: state.selectEntity,
configurations: state.configurations,
fetchEntityData: state.fetchEntityData,
fetchEntityOperations: state.fetchEntityOperations,
listEntityFaults: state.listEntityFaults,
}))
const { selectEntity, configurations, fetchEntityData, fetchEntityOperations, listEntityFaults, scriptsSupported } =
useAppStore(
useShallow((state) => ({
selectEntity: state.selectEntity,
configurations: state.configurations,
fetchEntityData: state.fetchEntityData,
fetchEntityOperations: state.fetchEntityOperations,
listEntityFaults: state.listEntityFaults,
scriptsSupported: state.scriptsSupported,
}))
);

const appTabs = useMemo(
() => (scriptsSupported ? [...BASE_APP_TABS, SCRIPTS_TAB] : BASE_APP_TABS),
[scriptsSupported]
);

// Fall back to the default tab when the Scripts tab disappears (e.g. the
// gateway capability flips off) while it is the active tab.
useEffect(() => {
if (!scriptsSupported && activeTab === 'scripts') setActiveTab('overview');
}, [scriptsSupported, activeTab]);

// Load app resources on mount (configurations are loaded by ConfigurationPanel)
useEffect(() => {
const loadAppData = async () => {
Expand Down Expand Up @@ -147,7 +161,7 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI
{/* Tab Navigation */}
<div className="px-6 pb-4">
<div className="flex gap-1 p-1 bg-muted rounded-lg overflow-x-auto">
{APP_TABS.map((tab) => {
{appTabs.map((tab) => {
const TabIcon = tab.icon;
const isActive = activeTab === tab.id;
let count = 0;
Expand Down
62 changes: 61 additions & 1 deletion src/components/EntityDetailPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { TooltipProvider } from '@/components/ui/tooltip';
import { EntityDetailPanel } from './EntityDetailPanel';

Expand All @@ -37,6 +37,11 @@ vi.mock('@/components/ResourceTabs', async () => {
renderResourceTabContent: (tab: string) => <div data-testid={`tab-content-${tab}`} />,
};
});
vi.mock('@/components/ScriptsPanel', () => ({
ScriptsPanel: ({ entityId, entityType }: { entityId: string; entityType: string }) => (
<div data-testid="scripts-panel">{`${entityType}:${entityId}`}</div>
),
}));

const mockPrefetchResourceCounts = vi.fn();
const mockFetchEntityData = vi.fn();
Expand Down Expand Up @@ -73,6 +78,7 @@ function setStore(overrides: Record<string, unknown>) {
statusByEntity: {},
actuationByEntity: {},
watchEntityStatus: vi.fn(() => () => {}),
scriptsSupported: false,
...overrides,
};
}
Expand Down Expand Up @@ -151,3 +157,57 @@ describe('EntityDetailPanel - nested entity types', () => {
expect(screen.queryByText(/No detailed information available/i)).not.toBeInTheDocument();
});
});

describe('EntityDetailPanel - scripts tab gating (component view)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockPrefetchResourceCounts.mockResolvedValue({ data: 0, operations: 0, configurations: 0, faults: 0, logs: 0 });
mockFetchEntityData.mockResolvedValue([]);
});

it('hides the Scripts tab when the gateway does not report the capability', async () => {
setStore({
selectedPath: '/server/area1/component1',
selectedEntity: {
id: 'component1',
name: 'component1',
type: 'component',
},
scriptsSupported: false,
});

render(
<TooltipProvider>
<EntityDetailPanel onConnectClick={() => {}} />
</TooltipProvider>
);

await waitFor(() => {
expect(screen.getByRole('button', { name: /Data/ })).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /scripts/i })).not.toBeInTheDocument();
});

it('shows the Scripts tab and renders its content when the capability is reported', async () => {
setStore({
selectedPath: '/server/area1/component1',
selectedEntity: {
id: 'component1',
name: 'component1',
type: 'component',
},
scriptsSupported: true,
});

render(
<TooltipProvider>
<EntityDetailPanel onConnectClick={() => {}} />
</TooltipProvider>
);

const scriptsButton = await screen.findByRole('button', { name: /scripts/i });
fireEvent.click(scriptsButton);

expect(await screen.findByTestId('tab-content-scripts')).toBeInTheDocument();
});
});
25 changes: 20 additions & 5 deletions src/components/EntityDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useShallow } from 'zustand/shallow';
import {
Copy,
Expand All @@ -24,7 +24,7 @@ import { EntityDetailSkeleton } from '@/components/EntityDetailSkeleton';
import { DataPanel } from '@/components/DataPanel';
import { ConfigurationPanel } from '@/components/ConfigurationPanel';
import { OperationsPanel } from '@/components/OperationsPanel';
import { RESOURCE_TABS, renderResourceTabContent, type ResourceTabId } from '@/components/ResourceTabs';
import { RESOURCE_TABS, renderResourceTabContent, SCRIPTS_TAB, type ResourceTabId } from '@/components/ResourceTabs';
import { AreasPanel } from '@/components/AreasPanel';
import { AppsPanel } from '@/components/AppsPanel';
import { FunctionsPanel } from '@/components/FunctionsPanel';
Expand All @@ -44,7 +44,7 @@ interface TabConfig {
description?: string;
}

const COMPONENT_TABS: TabConfig[] = RESOURCE_TABS;
const BASE_COMPONENT_TABS: TabConfig[] = RESOURCE_TABS;

/**
* Determine entity type for API calls based on entity type
Expand Down Expand Up @@ -377,6 +377,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
configurations: 0,
faults: 0,
logs: 0,
scripts: 0,
});
// Store fetched topics data for the Data tab. `null` means "not yet loaded
// for the current entity" so the Data tab can render a skeleton instead of
Expand All @@ -395,6 +396,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
refreshSelectedEntity,
prefetchResourceCounts,
fetchEntityData,
scriptsSupported,
} = useAppStore(
useShallow((state: AppState) => ({
selectedPath: state.selectedPath,
Expand All @@ -407,16 +409,28 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
refreshSelectedEntity: state.refreshSelectedEntity,
prefetchResourceCounts: state.prefetchResourceCounts,
fetchEntityData: state.fetchEntityData,
scriptsSupported: state.scriptsSupported,
}))
);

const componentTabs = useMemo(
() => (scriptsSupported ? [...BASE_COMPONENT_TABS, SCRIPTS_TAB] : BASE_COMPONENT_TABS),
[scriptsSupported]
);

// Notify parent when entity is selected
useEffect(() => {
if (selectedPath && onEntitySelect) {
onEntitySelect();
}
}, [selectedPath, onEntitySelect]);

// Fall back to the default tab when the Scripts tab disappears (e.g. the
// gateway capability flips off) while it is the active tab.
useEffect(() => {
if (!scriptsSupported && activeTab === 'scripts') setActiveTab('data');
}, [scriptsSupported, activeTab]);

// Reset the component-view resource tab to Data when the entity changes,
// so switching between components doesn't show stale tab state.
useEffect(() => {
Expand All @@ -431,6 +445,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
configurations: 0,
faults: 0,
logs: 0,
scripts: 0,
};
// Guard against late results from a previous entity overwriting the
// current entity's state. The cleanup aborts in-flight requests AND
Expand Down Expand Up @@ -483,7 +498,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
setTopicsData(fetchedData);

// Use the already-fetched data length instead of a separate request
setResourceCounts({ ...counts, data: fetchedData.length, logs: 0 });
setResourceCounts({ ...counts, data: fetchedData.length, logs: 0, scripts: 0 });
} catch {
if (cancelled) return;
// On unexpected failure fall back to "loaded empty" so the UI
Expand Down Expand Up @@ -821,7 +836,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
{isComponent && (
<div className="px-6 pb-4">
<div className="flex gap-1 p-1 bg-muted rounded-lg">
{COMPONENT_TABS.map((tab) => {
{componentTabs.map((tab) => {
const TabIcon = tab.icon;
const isActive = activeTab === tab.id;
const count = resourceCounts[tab.id];
Expand Down
Loading
Loading