Skip to content
Open
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ Simple, open-source web UI for browsing SOVD (Service-Oriented Vehicle Diagnosti
ros2_medkit_web_ui is a lightweight single-page application that connects to a SOVD server and visualizes the entity hierarchy. It provides:

- **Server Connection Dialog** - Enter the URL of your SOVD server (supports both `http://ip:port` and `ip:port` formats)
- **Entity Tree Sidebar** - Browse the hierarchical structure of SOVD entities with lazy-loading
- **Entity Tree Sidebar** - Browse the hierarchical structure of SOVD entities with lazy-loading, with a readiness lamp on app and component nodes (green = ready, amber = not ready)
- **Entity Detail Panel** - View raw JSON details of any selected entity
- **Entity Lifecycle Status Control** - View readiness and request lifecycle transitions (start, restart, force-restart, shutdown, force-shutdown) for apps and components, degrading gracefully when no lifecycle provider is configured. Actions are gated by the current status (unavailable transitions are disabled with an explanatory tooltip), and every destructive transition (all but Start) asks for confirmation before dispatch

This tool is designed for developers and integrators working with SOVD-compatible systems who need a quick way to explore and debug the entity structure.

Expand Down
6 changes: 6 additions & 0 deletions src/components/AppsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isResourceTabId,
type ResourceTabId,
} from '@/components/ResourceTabs';
import { EntityStatusControl } from '@/components/EntityStatusControl';
import type { ComponentTopic, Operation, Fault } from '@/lib/types';

type AppTab = 'overview' | ResourceTabId;
Expand Down Expand Up @@ -136,6 +137,11 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI
</CardDescription>
</div>
</div>

{/* Lifecycle status control (gateway 0.6.0 lifecycle API) */}
<div className="mt-4">
<EntityStatusControl entityType="apps" entityId={appId} />
</div>
Comment on lines +141 to +144
</CardHeader>

{/* Tab Navigation */}
Expand Down
8 changes: 8 additions & 0 deletions src/components/EntityDetailPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ vi.mock('@/lib/store', () => ({
// 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,
// EntityStatusControl (rendered for component/subcomponent entities) reads
// the status cache keyed by entityStatusKey.
entityStatusKey: (entityType: string, entityId: string) => `${entityType}:${entityId}`,
}));

function setStore(overrides: Record<string, unknown>) {
Expand All @@ -63,6 +66,11 @@ function setStore(overrides: Record<string, unknown>) {
refreshSelectedEntity: mockRefreshSelectedEntity,
prefetchResourceCounts: mockPrefetchResourceCounts,
fetchEntityData: mockFetchEntityData,
// EntityStatusControl reads these from the store; provide inert values
// so the rendered control mounts without touching the network.
client: null,
statusByEntity: {},
fetchEntityStatus: vi.fn(),
...overrides,
};
}
Expand Down
11 changes: 10 additions & 1 deletion src/components/EntityDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { FunctionsPanel } from '@/components/FunctionsPanel';
import { ServerInfoPanel } from '@/components/ServerInfoPanel';
import { FaultsDashboard } from '@/components/FaultsDashboard';
import { UpdatesDashboard } from '@/components/UpdatesDashboard';
import { EntityStatusControl } from '@/components/EntityStatusControl';
import { useAppStore, findNode, type AppState } from '@/lib/store';
import type { ComponentTopic, Parameter, SovdResourceEntityType } from '@/lib/types';

Expand Down Expand Up @@ -777,7 +778,9 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
{getEntityTypeIcon()}
</div>
<div>
<CardTitle className="text-xl">{selectedEntity.name}</CardTitle>
<CardTitle className="text-xl">
{selectedEntity.description || selectedEntity.name}
</CardTitle>
<CardDescription className="flex items-center gap-2">
<Badge variant="outline">{selectedEntity.type}</Badge>
<span className="text-muted-foreground">•</span>
Expand All @@ -803,6 +806,12 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
</Button>
</div>
</div>
{/* Lifecycle status control (gateway 0.6.0 lifecycle API) */}
{isComponent && (
<div className="mt-4">
<EntityStatusControl entityType="components" entityId={entityId} />
</div>
)}
</CardHeader>

{/* Tab Navigation for Components */}
Expand Down
264 changes: 264 additions & 0 deletions src/components/EntityStatusControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
// 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, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@/components/ui/tooltip';

