From 587444a83d63f251c046f6bd469397597cce7b13 Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 10:10:11 +0200
Subject: [PATCH 01/11] feat: add entity lifecycle status control for apps and
components
Add EntityStatusControl consuming the gateway 0.6.0 lifecycle API
(GET/PUT /{apps,components}/{id}/status). Renders current readiness as
a badge and exposes the five lifecycle transitions (start, restart,
force-restart, shutdown, force-shutdown) as action buttons. A 501 from
the gateway (no lifecycle provider configured) is surfaced as a disabled
"not available" state instead of an error.
Add getStatus/setStatus dispatch helpers in api-dispatch.ts (narrowed to
the apps/components entity types that expose the lifecycle collection)
plus LifecycleAction/LifecycleStatus types. Mount the control on the app
header (AppsPanel) and the component header (EntityDetailPanel).
---
README.md | 1 +
src/components/AppsPanel.tsx | 6 +
src/components/EntityDetailPanel.tsx | 7 +
src/components/EntityStatusControl.test.tsx | 136 ++++++++++++++
src/components/EntityStatusControl.tsx | 189 ++++++++++++++++++++
src/lib/api-dispatch.ts | 69 ++++++-
src/lib/types.ts | 20 +++
7 files changed, 427 insertions(+), 1 deletion(-)
create mode 100644 src/components/EntityStatusControl.test.tsx
create mode 100644 src/components/EntityStatusControl.tsx
diff --git a/README.md b/README.md
index e5e894c..02fb941 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ ros2_medkit_web_ui is a lightweight single-page application that connects to a S
- **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 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
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.
diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx
index d2830af..d840fa4 100644
--- a/src/components/AppsPanel.tsx
+++ b/src/components/AppsPanel.tsx
@@ -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;
@@ -136,6 +137,11 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI
+
+ {/* Lifecycle status control (gateway 0.6.0 lifecycle API) */}
+
+
+
{/* Tab Navigation */}
diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx
index ca03e14..985b917 100644
--- a/src/components/EntityDetailPanel.tsx
+++ b/src/components/EntityDetailPanel.tsx
@@ -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';
@@ -803,6 +804,12 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
+ {/* Lifecycle status control (gateway 0.6.0 lifecycle API) */}
+ {isComponent && (
+
+
+
+ )}
{/* Tab Navigation for Components */}
diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx
new file mode 100644
index 0000000..c627fe1
--- /dev/null
+++ b/src/components/EntityStatusControl.test.tsx
@@ -0,0 +1,136 @@
+// 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';
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockGetStatus = vi.fn();
+const mockSetStatus = vi.fn();
+
+vi.mock('@/lib/api-dispatch', () => ({
+ getStatus: (...args: unknown[]) => mockGetStatus(...args),
+ setStatus: (...args: unknown[]) => mockSetStatus(...args),
+}));
+
+// The component reads the typed client from the store; provide a sentinel.
+const fakeClient = { __fake: true };
+
+vi.mock('@/lib/store', () => ({
+ useAppStore: vi.fn((selector: (s: { client: unknown }) => unknown) => selector({ client: fakeClient })),
+}));
+
+vi.mock('react-toastify', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** 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 };
+}
+
+// Lazy import so mocks are wired before the module loads.
+const { EntityStatusControl } = await import('./EntityStatusControl');
+
+describe('EntityStatusControl', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // getStatus is called on mount to refresh the live status.
+ mockGetStatus.mockResolvedValue(ok(200, { status: 'ready' }));
+ mockSetStatus.mockResolvedValue(ok(204));
+ });
+
+ it('renders the current status badge from the status prop', async () => {
+ render();
+ // Both the prop-seeded badge and the on-mount refresh should land on "ready".
+ expect(await screen.findByText(/ready/i)).toBeInTheDocument();
+ });
+
+ it('renders an action button for each lifecycle action', () => {
+ render();
+ 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 click', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: /^restart$/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 action', async () => {
+ const user = userEvent.setup();
+ mockGetStatus.mockResolvedValue(ok(200, { status: 'notReady' }));
+ render();
+
+ await user.click(screen.getByRole('button', { name: /^shutdown$/i }));
+
+ // getStatus runs once on mount and once after the action.
+ await waitFor(() => expect(mockGetStatus).toHaveBeenCalledTimes(2));
+ expect(await screen.findByText(/notReady/i)).toBeInTheDocument();
+ });
+
+ it('surfaces a 501 from setStatus as a "not available" state and disables actions', async () => {
+ const user = userEvent.setup();
+ mockSetStatus.mockResolvedValue(errResult(501, 'Not Implemented'));
+ render();
+
+ await user.click(screen.getByRole('button', { name: /^start$/i }));
+
+ expect(await screen.findByText(/not available/i)).toBeInTheDocument();
+ // After "not available", action buttons are disabled.
+ expect(screen.getByRole('button', { name: /^start$/i })).toBeDisabled();
+ });
+
+ it('shows a 501 not-available state when the on-mount status fetch returns 501', async () => {
+ mockGetStatus.mockResolvedValue(errResult(501, 'Not Implemented'));
+ render();
+
+ expect(await screen.findByText(/not available/i)).toBeInTheDocument();
+ });
+
+ it('surfaces a non-501 error from setStatus inline and keeps actions enabled', async () => {
+ const user = userEvent.setup();
+ mockSetStatus.mockResolvedValue(errResult(400, 'invalid transition'));
+ render();
+
+ 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();
+ });
+});
diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx
new file mode 100644
index 0000000..098d3e6
--- /dev/null
+++ b/src/components/EntityStatusControl.tsx
@@ -0,0 +1,189 @@
+// 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 { useState, useEffect, useCallback } from 'react';
+import { useShallow } from 'zustand/shallow';
+import { Activity, AlertCircle, Loader2, Play, Power, RotateCw, Zap } from 'lucide-react';
+import { toast } from 'react-toastify';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { useAppStore } from '@/lib/store';
+import { getStatus, setStatus, type LifecycleEntityType } from '@/lib/api-dispatch';
+import type { LifecycleAction, LifecycleStatus } from '@/lib/types';
+
+interface EntityStatusControlProps {
+ entityType: LifecycleEntityType;
+ entityId: string;
+ /** Initial readiness value from the entity detail (AppDetail/ComponentDetail.status). */
+ status?: string;
+}
+
+interface ActionConfig {
+ action: LifecycleAction;
+ label: string;
+ icon: typeof Play;
+ /** Destructive transitions use the destructive button variant. */
+ variant: 'outline' | 'destructive';
+}
+
+const ACTIONS: ActionConfig[] = [
+ { action: 'start', label: 'Start', icon: Play, variant: 'outline' },
+ { action: 'restart', label: 'Restart', icon: RotateCw, variant: 'outline' },
+ { action: 'force-restart', label: 'Force restart', icon: Zap, variant: 'outline' },
+ { action: 'shutdown', label: 'Shutdown', icon: Power, variant: 'destructive' },
+ { action: 'force-shutdown', label: 'Force shutdown', icon: Power, variant: 'destructive' },
+];
+
+/** Narrow an arbitrary status string to the known readiness union, else null. */
+function toLifecycleStatus(value: string | undefined): LifecycleStatus | null {
+ return value === 'ready' || value === 'notReady' ? value : null;
+}
+
+/**
+ * Entity lifecycle status control for apps and components (gateway 0.6.0
+ * lifecycle API). Shows the current readiness as a badge and exposes the five
+ * lifecycle transitions as buttons.
+ *
+ * The gateway returns 501 until a lifecycle provider is configured. That case
+ * is surfaced as a disabled "not available" state rather than an error toast,
+ * so the control degrades gracefully on stock gateways.
+ */
+export function EntityStatusControl({ entityType, entityId, status }: EntityStatusControlProps) {
+ const { client } = useAppStore(useShallow((state) => ({ client: state.client })));
+
+ const [currentStatus, setCurrentStatus] = useState(toLifecycleStatus(status));
+ const [pendingAction, setPendingAction] = useState(null);
+ const [notAvailable, setNotAvailable] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Refresh the live status from the gateway, replacing the prop-seeded value.
+ const refreshStatus = useCallback(
+ async (signal?: AbortSignal) => {
+ if (!client) return;
+ const result = await getStatus(client, entityType, entityId, signal);
+ if (signal?.aborted) return;
+ if (result.response.status === 501) {
+ setNotAvailable(true);
+ return;
+ }
+ if (result.data && typeof result.data.status === 'string') {
+ const next = toLifecycleStatus(result.data.status);
+ if (next) setCurrentStatus(next);
+ }
+ },
+ [client, entityType, entityId]
+ );
+
+ useEffect(() => {
+ const controller = new AbortController();
+ refreshStatus(controller.signal).catch(() => {
+ // On-mount status fetch is best-effort; the prop value remains shown.
+ });
+ return () => controller.abort();
+ }, [refreshStatus]);
+
+ const handleAction = useCallback(
+ async (action: LifecycleAction) => {
+ if (!client) return;
+ setPendingAction(action);
+ setError(null);
+ try {
+ const result = await setStatus(client, entityType, entityId, action);
+ if (result.response.status === 501) {
+ setNotAvailable(true);
+ return;
+ }
+ if (result.error) {
+ const message = result.error.message || `Failed to ${action}`;
+ setError(message);
+ toast.error(`Failed to ${action} ${entityId}: ${message}`);
+ return;
+ }
+ toast.success(`${action} requested for ${entityId}`);
+ await refreshStatus();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+ setError(message);
+ toast.error(`Failed to ${action} ${entityId}: ${message}`);
+ } finally {
+ setPendingAction(null);
+ }
+ },
+ [client, entityType, entityId, refreshStatus]
+ );
+
+ const statusBadge = (() => {
+ if (currentStatus === 'ready') {
+ return (
+
+ ready
+
+ );
+ }
+ if (currentStatus === 'notReady') {
+ return (
+
+ notReady
+
+ );
+ }
+ return unknown;
+ })();
+
+ return (
+
+
+
+
+ Lifecycle
+
+ {statusBadge}
+ {notAvailable && (
+
+
+ not available
+
+ )}
+
+ );
+}
diff --git a/src/lib/api-dispatch.ts b/src/lib/api-dispatch.ts
index 1565132..33d2a2c 100644
--- a/src/lib/api-dispatch.ts
+++ b/src/lib/api-dispatch.ts
@@ -22,7 +22,7 @@
*/
import type { MedkitClient } from '@selfpatch/ros2-medkit-client-ts';
-import type { SovdResourceEntityType } from './types';
+import type { SovdResourceEntityType, LifecycleAction } from './types';
import type { LogsQueryParams, LogsConfiguration } from './log-types';
// =============================================================================
@@ -623,3 +623,70 @@ export function putEntityLogsConfiguration(
});
}
}
+
+// =============================================================================
+// Lifecycle Status
+//
+// The gateway 0.6.0 lifecycle API exists ONLY for apps and components. There is
+// no areas/functions equivalent, so these helpers narrow the entity type to
+// 'apps' | 'components'. Each transition is a distinct PUT path (the action is
+// part of the URL, not a path parameter), so setStatus maps the action string
+// to the matching typed path.
+// =============================================================================
+
+/** Entity types that expose the lifecycle status collection. */
+export type LifecycleEntityType = Extract;
+
+export function getStatus(
+ client: MedkitClient,
+ entityType: LifecycleEntityType,
+ entityId: string,
+ signal?: AbortSignal
+) {
+ switch (entityType) {
+ case 'apps':
+ return client.GET('/apps/{app_id}/status', { params: { path: { app_id: entityId } }, signal });
+ case 'components':
+ return client.GET('/components/{component_id}/status', {
+ params: { path: { component_id: entityId } },
+ signal,
+ });
+ }
+}
+
+export function setStatus(
+ client: MedkitClient,
+ entityType: LifecycleEntityType,
+ entityId: string,
+ action: LifecycleAction,
+ signal?: AbortSignal
+) {
+ if (entityType === 'apps') {
+ const params = { path: { app_id: entityId } };
+ switch (action) {
+ case 'start':
+ return client.PUT('/apps/{app_id}/status/start', { params, signal });
+ case 'restart':
+ return client.PUT('/apps/{app_id}/status/restart', { params, signal });
+ case 'force-restart':
+ return client.PUT('/apps/{app_id}/status/force-restart', { params, signal });
+ case 'shutdown':
+ return client.PUT('/apps/{app_id}/status/shutdown', { params, signal });
+ case 'force-shutdown':
+ return client.PUT('/apps/{app_id}/status/force-shutdown', { params, signal });
+ }
+ }
+ const params = { path: { component_id: entityId } };
+ switch (action) {
+ case 'start':
+ return client.PUT('/components/{component_id}/status/start', { params, signal });
+ case 'restart':
+ return client.PUT('/components/{component_id}/status/restart', { params, signal });
+ case 'force-restart':
+ return client.PUT('/components/{component_id}/status/force-restart', { params, signal });
+ case 'shutdown':
+ return client.PUT('/components/{component_id}/status/shutdown', { params, signal });
+ case 'force-shutdown':
+ return client.PUT('/components/{component_id}/status/force-shutdown', { params, signal });
+ }
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index c482b73..2128aec 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -64,6 +64,26 @@ export type {
*/
export type SovdResourceEntityType = 'areas' | 'components' | 'apps' | 'functions';
+// =============================================================================
+// Lifecycle Status (gateway 0.6.0 lifecycle API)
+//
+// Only apps and components expose the lifecycle status collection. The gateway
+// returns 501 until a lifecycle provider is configured; callers should treat
+// that as "not available" rather than an error.
+// =============================================================================
+
+/**
+ * Lifecycle transition action. Each maps to a distinct
+ * PUT /{entity}/{id}/status/{action} endpoint.
+ */
+export type LifecycleAction = 'start' | 'restart' | 'force-restart' | 'shutdown' | 'force-shutdown';
+
+/**
+ * Lifecycle readiness value reported by GET /{entity}/{id}/status and carried
+ * on AppDetail/ComponentDetail.
+ */
+export type LifecycleStatus = 'ready' | 'notReady';
+
/**
* QoS profile for a topic endpoint
*/
From 14395452095694e5f20fa49d998635758f89bc1c Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 11:17:10 +0200
Subject: [PATCH 02/11] feat: add entity lifecycle status store slice
---
src/lib/store.test.ts | 49 ++++++++++++++++++++++++++++++++
src/lib/store.ts | 66 +++++++++++++++++++++++++++++++++++++++++++
src/lib/types.ts | 3 ++
3 files changed, 118 insertions(+)
create mode 100644 src/lib/store.test.ts
diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts
new file mode 100644
index 0000000..67e975b
--- /dev/null
+++ b/src/lib/store.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+// Mock api-dispatch so the store's getStatus call hits our spy. A namespace
+// spy (vi.spyOn) does not patch the store's named-import binding, so mock the
+// module instead and drive the return value per-test.
+vi.mock('./api-dispatch', () => ({
+ getStatus: vi.fn(),
+ setStatus: vi.fn(),
+}));
+
+import { useAppStore, entityStatusKey } from './store';
+import * as api from './api-dispatch';
+
+const getStatusMock = vi.mocked(api.getStatus);
+
+describe('entityStatusKey', () => {
+ it('joins type and id with a colon', () => {
+ expect(entityStatusKey('components', 'host1')).toBe('components:host1');
+ expect(entityStatusKey('apps', 'planner')).toBe('apps:planner');
+ });
+});
+
+describe('fetchEntityStatus', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useAppStore.setState({ statusByEntity: {}, client: {} as never });
+ });
+
+ it('maps a 200 ready response into the cache', async () => {
+ getStatusMock.mockResolvedValue({ data: { status: 'ready' }, response: { status: 200 } } as never);
+ await useAppStore.getState().fetchEntityStatus('components', 'host1');
+ expect(useAppStore.getState().statusByEntity[entityStatusKey('components', 'host1')]).toBe('ready');
+ });
+
+ it('maps a 501 response to "unavailable"', async () => {
+ getStatusMock.mockResolvedValue({ data: undefined, response: { status: 501 } } as never);
+ await useAppStore.getState().fetchEntityStatus('apps', 'planner');
+ expect(useAppStore.getState().statusByEntity[entityStatusKey('apps', 'planner')]).toBe('unavailable');
+ });
+
+ it('de-dupes concurrent in-flight calls for the same key', async () => {
+ getStatusMock.mockResolvedValue({ data: { status: 'notReady' }, response: { status: 200 } } as never);
+ await Promise.all([
+ useAppStore.getState().fetchEntityStatus('apps', 'planner'),
+ useAppStore.getState().fetchEntityStatus('apps', 'planner'),
+ ]);
+ expect(getStatusMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/store.ts b/src/lib/store.ts
index 10e08dc..deabe43 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -16,6 +16,7 @@ import type {
App,
VersionInfo,
SovdFunction,
+ EntityStatusValue,
} from './types';
import { createMedkitClient, normalizeBaseUrl, type MedkitClient } from '@selfpatch/ros2-medkit-client-ts';
import type { SovdResourceEntityType } from './types';
@@ -47,6 +48,8 @@ import {
getEntityLogs,
getEntityLogsConfiguration,
putEntityLogsConfiguration,
+ getStatus,
+ type LifecycleEntityType,
} from './api-dispatch';
import type { LogCollection, LogsConfiguration, LogsFetchResult, LogsQueryParams } from './log-types';
@@ -108,6 +111,10 @@ export interface AppState {
isLoadingFaults: boolean;
faultStreamCleanup: (() => void) | null;
+ // Lifecycle status cache (apps/components only).
+ // Key is `${entityType}:${entityId}` (plural type); see entityStatusKey.
+ statusByEntity: Record;
+
// Actions
connect: (url: string) => Promise;
disconnect: () => void;
@@ -157,6 +164,9 @@ export interface AppState {
startExecutionPolling: () => void;
stopExecutionPolling: () => void;
+ // Lifecycle status action (apps/components only) - fills statusByEntity.
+ fetchEntityStatus: (entityType: LifecycleEntityType, entityId: string) => Promise;
+
// Faults actions
fetchFaults: () => Promise;
clearFault: (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => Promise;
@@ -734,6 +744,26 @@ export function __resetAppsRequestCache(): void {
inFlightAppsRequest = null;
}
+/**
+ * Build the `statusByEntity` cache key from an entity's plural resource type
+ * and id (e.g. `entityStatusKey('apps', 'planner') === 'apps:planner'`).
+ */
+export function entityStatusKey(entityType: string, entityId: string): string {
+ return `${entityType}:${entityId}`;
+}
+
+/**
+ * Module-level dedupe for `fetchEntityStatus`. Concurrent calls for the same
+ * entity (e.g. the control and the tree lamp both mounting) share one request.
+ * The promise is cleared on settlement so later mounts refetch fresh status.
+ */
+const inFlightStatusRequests = new Map>();
+
+/** Reset the status-request dedupe cache. Exposed for tests. */
+export function __resetStatusRequestCache(): void {
+ inFlightStatusRequests.clear();
+}
+
export async function fetchAllAppsDeduped(client: MedkitClient): Promise[]> {
if (inFlightAppsRequest) return inFlightAppsRequest;
inFlightAppsRequest = client
@@ -865,6 +895,9 @@ export const useAppStore = create()(
isLoadingFaults: false,
faultStreamCleanup: null,
+ // Lifecycle status cache
+ statusByEntity: {},
+
// Connect to ros2_medkit gateway
connect: async (url: string) => {
set({ isConnecting: true, connectionError: null });
@@ -1877,6 +1910,39 @@ export const useAppStore = create()(
}
},
+ // ===========================================================================
+ // LIFECYCLE STATUS ACTION (apps/components only)
+ // ===========================================================================
+
+ fetchEntityStatus: async (entityType: LifecycleEntityType, entityId: string) => {
+ const key = entityStatusKey(entityType, entityId);
+ const existing = inFlightStatusRequests.get(key);
+ if (existing) return existing;
+
+ const client = get().client;
+ if (!client) return;
+
+ const request = (async () => {
+ try {
+ const result = await getStatus(client, entityType, entityId);
+ let value: EntityStatusValue = 'unknown';
+ if (result.response?.status === 501) {
+ value = 'unavailable';
+ } else if (result.data?.status === 'ready' || result.data?.status === 'notReady') {
+ value = result.data.status;
+ }
+ set((s) => ({ statusByEntity: { ...s.statusByEntity, [key]: value } }));
+ } catch {
+ set((s) => ({ statusByEntity: { ...s.statusByEntity, [key]: 'unknown' } }));
+ } finally {
+ inFlightStatusRequests.delete(key);
+ }
+ })();
+
+ inFlightStatusRequests.set(key, request);
+ return request;
+ },
+
// ===========================================================================
// FAULTS ACTIONS (Diagnostic Trouble Codes)
// ===========================================================================
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 2128aec..35252ac 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -84,6 +84,9 @@ export type LifecycleAction = 'start' | 'restart' | 'force-restart' | 'shutdown'
*/
export type LifecycleStatus = 'ready' | 'notReady';
+/** Cached lifecycle status value for an entity (apps/components only). */
+export type EntityStatusValue = 'ready' | 'notReady' | 'unavailable' | 'unknown';
+
/**
* QoS profile for a topic endpoint
*/
From 0bf092c750d1dbad398c3d6ae274e65c0271ae70 Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 11:20:42 +0200
Subject: [PATCH 03/11] feat: gate lifecycle actions by status with tooltips
---
src/components/EntityStatusControl.test.tsx | 142 +++++++++++++-------
src/components/EntityStatusControl.tsx | 111 ++++++++-------
2 files changed, 158 insertions(+), 95 deletions(-)
diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx
index c627fe1..709667f 100644
--- a/src/components/EntityStatusControl.test.tsx
+++ b/src/components/EntityStatusControl.test.tsx
@@ -12,36 +12,39 @@
// 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 { 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 mockGetStatus = vi.fn();
const mockSetStatus = vi.fn();
-vi.mock('@/lib/api-dispatch', () => ({
- getStatus: (...args: unknown[]) => mockGetStatus(...args),
- setStatus: (...args: unknown[]) => mockSetStatus(...args),
-}));
-
-// The component reads the typed client from the store; provide a sentinel.
-const fakeClient = { __fake: true };
-
-vi.mock('@/lib/store', () => ({
- useAppStore: vi.fn((selector: (s: { client: unknown }) => unknown) => selector({ client: fakeClient })),
-}));
+vi.mock('@/lib/api-dispatch', async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ setStatus: (...args: unknown[]) => mockSetStatus(...args),
+ };
+});
vi.mock('react-toastify', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
+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) {
@@ -52,25 +55,44 @@ function errResult(status: number, message: string) {
return { data: undefined, error: { message }, response: { status } as Response };
}
-// Lazy import so mocks are wired before the module loads.
-const { EntityStatusControl } = await import('./EntityStatusControl');
+/**
+ * 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({ui});
describe('EntityStatusControl', () => {
beforeEach(() => {
vi.clearAllMocks();
- // getStatus is called on mount to refresh the live status.
- mockGetStatus.mockResolvedValue(ok(200, { status: 'ready' }));
mockSetStatus.mockResolvedValue(ok(204));
+ useAppStore.setState({ statusByEntity: {}, fetchEntityStatus: vi.fn(), client: fakeClient });
});
- it('renders the current status badge from the status prop', async () => {
- render();
- // Both the prop-seeded badge and the on-mount refresh should land on "ready".
- expect(await screen.findByText(/ready/i)).toBeInTheDocument();
+ 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();
+ expect(await screen.findByText(/^ready$/i)).toBeInTheDocument();
});
it('renders an action button for each lifecycle action', () => {
- render();
+ seedStatus('apps:motor', 'ready');
+ renderControl();
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();
@@ -78,9 +100,11 @@ describe('EntityStatusControl', () => {
expect(screen.getByRole('button', { name: /force shutdown/i })).toBeInTheDocument();
});
- it('calls setStatus with client, entityType, entityId and action on click', async () => {
+ it('calls setStatus with client, entityType, entityId and action on restart', async () => {
const user = userEvent.setup();
- render();
+ // ready leaves Restart enabled.
+ seedStatus('components:host-1', 'ready');
+ renderControl();
await user.click(screen.getByRole('button', { name: /^restart$/i }));
@@ -94,43 +118,69 @@ describe('EntityStatusControl', () => {
it('refreshes the status after a successful action', async () => {
const user = userEvent.setup();
- mockGetStatus.mockResolvedValue(ok(200, { status: 'notReady' }));
- render();
+ const refresh = vi.fn();
+ useAppStore.setState({
+ statusByEntity: { 'apps:motor': 'ready' },
+ fetchEntityStatus: refresh,
+ client: fakeClient,
+ });
+ renderControl();
+
+ // Mount effect calls fetchEntityStatus once.
+ await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
await user.click(screen.getByRole('button', { name: /^shutdown$/i }));
- // getStatus runs once on mount and once after the action.
- await waitFor(() => expect(mockGetStatus).toHaveBeenCalledTimes(2));
- expect(await screen.findByText(/notReady/i)).toBeInTheDocument();
+ // The post-dispatch refresh calls fetchEntityStatus again.
+ await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2));
});
- it('surfaces a 501 from setStatus as a "not available" state and disables actions', async () => {
- const user = userEvent.setup();
- mockSetStatus.mockResolvedValue(errResult(501, 'Not Implemented'));
- render();
-
- await user.click(screen.getByRole('button', { name: /^start$/i }));
+ 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();
expect(await screen.findByText(/not available/i)).toBeInTheDocument();
- // After "not available", action buttons are disabled.
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 a 501 not-available state when the on-mount status fetch returns 501', async () => {
- mockGetStatus.mockResolvedValue(errResult(501, 'Not Implemented'));
- render();
-
+ it('shows the "not available" state when the cached status is unavailable for components', async () => {
+ seedStatus('components:host-1', 'unavailable');
+ renderControl();
expect(await screen.findByText(/not available/i)).toBeInTheDocument();
});
- it('surfaces a non-501 error from setStatus inline and keeps actions enabled', async () => {
+ 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'));
- render();
+ // start is enabled when notReady and dispatches immediately.
+ seedStatus('apps:motor', 'notReady');
+ renderControl();
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();
+ 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();
+ 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();
+ });
});
diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx
index 098d3e6..5187c61 100644
--- a/src/components/EntityStatusControl.tsx
+++ b/src/components/EntityStatusControl.tsx
@@ -12,15 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import { useState, useEffect, useCallback } from 'react';
-import { useShallow } from 'zustand/shallow';
+import { useEffect, useState, useCallback } from 'react';
import { Activity, AlertCircle, Loader2, Play, Power, RotateCw, Zap } from 'lucide-react';
import { toast } from 'react-toastify';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
-import { useAppStore } from '@/lib/store';
-import { getStatus, setStatus, type LifecycleEntityType } from '@/lib/api-dispatch';
-import type { LifecycleAction, LifecycleStatus } from '@/lib/types';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { useAppStore, entityStatusKey } from '@/lib/store';
+import { setStatus, type LifecycleEntityType } from '@/lib/api-dispatch';
+import type { LifecycleAction } from '@/lib/types';
interface EntityStatusControlProps {
entityType: LifecycleEntityType;
@@ -45,55 +45,51 @@ const ACTIONS: ActionConfig[] = [
{ action: 'force-shutdown', label: 'Force shutdown', icon: Power, variant: 'destructive' },
];
-/** Narrow an arbitrary status string to the known readiness union, else null. */
-function toLifecycleStatus(value: string | undefined): LifecycleStatus | null {
- return value === 'ready' || value === 'notReady' ? value : null;
-}
+/** Transitions disabled for a given cached readiness value. */
+const DISABLED_BY_STATUS: Record> = {
+ ready: new Set(['start']),
+ notReady: new Set(['restart', 'shutdown', 'force-shutdown']),
+};
/**
* Entity lifecycle status control for apps and components (gateway 0.6.0
* lifecycle API). Shows the current readiness as a badge and exposes the five
* lifecycle transitions as buttons.
*
+ * Status is read from the shared `statusByEntity` store slice (the single
+ * source of truth, also feeding the tree readiness lamp). Actions are gated by
+ * that status (disabled + tooltip).
+ *
* The gateway returns 501 until a lifecycle provider is configured. That case
- * is surfaced as a disabled "not available" state rather than an error toast,
- * so the control degrades gracefully on stock gateways.
+ * surfaces as the cached value `'unavailable'` -> a disabled "not available"
+ * state rather than an error toast, so the control degrades gracefully on stock
+ * gateways.
*/
-export function EntityStatusControl({ entityType, entityId, status }: EntityStatusControlProps) {
- const { client } = useAppStore(useShallow((state) => ({ client: state.client })));
+export function EntityStatusControl({ entityType, entityId }: EntityStatusControlProps) {
+ const client = useAppStore((s) => s.client);
+ const status = useAppStore((s) => s.statusByEntity[entityStatusKey(entityType, entityId)]);
+ const fetchEntityStatus = useAppStore((s) => s.fetchEntityStatus);
- const [currentStatus, setCurrentStatus] = useState(toLifecycleStatus(status));
const [pendingAction, setPendingAction] = useState(null);
- const [notAvailable, setNotAvailable] = useState(false);
const [error, setError] = useState(null);
- // Refresh the live status from the gateway, replacing the prop-seeded value.
- const refreshStatus = useCallback(
- async (signal?: AbortSignal) => {
- if (!client) return;
- const result = await getStatus(client, entityType, entityId, signal);
- if (signal?.aborted) return;
- if (result.response.status === 501) {
- setNotAvailable(true);
- return;
- }
- if (result.data && typeof result.data.status === 'string') {
- const next = toLifecycleStatus(result.data.status);
- if (next) setCurrentStatus(next);
- }
- },
- [client, entityType, entityId]
- );
-
+ // Fetch the live status on mount; the slice de-dupes against the tree lamp.
useEffect(() => {
- const controller = new AbortController();
- refreshStatus(controller.signal).catch(() => {
- // On-mount status fetch is best-effort; the prop value remains shown.
- });
- return () => controller.abort();
- }, [refreshStatus]);
-
- const handleAction = useCallback(
+ fetchEntityStatus(entityType, entityId);
+ }, [entityType, entityId, fetchEntityStatus]);
+
+ const notAvailable = status === 'unavailable';
+
+ const isDisabled = (action: LifecycleAction): boolean =>
+ !client || notAvailable || pendingAction !== null || (DISABLED_BY_STATUS[status ?? '']?.has(action) ?? false);
+
+ const tooltipFor = (action: LifecycleAction): string => {
+ if (status === 'ready' && action === 'start') return 'Already running';
+ if (status === 'notReady' && DISABLED_BY_STATUS.notReady!.has(action)) return 'Entity is not running';
+ return '';
+ };
+
+ const dispatchAction = useCallback(
async (action: LifecycleAction) => {
if (!client) return;
setPendingAction(action);
@@ -101,7 +97,8 @@ export function EntityStatusControl({ entityType, entityId, status }: EntityStat
try {
const result = await setStatus(client, entityType, entityId, action);
if (result.response.status === 501) {
- setNotAvailable(true);
+ // Mark the cache as unavailable so the control disables uniformly.
+ await fetchEntityStatus(entityType, entityId);
return;
}
if (result.error) {
@@ -111,7 +108,7 @@ export function EntityStatusControl({ entityType, entityId, status }: EntityStat
return;
}
toast.success(`${action} requested for ${entityId}`);
- await refreshStatus();
+ await fetchEntityStatus(entityType, entityId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
@@ -120,18 +117,18 @@ export function EntityStatusControl({ entityType, entityId, status }: EntityStat
setPendingAction(null);
}
},
- [client, entityType, entityId, refreshStatus]
+ [client, entityType, entityId, fetchEntityStatus]
);
const statusBadge = (() => {
- if (currentStatus === 'ready') {
+ if (status === 'ready') {
return (
ready
);
}
- if (currentStatus === 'notReady') {
+ if (status === 'notReady') {
return (
notReady
@@ -160,13 +157,15 @@ export function EntityStatusControl({ entityType, entityId, status }: EntityStat
{ACTIONS.map(({ action, label, icon: Icon, variant }) => {
const isPending = pendingAction === action;
- return (
+ const disabled = isDisabled(action);
+ const tip = disabled ? tooltipFor(action) : '';
+ const button = (
);
+
+ // A disabled button does not fire pointer events, so wrap it
+ // in a focusable span to let the tooltip explain why.
+ if (tip) {
+ return (
+
+
+ {button}
+
+ {tip}
+
+ );
+ }
+ return button;
})}
From 3c8e43d2ba35b4bcceac20a715a0fcbd3eb22db5 Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 11:22:54 +0200
Subject: [PATCH 04/11] feat: confirm destructive lifecycle transitions before
dispatch
---
src/components/EntityStatusControl.test.tsx | 33 ++++++++++-
src/components/EntityStatusControl.tsx | 65 ++++++++++++++++++++-
2 files changed, 94 insertions(+), 4 deletions(-)
diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx
index 709667f..087d583 100644
--- a/src/components/EntityStatusControl.test.tsx
+++ b/src/components/EntityStatusControl.test.tsx
@@ -100,13 +100,14 @@ describe('EntityStatusControl', () => {
expect(screen.getByRole('button', { name: /force shutdown/i })).toBeInTheDocument();
});
- it('calls setStatus with client, entityType, entityId and action on restart', async () => {
+ 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();
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]!;
@@ -116,7 +117,7 @@ describe('EntityStatusControl', () => {
expect(call[3]).toBe('restart');
});
- it('refreshes the status after a successful action', async () => {
+ it('refreshes the status after a successful confirmed action', async () => {
const user = userEvent.setup();
const refresh = vi.fn();
useAppStore.setState({
@@ -130,6 +131,7 @@ describe('EntityStatusControl', () => {
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));
@@ -183,4 +185,31 @@ describe('EntityStatusControl', () => {
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();
+
+ 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();
+
+ await user.click(screen.getByRole('button', { name: /^start$/i }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith(fakeClient, 'apps', 'planner', 'start'));
+ });
});
diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx
index 5187c61..0988cf2 100644
--- a/src/components/EntityStatusControl.tsx
+++ b/src/components/EntityStatusControl.tsx
@@ -17,6 +17,14 @@ import { Activity, AlertCircle, Loader2, Play, Power, RotateCw, Zap } from 'luci
import { toast } from 'react-toastify';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAppStore, entityStatusKey } from '@/lib/store';
import { setStatus, type LifecycleEntityType } from '@/lib/api-dispatch';
@@ -51,6 +59,9 @@ const DISABLED_BY_STATUS: Record> = {
notReady: new Set(['restart', 'shutdown', 'force-shutdown']),
};
+/** Destructive transitions get the destructive confirm-button variant. */
+const DESTRUCTIVE_ACTIONS = new Set(['shutdown', 'force-shutdown']);
+
/**
* Entity lifecycle status control for apps and components (gateway 0.6.0
* lifecycle API). Shows the current readiness as a badge and exposes the five
@@ -58,7 +69,8 @@ const DISABLED_BY_STATUS: Record> = {
*
* Status is read from the shared `statusByEntity` store slice (the single
* source of truth, also feeding the tree readiness lamp). Actions are gated by
- * that status (disabled + tooltip).
+ * that status (disabled + tooltip), and every transition except Start asks for
+ * confirmation before dispatch.
*
* The gateway returns 501 until a lifecycle provider is configured. That case
* surfaces as the cached value `'unavailable'` -> a disabled "not available"
@@ -71,6 +83,7 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
const fetchEntityStatus = useAppStore((s) => s.fetchEntityStatus);
const [pendingAction, setPendingAction] = useState(null);
+ const [confirmAction, setConfirmAction] = useState(null);
const [error, setError] = useState(null);
// Fetch the live status on mount; the slice de-dupes against the tree lamp.
@@ -120,6 +133,28 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
[client, entityType, entityId, fetchEntityStatus]
);
+ const handleClick = useCallback(
+ (action: LifecycleAction) => {
+ // Start is non-destructive: dispatch immediately. Everything else
+ // interrupts a running entity, so confirm first.
+ if (action === 'start') {
+ void dispatchAction(action);
+ } else {
+ setConfirmAction(action);
+ }
+ },
+ [dispatchAction]
+ );
+
+ const handleConfirm = useCallback(() => {
+ if (confirmAction) {
+ void dispatchAction(confirmAction);
+ }
+ setConfirmAction(null);
+ }, [confirmAction, dispatchAction]);
+
+ const confirmLabel = confirmAction ? (ACTIONS.find((a) => a.action === confirmAction)?.label ?? confirmAction) : '';
+
const statusBadge = (() => {
if (status === 'ready') {
return (
@@ -165,7 +200,7 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
variant={variant}
size="sm"
disabled={disabled}
- onClick={() => dispatchAction(action)}
+ onClick={() => handleClick(action)}
>
{isPending ? (
@@ -197,6 +232,32 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
{error}
)}
+
+
);
}
From 01c842f2ab16d6f2c4897652b617a1d38c8d9cda Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 11:24:28 +0200
Subject: [PATCH 05/11] feat: show lifecycle readiness lamp on app/component
tree nodes
---
src/components/EntityTreeNode.test.tsx | 72 ++++++++++++++++++++++++++
src/components/EntityTreeNode.tsx | 42 ++++++++++++++-
2 files changed, 113 insertions(+), 1 deletion(-)
create mode 100644 src/components/EntityTreeNode.test.tsx
diff --git a/src/components/EntityTreeNode.test.tsx b/src/components/EntityTreeNode.test.tsx
new file mode 100644
index 0000000..be3d5a1
--- /dev/null
+++ b/src/components/EntityTreeNode.test.tsx
@@ -0,0 +1,72 @@
+// 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, cleanup } from '@testing-library/react';
+
+vi.mock('react-toastify', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), warn: vi.fn(), warning: vi.fn() },
+}));
+
+import { useAppStore } from '@/lib/store';
+import { EntityTreeNode } from './EntityTreeNode';
+
+describe('EntityTreeNode lifecycle lamp', () => {
+ beforeEach(() => {
+ useAppStore.setState({
+ statusByEntity: {},
+ expandedPaths: [],
+ loadingPaths: [],
+ selectedPath: null,
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('app node fetches status on mount and renders a lamp from the cache', () => {
+ const fetchEntityStatus = vi.fn();
+ useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' }, fetchEntityStatus } as never);
+ render(
+
+ );
+ expect(fetchEntityStatus).toHaveBeenCalledWith('apps', 'talker');
+ expect(screen.getByLabelText(/status: ready/i)).toBeInTheDocument();
+ });
+
+ it('component node fetches status on mount mapped to the plural resource type', () => {
+ const fetchEntityStatus = vi.fn();
+ useAppStore.setState({ statusByEntity: { 'components:host1': 'notReady' }, fetchEntityStatus } as never);
+ render(
+
+ );
+ expect(fetchEntityStatus).toHaveBeenCalledWith('components', 'host1');
+ expect(screen.getByLabelText(/status: notReady/i)).toBeInTheDocument();
+ });
+
+ it('area node renders no lamp and triggers no status fetch', () => {
+ const fetchEntityStatus = vi.fn();
+ useAppStore.setState({ fetchEntityStatus } as never);
+ render();
+ expect(fetchEntityStatus).not.toHaveBeenCalled();
+ expect(screen.queryByLabelText(/status:/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx
index 4bd8c30..e841db7 100644
--- a/src/components/EntityTreeNode.tsx
+++ b/src/components/EntityTreeNode.tsx
@@ -22,7 +22,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
-import { useAppStore } from '@/lib/store';
+import { useAppStore, entityStatusKey } from '@/lib/store';
import type { EntityTreeNode as EntityTreeNodeType, TopicNodeData, Parameter } from '@/lib/types';
interface EntityTreeNodeProps {
@@ -114,6 +114,21 @@ function getEntityColor(type: string, isSelected?: boolean): string {
}
}
+/**
+ * Tailwind colour for the readiness lamp. Green = ready, amber = notReady,
+ * grey for unavailable / unknown / not-yet-fetched.
+ */
+function getLampColor(status: string | undefined): string {
+ switch (status) {
+ case 'ready':
+ return 'bg-emerald-500';
+ case 'notReady':
+ return 'bg-amber-500';
+ default:
+ return 'bg-muted-foreground/40';
+ }
+}
+
/**
* Check if node data is TopicNodeData (from topicsInfo)
*/
@@ -140,6 +155,24 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) {
}))
);
+ // Lifecycle readiness lamp is only meaningful for apps and components.
+ // The tree uses singular node types; the lifecycle API uses plural.
+ const isLifecycleEntity = node.type === 'app' || node.type === 'component';
+ const lifecycleType = node.type === 'app' ? 'apps' : 'components';
+ const status = useAppStore((s) =>
+ isLifecycleEntity ? s.statusByEntity[entityStatusKey(lifecycleType, node.id)] : undefined
+ );
+ const fetchEntityStatus = useAppStore((s) => s.fetchEntityStatus);
+
+ // Lazily fetch readiness on mount. This node only mounts when its parent is
+ // expanded, so this is the on-expand fetch. The slice de-dupes with the
+ // control's own fetch.
+ useEffect(() => {
+ if (isLifecycleEntity) {
+ fetchEntityStatus(lifecycleType, node.id);
+ }
+ }, [isLifecycleEntity, lifecycleType, node.id, fetchEntityStatus]);
+
const isExpanded = expandedPaths.includes(node.path);
const isLoading = loadingPaths.includes(node.path);
const isSelected = selectedPath === node.path;
@@ -221,6 +254,13 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) {
+ {isLifecycleEntity && (
+
+ )}
+
{typeof node.name === 'string' ? node.name : String(node.name || node.id || '')}
From 4698342f04f73bde03ba8786e086caaada20a1a6 Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 11:27:13 +0200
Subject: [PATCH 06/11] docs: note lifecycle gating, confirmation, and tree
readiness lamp
---
README.md | 4 ++--
src/components/EntityDetailPanel.test.tsx | 8 ++++++++
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 02fb941..8667b10 100644
--- a/README.md
+++ b/README.md
@@ -13,9 +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
+- **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.
diff --git a/src/components/EntityDetailPanel.test.tsx b/src/components/EntityDetailPanel.test.tsx
index 4ed4dc4..84e3a3c 100644
--- a/src/components/EntityDetailPanel.test.tsx
+++ b/src/components/EntityDetailPanel.test.tsx
@@ -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) {
@@ -63,6 +66,11 @@ function setStore(overrides: Record) {
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,
};
}
From b123b6d9b68165f2b715c68ee9e76c2645dd1715 Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 12:30:31 +0200
Subject: [PATCH 07/11] feat: track gateway lifecycle actuation support in the
store
---
src/lib/store.test.ts | 13 +++++++++++++
src/lib/store.ts | 17 ++++++++++++++++-
2 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts
index 67e975b..250bc1b 100644
--- a/src/lib/store.test.ts
+++ b/src/lib/store.test.ts
@@ -47,3 +47,16 @@ describe('fetchEntityStatus', () => {
expect(getStatusMock).toHaveBeenCalledTimes(1);
});
});
+
+describe('actuationSupported', () => {
+ it('defaults to null and setActuationSupported updates it', () => {
+ useAppStore.setState({ actuationSupported: null });
+ useAppStore.getState().setActuationSupported(false);
+ expect(useAppStore.getState().actuationSupported).toBe(false);
+ });
+ it('disconnect resets the flag to null', () => {
+ useAppStore.setState({ actuationSupported: false });
+ useAppStore.getState().disconnect();
+ expect(useAppStore.getState().actuationSupported).toBeNull();
+ });
+});
diff --git a/src/lib/store.ts b/src/lib/store.ts
index deabe43..039d15e 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -115,6 +115,11 @@ export interface AppState {
// Key is `${entityType}:${entityId}` (plural type); see entityStatusKey.
statusByEntity: Record;
+ // Gateway-wide lifecycle actuation support, derived from observed transition
+ // responses: null = unknown, true = a transition succeeded (2xx), false = the
+ // gateway answered 501 (no actuation provider). Reset on every (re)connect.
+ actuationSupported: boolean | null;
+
// Actions
connect: (url: string) => Promise;
disconnect: () => void;
@@ -167,6 +172,9 @@ export interface AppState {
// Lifecycle status action (apps/components only) - fills statusByEntity.
fetchEntityStatus: (entityType: LifecycleEntityType, entityId: string) => Promise;
+ // Records whether the gateway supports lifecycle actuation (see actuationSupported).
+ setActuationSupported: (value: boolean) => void;
+
// Faults actions
fetchFaults: () => Promise;
clearFault: (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => Promise;
@@ -898,9 +906,13 @@ export const useAppStore = create()(
// Lifecycle status cache
statusByEntity: {},
+ // Gateway-wide lifecycle actuation support (unknown until observed).
+ actuationSupported: null,
+
// Connect to ros2_medkit gateway
connect: async (url: string) => {
- set({ isConnecting: true, connectionError: null });
+ // Clear any stale actuation flag so a reconnect re-probes support.
+ set({ isConnecting: true, connectionError: null, actuationSupported: null });
try {
const client = createMedkitClient({ baseUrl: url, fetch: fetch.bind(globalThis) });
@@ -973,6 +985,7 @@ export const useAppStore = create()(
selectedPath: null,
selectedEntity: null,
activeExecutions: new Map(),
+ actuationSupported: null,
});
},
@@ -1943,6 +1956,8 @@ export const useAppStore = create()(
return request;
},
+ setActuationSupported: (value: boolean) => set({ actuationSupported: value }),
+
// ===========================================================================
// FAULTS ACTIONS (Diagnostic Trouble Codes)
// ===========================================================================
From f6f2fc12d28e73b66a65ff18f6e9df489bd7e4f4 Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 12:32:13 +0200
Subject: [PATCH 08/11] fix: give response-driven feedback for lifecycle
transitions
---
src/components/EntityStatusControl.test.tsx | 32 ++++++++++++++++++++-
src/components/EntityStatusControl.tsx | 16 ++++++++---
2 files changed, 43 insertions(+), 5 deletions(-)
diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx
index 087d583..71c234d 100644
--- a/src/components/EntityStatusControl.test.tsx
+++ b/src/components/EntityStatusControl.test.tsx
@@ -38,9 +38,10 @@ vi.mock('@/lib/api-dispatch', async (importActual) => {
});
vi.mock('react-toastify', () => ({
- toast: { success: vi.fn(), error: vi.fn() },
+ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
}));
+import { toast } from 'react-toastify';
import { useAppStore } from '@/lib/store';
import { EntityStatusControl } from './EntityStatusControl';
@@ -212,4 +213,33 @@ describe('EntityStatusControl', () => {
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();
+
+ 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();
+
+ await userEvent.click(screen.getByRole('button', { name: /^start/i }));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalled());
+ expect(useAppStore.getState().actuationSupported).toBe(true);
+ });
});
diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx
index 0988cf2..5436105 100644
--- a/src/components/EntityStatusControl.tsx
+++ b/src/components/EntityStatusControl.tsx
@@ -81,6 +81,7 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
const client = useAppStore((s) => s.client);
const status = useAppStore((s) => s.statusByEntity[entityStatusKey(entityType, entityId)]);
const fetchEntityStatus = useAppStore((s) => s.fetchEntityStatus);
+ const setActuationSupported = useAppStore((s) => s.setActuationSupported);
const [pendingAction, setPendingAction] = useState(null);
const [confirmAction, setConfirmAction] = useState(null);
@@ -109,9 +110,14 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
setError(null);
try {
const result = await setStatus(client, entityType, entityId, action);
- if (result.response.status === 501) {
- // Mark the cache as unavailable so the control disables uniformly.
- await fetchEntityStatus(entityType, entityId);
+ const httpStatus = result.response?.status;
+ if (httpStatus === 501) {
+ // The gateway has no actuation provider: record it gateway-wide
+ // so every transition button disables, and warn (not error) -
+ // this is a missing capability, not a failed request.
+ setActuationSupported(false);
+ const msg = result.error?.message;
+ toast.warning(`${action} is not implemented by this gateway${msg ? `: ${msg}` : ''}`);
return;
}
if (result.error) {
@@ -120,6 +126,8 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
toast.error(`Failed to ${action} ${entityId}: ${message}`);
return;
}
+ // Any 2xx proves the gateway can actuate; clear a stale "unsupported".
+ setActuationSupported(true);
toast.success(`${action} requested for ${entityId}`);
await fetchEntityStatus(entityType, entityId);
} catch (err) {
@@ -130,7 +138,7 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
setPendingAction(null);
}
},
- [client, entityType, entityId, fetchEntityStatus]
+ [client, entityType, entityId, fetchEntityStatus, setActuationSupported]
);
const handleClick = useCallback(
From 3b1b1e7a5fc7da0a9b613cd864ff6301a46c5ec8 Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 12:33:32 +0200
Subject: [PATCH 09/11] feat: surface 'transitions not implemented' state on
the control
---
src/components/EntityStatusControl.test.tsx | 21 ++++++++++++++++++++-
src/components/EntityStatusControl.tsx | 18 +++++++++++++++++-
2 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx
index 71c234d..61cd9d1 100644
--- a/src/components/EntityStatusControl.test.tsx
+++ b/src/components/EntityStatusControl.test.tsx
@@ -74,7 +74,12 @@ describe('EntityStatusControl', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSetStatus.mockResolvedValue(ok(204));
- useAppStore.setState({ statusByEntity: {}, fetchEntityStatus: vi.fn(), client: fakeClient });
+ useAppStore.setState({
+ statusByEntity: {},
+ fetchEntityStatus: vi.fn(),
+ client: fakeClient,
+ actuationSupported: null,
+ });
});
afterEach(() => {
@@ -242,4 +247,18 @@ describe('EntityStatusControl', () => {
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();
+
+ 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();
+ });
});
diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx
index 5436105..2a973c9 100644
--- a/src/components/EntityStatusControl.tsx
+++ b/src/components/EntityStatusControl.tsx
@@ -82,6 +82,7 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
const status = useAppStore((s) => s.statusByEntity[entityStatusKey(entityType, entityId)]);
const fetchEntityStatus = useAppStore((s) => s.fetchEntityStatus);
const setActuationSupported = useAppStore((s) => s.setActuationSupported);
+ const actuationSupported = useAppStore((s) => s.actuationSupported);
const [pendingAction, setPendingAction] = useState(null);
const [confirmAction, setConfirmAction] = useState(null);
@@ -93,11 +94,19 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
}, [entityType, entityId, fetchEntityStatus]);
const notAvailable = status === 'unavailable';
+ // A 501 from any transition means the gateway has no actuation provider:
+ // disable every action (Start included), gateway-wide.
+ const actuationUnsupported = actuationSupported === false;
const isDisabled = (action: LifecycleAction): boolean =>
- !client || notAvailable || pendingAction !== null || (DISABLED_BY_STATUS[status ?? '']?.has(action) ?? false);
+ !client ||
+ notAvailable ||
+ actuationUnsupported ||
+ pendingAction !== null ||
+ (DISABLED_BY_STATUS[status ?? '']?.has(action) ?? false);
const tooltipFor = (action: LifecycleAction): string => {
+ if (actuationUnsupported) return 'Not implemented by this gateway';
if (status === 'ready' && action === 'start') return 'Already running';
if (status === 'notReady' && DISABLED_BY_STATUS.notReady!.has(action)) return 'Entity is not running';
return '';
@@ -235,6 +244,13 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
})}
+ {actuationUnsupported && (
+
+
+ Transitions not implemented by this gateway (yet)
+
+ )}
+
{error && !notAvailable && (
{error}
From 35c2b5debe669b63ba9cf56c0af129d33d0b1c72 Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 14:15:46 +0200
Subject: [PATCH 10/11] feat: show entity description as the label when
available
Prefer an entity's description (e.g. a component's host OS string
'Ubuntu 24.04.4 LTS on x86_64') over the raw name/hostname for the tree
node label and the component detail header. The hostname/id stays
discoverable via the tree node tooltip and the detail path. Falls back
to the name when there is no description.
---
src/components/EntityDetailPanel.tsx | 4 ++-
src/components/EntityTreeNode.test.tsx | 37 ++++++++++++++++++++++++++
src/components/EntityTreeNode.tsx | 5 ++--
src/lib/types.ts | 2 ++
4 files changed, 45 insertions(+), 3 deletions(-)
diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx
index 985b917..8c354fd 100644
--- a/src/components/EntityDetailPanel.tsx
+++ b/src/components/EntityDetailPanel.tsx
@@ -778,7 +778,9 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
{getEntityTypeIcon()}
- {selectedEntity.name}
+
+ {selectedEntity.description || selectedEntity.name}
+ {selectedEntity.type}•
diff --git a/src/components/EntityTreeNode.test.tsx b/src/components/EntityTreeNode.test.tsx
index be3d5a1..ba63638 100644
--- a/src/components/EntityTreeNode.test.tsx
+++ b/src/components/EntityTreeNode.test.tsx
@@ -70,3 +70,40 @@ describe('EntityTreeNode lifecycle lamp', () => {
expect(screen.queryByLabelText(/status:/i)).not.toBeInTheDocument();
});
});
+
+describe('EntityTreeNode label', () => {
+ beforeEach(() => {
+ useAppStore.setState({ statusByEntity: {}, expandedPaths: [], loadingPaths: [], selectedPath: null });
+ });
+ afterEach(() => cleanup());
+
+ it('shows the entity description as the label when present', () => {
+ useAppStore.setState({ fetchEntityStatus: vi.fn() } as never);
+ render(
+
+ );
+ expect(screen.getByText('Ubuntu 24.04.4 LTS on x86_64')).toBeInTheDocument();
+ });
+
+ it('falls back to the name when there is no description', () => {
+ useAppStore.setState({ fetchEntityStatus: vi.fn() } as never);
+ render(
+
+ );
+ expect(screen.getByText('talker')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx
index e841db7..91b43ee 100644
--- a/src/components/EntityTreeNode.tsx
+++ b/src/components/EntityTreeNode.tsx
@@ -261,8 +261,9 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) {
/>
)}
-
- {typeof node.name === 'string' ? node.name : String(node.name || node.id || '')}
+
+ {(typeof node.description === 'string' && node.description) ||
+ (typeof node.name === 'string' ? node.name : String(node.name || node.id || ''))}
{/* Topic direction indicators */}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 35252ac..ddef9ec 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -126,6 +126,8 @@ export interface SovdEntity {
id: string;
/** Display name */
name: string;
+ /** Optional human-friendly description (e.g. the host OS for a component). */
+ description?: string;
/** Entity type (e.g., "component", "application", "signal") */
type: string;
/** API path for this entity */
From 0c81157fdae41acf53e5005407d82638444019df Mon Sep 17 00:00:00 2001
From: Bartosz Burda
Date: Thu, 25 Jun 2026 20:17:51 +0200
Subject: [PATCH 11/11] fix: reset stale lifecycle error on entity switch and
drop unused status prop
The EntityStatusControl reads readiness from the shared store keyed by
entity, so the declared status prop was dead. Remove it and clarify the
status doc comment to reference the GET /apps/{id} and GET /components/{id}
responses. Also clear the local error on entity change so a failed
transition on one entity cannot linger after the selection switches.
---
src/components/EntityStatusControl.tsx | 5 +++--
src/lib/types.ts | 2 +-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx
index 2a973c9..3dcc7b7 100644
--- a/src/components/EntityStatusControl.tsx
+++ b/src/components/EntityStatusControl.tsx
@@ -33,8 +33,6 @@ import type { LifecycleAction } from '@/lib/types';
interface EntityStatusControlProps {
entityType: LifecycleEntityType;
entityId: string;
- /** Initial readiness value from the entity detail (AppDetail/ComponentDetail.status). */
- status?: string;
}
interface ActionConfig {
@@ -89,7 +87,10 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
const [error, setError] = useState(null);
// Fetch the live status on mount; the slice de-dupes against the tree lamp.
+ // Clear any prior error so a failed transition on one entity can't linger in
+ // the badge area after the selection switches to another entity.
useEffect(() => {
+ setError(null);
fetchEntityStatus(entityType, entityId);
}, [entityType, entityId, fetchEntityStatus]);
diff --git a/src/lib/types.ts b/src/lib/types.ts
index ddef9ec..7d75b98 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -80,7 +80,7 @@ export type LifecycleAction = 'start' | 'restart' | 'force-restart' | 'shutdown'
/**
* Lifecycle readiness value reported by GET /{entity}/{id}/status and carried
- * on AppDetail/ComponentDetail.
+ * in the `status` field of the GET /apps/{id} and GET /components/{id} responses.
*/
export type LifecycleStatus = 'ready' | 'notReady';