// ---------------------------------------------------------------------------
// Mocks
//
// Status is read from the real store slice (statusByEntity), seeded per-test.
// Only setStatus (the transition dispatch) is mocked; the rest of api-dispatch
// stays real so the store module loads. fetchEntityStatus is seeded as a no-op
// vi.fn() in every test so the on-mount fetch does not overwrite the seeded
// status with 'unknown' against the fake client.
// ---------------------------------------------------------------------------

const mockSetStatus = vi.fn();

vi.mock('@/lib/api-dispatch', async (importActual) => {
const actual = await importActual<typeof import('@/lib/api-dispatch')>();
return {
...actual,
setStatus: (...args: unknown[]) => mockSetStatus(...args),
};
});

vi.mock('react-toastify', () => ({
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
}));

import { toast } from 'react-toastify';
import { useAppStore } from '@/lib/store';
import { EntityStatusControl } from './EntityStatusControl';

const fakeClient = { __fake: true } as never;

/** Build an openapi-fetch style result. */
function ok(status: number, data: unknown = undefined) {
return { data, error: undefined, response: { status } as Response };
}

function errResult(status: number, message: string) {
return { data: undefined, error: { message }, response: { status } as Response };
}

/**
* Seed the store with a cached status and a no-op fetchEntityStatus, plus the
* fake client used by setStatus dispatch.
*/
function seedStatus(key: string, value: string) {
useAppStore.setState({
statusByEntity: { [key]: value as never },
fetchEntityStatus: vi.fn(),
client: fakeClient,
});
}

const renderControl = (ui: React.ReactElement) => render(<TooltipProvider>{ui}</TooltipProvider>);

describe('EntityStatusControl', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSetStatus.mockResolvedValue(ok(204));
useAppStore.setState({
statusByEntity: {},
fetchEntityStatus: vi.fn(),
client: fakeClient,
actuationSupported: null,
});
});

afterEach(() => {
cleanup();
});

// -----------------------------------------------------------------------
// Migrated baseline coverage (now driven by the store slice)
// -----------------------------------------------------------------------

it('renders the current status badge from the cached status', async () => {
seedStatus('apps:motor', 'ready');
renderControl(<EntityStatusControl entityType="apps" entityId="motor" />);
expect(await screen.findByText(/^ready$/i)).toBeInTheDocument();
});

it('renders an action button for each lifecycle action', () => {
seedStatus('apps:motor', 'ready');
renderControl(<EntityStatusControl entityType="apps" entityId="motor" />);
expect(screen.getByRole('button', { name: /^start$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^restart$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /force restart/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^shutdown$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /force shutdown/i })).toBeInTheDocument();
});

it('calls setStatus with client, entityType, entityId and action on confirmed restart', async () => {
const user = userEvent.setup();
// ready leaves Restart enabled.
seedStatus('components:host-1', 'ready');
renderControl(<EntityStatusControl entityType="components" entityId="host-1" />);

await user.click(screen.getByRole('button', { name: /^restart$/i }));
await user.click(await screen.findByRole('button', { name: /confirm/i }));

await waitFor(() => expect(mockSetStatus).toHaveBeenCalledTimes(1));
const call = mockSetStatus.mock.calls[0]!;
expect(call[0]).toBe(fakeClient);
expect(call[1]).toBe('components');
expect(call[2]).toBe('host-1');
expect(call[3]).toBe('restart');
});

it('refreshes the status after a successful confirmed action', async () => {
const user = userEvent.setup();
const refresh = vi.fn();
useAppStore.setState({
statusByEntity: { 'apps:motor': 'ready' },
fetchEntityStatus: refresh,
client: fakeClient,
});
renderControl(<EntityStatusControl entityType="apps" entityId="motor" />);

// Mount effect calls fetchEntityStatus once.
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));

await user.click(screen.getByRole('button', { name: /^shutdown$/i }));
await user.click(await screen.findByRole('button', { name: /confirm/i }));

// The post-dispatch refresh calls fetchEntityStatus again.
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2));
});

it('shows a disabled "not available" state when status is unavailable (501)', async () => {
// The gateway 501 maps to the cached value 'unavailable' in the store.
seedStatus('apps:motor', 'unavailable');
renderControl(<EntityStatusControl entityType="apps" entityId="motor" />);

expect(await screen.findByText(/not available/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^start$/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /^restart$/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /^shutdown$/i })).toBeDisabled();
});

it('shows the "not available" state when the cached status is unavailable for components', async () => {
seedStatus('components:host-1', 'unavailable');
renderControl(<EntityStatusControl entityType="components" entityId="host-1" />);
expect(await screen.findByText(/not available/i)).toBeInTheDocument();
});

it('surfaces a non-501 error from setStatus inline and keeps the action enabled', async () => {
const user = userEvent.setup();
mockSetStatus.mockResolvedValue(errResult(400, 'invalid transition'));
// start is enabled when notReady and dispatches immediately.
seedStatus('apps:motor', 'notReady');
renderControl(<EntityStatusControl entityType="apps" entityId="motor" />);

await user.click(screen.getByRole('button', { name: /^start$/i }));

expect(await screen.findByText(/invalid transition/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^start$/i })).not.toBeDisabled();
});

// -----------------------------------------------------------------------
// Task 2: gating by status (disable + tooltip)
// -----------------------------------------------------------------------

it('disables Start with a tooltip when status is ready', async () => {
seedStatus('components:host1', 'ready');
renderControl(<EntityStatusControl entityType="components" entityId="host1" />);
expect(await screen.findByRole('button', { name: /^start$/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /^restart$/i })).toBeEnabled();
});

it('disables Restart/Shutdown when status is notReady, keeps Start enabled', async () => {
seedStatus('apps:planner', 'notReady');
renderControl(<EntityStatusControl entityType="apps" entityId="planner" />);
expect(await screen.findByRole('button', { name: /^start/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /^restart/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /^shutdown/i })).toBeDisabled();
});

// -----------------------------------------------------------------------
// Task 3: confirmation dialog for non-Start actions
// -----------------------------------------------------------------------

it('Restart opens a confirm dialog and does not call setStatus until confirmed', async () => {
const user = userEvent.setup();
seedStatus('apps:planner', 'ready');
renderControl(<EntityStatusControl entityType="apps" entityId="planner" />);

await user.click(screen.getByRole('button', { name: /^restart$/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockSetStatus).not.toHaveBeenCalled();

await user.click(screen.getByRole('button', { name: /confirm/i }));
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith(fakeClient, 'apps', 'planner', 'restart'));
});

it('Start dispatches immediately with no dialog', async () => {
const user = userEvent.setup();
seedStatus('apps:planner', 'notReady');
renderControl(<EntityStatusControl entityType="apps" entityId="planner" />);

await user.click(screen.getByRole('button', { name: /^start$/i }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith(fakeClient, 'apps', 'planner', 'start'));
});

// -----------------------------------------------------------------------
// Task B: response-driven transition feedback (replaces the 501 no-op)
// -----------------------------------------------------------------------

it('501 transition warns "not implemented" and sets actuationSupported false', async () => {
seedStatus('apps:planner', 'notReady');
useAppStore.setState({ actuationSupported: null });
mockSetStatus.mockResolvedValue(errResult(501, 'no actuation provider'));
renderControl(<EntityStatusControl entityType="apps" entityId="planner" />);

await userEvent.click(screen.getByRole('button', { name: /^start/i }));

await waitFor(() => expect(toast.warning).toHaveBeenCalled());
expect(toast.error).not.toHaveBeenCalled();
expect(useAppStore.getState().actuationSupported).toBe(false);
});

it('2xx transition reports success and sets actuationSupported true', async () => {
seedStatus('apps:planner', 'notReady');
useAppStore.setState({ actuationSupported: null });
mockSetStatus.mockResolvedValue(ok(202));
renderControl(<EntityStatusControl entityType="apps" entityId="planner" />);

await userEvent.click(screen.getByRole('button', { name: /^start/i }));

await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(useAppStore.getState().actuationSupported).toBe(true);
});

// -----------------------------------------------------------------------
// Task C: disable + "not implemented" note when actuationSupported === false
// -----------------------------------------------------------------------

it('disables all transition buttons and shows a note when actuation is unsupported', async () => {
seedStatus('apps:planner', 'notReady');
useAppStore.setState({ actuationSupported: false });
renderControl(<EntityStatusControl entityType="apps" entityId="planner" />);

expect(await screen.findByRole('button', { name: /^start/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /^restart/i })).toBeDisabled();
expect(screen.getByText(/not implemented by this gateway/i)).toBeInTheDocument();
});
});
Loading
Loading