From 587444a83d63f251c046f6bd469397597cce7b13 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 25 Jun 2026 10:10:11 +0200 Subject: [PATCH 01/23] 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 + + )} +
+ +
+ {ACTIONS.map(({ action, label, icon: Icon, variant }) => { + const isPending = pendingAction === action; + return ( + + ); + })} +
+ + {error && !notAvailable && ( +

+ {error} +

+ )} +
+ ); +} 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/23] 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/23] 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/23] 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}

)} + + !open && setConfirmAction(null)}> + + + Confirm {confirmLabel}? + + This will {confirmLabel.toLowerCase()} {entityId}. The transition interrupts the entity and + may trigger faults. + + + + + + + + ); } From 01c842f2ab16d6f2c4897652b617a1d38c8d9cda Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 25 Jun 2026 11:24:28 +0200 Subject: [PATCH 05/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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'; From 571c768c0a0c4486c3b750206d80df1f4de0e644 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 21 Aug 2026 20:39:17 +0200 Subject: [PATCH 12/23] fix: scope lifecycle readiness to the gateway session that read it Entity ids are not unique across gateways, so a `statusByEntity` entry or an in-flight status request that outlives a session renders one robot's readiness for another. Connect and disconnect now clear both the cache and the dedupe map, and a status response is only written when the client that issued it is still the current one. --- src/lib/store.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++- src/lib/store.ts | 31 ++++++++++++++++++++--- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts index 250bc1b..4f66f21 100644 --- a/src/lib/store.test.ts +++ b/src/lib/store.test.ts @@ -8,7 +8,7 @@ vi.mock('./api-dispatch', () => ({ setStatus: vi.fn(), })); -import { useAppStore, entityStatusKey } from './store'; +import { useAppStore, entityStatusKey, __resetStatusRequestCache } from './store'; import * as api from './api-dispatch'; const getStatusMock = vi.mocked(api.getStatus); @@ -48,6 +48,61 @@ describe('fetchEntityStatus', () => { }); }); +describe('lifecycle status cache across sessions', () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetStatusRequestCache(); + useAppStore.setState({ statusByEntity: {}, client: {} as never }); + }); + + it('disconnect clears the cached statuses', () => { + useAppStore.setState({ statusByEntity: { 'components:host1': 'ready' } }); + useAppStore.getState().disconnect(); + expect(useAppStore.getState().statusByEntity).toEqual({}); + }); + + it('connect clears the cached statuses before it reaches the network', async () => { + useAppStore.setState({ statusByEntity: { 'components:host1': 'ready' } }); + // The reset is in connect's first synchronous `set`, so it is observable + // without waiting for (or reaching) the health check. + const pending = useAppStore.getState().connect('http://127.0.0.1:1/'); + expect(useAppStore.getState().statusByEntity).toEqual({}); + await pending; + }); + + it('a fetch left in flight by the previous session does not satisfy the next one', async () => { + // Entity ids collide across gateways ('components:host1' is not unique + // per robot), so a promise held over a reconnect would answer the new + // session with the old gateway's readiness and never hit the network. + getStatusMock.mockReturnValue(new Promise(() => {}) as never); + void useAppStore.getState().fetchEntityStatus('components', 'host1'); + expect(getStatusMock).toHaveBeenCalledTimes(1); + + useAppStore.getState().disconnect(); + useAppStore.setState({ client: {} as never }); + getStatusMock.mockResolvedValue({ data: { status: 'notReady' }, response: { status: 200 } } as never); + + await useAppStore.getState().fetchEntityStatus('components', 'host1'); + + expect(getStatusMock).toHaveBeenCalledTimes(2); + expect(useAppStore.getState().statusByEntity[entityStatusKey('components', 'host1')]).toBe('notReady'); + }); + + it('a late response from the previous session is not written into the new one', async () => { + let settle: (value: unknown) => void = () => {}; + getStatusMock.mockReturnValue(new Promise((resolve) => (settle = resolve)) as never); + const stale = useAppStore.getState().fetchEntityStatus('components', 'host1'); + + useAppStore.getState().disconnect(); + useAppStore.setState({ client: {} as never, statusByEntity: { 'components:host1': 'notReady' } }); + + settle({ data: { status: 'ready' }, response: { status: 200 } }); + await stale; + + expect(useAppStore.getState().statusByEntity[entityStatusKey('components', 'host1')]).toBe('notReady'); + }); +}); + describe('actuationSupported', () => { it('defaults to null and setActuationSupported updates it', () => { useAppStore.setState({ actuationSupported: null }); diff --git a/src/lib/store.ts b/src/lib/store.ts index 039d15e..4b841aa 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -911,8 +911,18 @@ export const useAppStore = create()( // Connect to ros2_medkit gateway connect: async (url: string) => { - // Clear any stale actuation flag so a reconnect re-probes support. - set({ isConnecting: true, connectionError: null, actuationSupported: null }); + // Drop everything the previous session learned about lifecycle: + // entity ids are not unique across gateways, so a surviving + // `statusByEntity` entry would render one robot's readiness for + // another, and a surviving in-flight promise would be handed to + // the new session instead of a request against the new gateway. + __resetStatusRequestCache(); + set({ + isConnecting: true, + connectionError: null, + actuationSupported: null, + statusByEntity: {}, + }); try { const client = createMedkitClient({ baseUrl: url, fetch: fetch.bind(globalThis) }); @@ -970,6 +980,10 @@ export const useAppStore = create()( // Stop execution polling get().stopExecutionPolling(); + // See connect: lifecycle readiness is scoped to one gateway, and + // an in-flight request must not be handed to the next session. + __resetStatusRequestCache(); + // Unsubscribe from fault stream get().unsubscribeFaultStream(); @@ -986,6 +1000,7 @@ export const useAppStore = create()( selectedEntity: null, activeExecutions: new Map(), actuationSupported: null, + statusByEntity: {}, }); }, @@ -1944,12 +1959,20 @@ export const useAppStore = create()( } else if (result.data?.status === 'ready' || result.data?.status === 'notReady') { value = result.data.status; } - set((s) => ({ statusByEntity: { ...s.statusByEntity, [key]: value } })); + writeStatus(value); } catch { - set((s) => ({ statusByEntity: { ...s.statusByEntity, [key]: 'unknown' } })); + writeStatus('unknown'); } finally { inFlightStatusRequests.delete(key); } + + // A response that outlived its session belongs to a gateway + // the UI is no longer talking to, and entity ids collide + // across gateways, so it must not land in the new cache. + function writeStatus(value: EntityStatusValue): void { + if (get().client !== client) return; + set((s) => ({ statusByEntity: { ...s.statusByEntity, [key]: value } })); + } })(); inFlightStatusRequests.set(key, request); From eef32bf92b33c16ea43d61a668065ceccbb6a6c8 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 21 Aug 2026 20:39:35 +0200 Subject: [PATCH 13/23] fix: decide a lifecycle transition by its HTTP status, not by the error body openapi-fetch leaves the error value falsy whenever a failed response carries nothing it can parse - undefined for 204, HEAD or `Content-Length: 0`, and the empty string for an empty body with no Content-Length. A 5xx from a proxy or an aborting gateway therefore reached the success path: a "requested" toast, a status refetch, and the gateway recorded as able to actuate. The outcome now comes from `response.ok`, with a status-derived message when the body says nothing. --- src/components/EntityStatusControl.test.tsx | 59 +++++++++++++++++++-- src/components/EntityStatusControl.tsx | 32 +++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx index 61cd9d1..1d5e217 100644 --- a/src/components/EntityStatusControl.test.tsx +++ b/src/components/EntityStatusControl.test.tsx @@ -47,13 +47,30 @@ import { EntityStatusControl } from './EntityStatusControl'; const fakeClient = { __fake: true } as never; -/** Build an openapi-fetch style result. */ +/** + * Build an openapi-fetch style result. `ok` is derived from the status the same + * way `Response.ok` is, because that is the field the control decides success + * on - a double that omits it cannot tell a 204 from a 502. + */ function ok(status: number, data: unknown = undefined) { - return { data, error: undefined, response: { status } as Response }; + return { data, error: undefined, response: httpResponse(status) }; } function errResult(status: number, message: string) { - return { data: undefined, error: { message }, response: { status } as Response }; + return { data: undefined, error: { message }, response: httpResponse(status) }; +} + +/** + * A non-2xx whose body openapi-fetch could not turn into an error value: + * `{ error: undefined }` for 204/HEAD/`Content-Length: 0`, `''` for an empty + * body with no `Content-Length` (openapi-fetch 0.17.0, src/index.js:245/268). + */ +function emptyBodyFailure(status: number, error: unknown = undefined) { + return { data: undefined, error, response: httpResponse(status) }; +} + +function httpResponse(status: number): Response { + return { status, ok: status >= 200 && status < 300 } as Response; } /** @@ -248,6 +265,42 @@ describe('EntityStatusControl', () => { expect(useAppStore.getState().actuationSupported).toBe(true); }); + it.each([ + ['undefined error (Content-Length: 0)', undefined], + ['empty-string error (empty body, no Content-Length)', ''], + ])('treats a 502 with an %s as a failure, not a success', async (_label, errorValue) => { + seedStatus('apps:planner', 'notReady'); + useAppStore.setState({ actuationSupported: null }); + mockSetStatus.mockResolvedValue(emptyBodyFailure(502, errorValue)); + renderControl(); + + await userEvent.click(screen.getByRole('button', { name: /^start/i })); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(toast.success).not.toHaveBeenCalled(); + // A failed transition proves nothing about actuation support. + expect(useAppStore.getState().actuationSupported).toBeNull(); + // The gateway said nothing usable, so the status has to carry the message. + expect(await screen.findByRole('alert')).toHaveTextContent(/502/); + }); + + it('does not refetch the status after a failed transition', async () => { + const refresh = vi.fn(); + useAppStore.setState({ + statusByEntity: { 'apps:planner': 'notReady' }, + fetchEntityStatus: refresh, + client: fakeClient, + }); + mockSetStatus.mockResolvedValue(emptyBodyFailure(503)); + renderControl(); + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + + await userEvent.click(screen.getByRole('button', { name: /^start/i })); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(refresh).toHaveBeenCalledTimes(1); + }); + // ----------------------------------------------------------------------- // Task C: disable + "not implemented" note when actuationSupported === false // ----------------------------------------------------------------------- diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx index 3dcc7b7..81e532e 100644 --- a/src/components/EntityStatusControl.tsx +++ b/src/components/EntityStatusControl.tsx @@ -60,6 +60,25 @@ const DISABLED_BY_STATUS: Record> = { /** Destructive transitions get the destructive confirm-button variant. */ const DESTRUCTIVE_ACTIONS = new Set(['shutdown', 'force-shutdown']); +/** + * Message carried by an openapi-fetch error value, which is the parsed JSON body + * when there is one and the raw text otherwise. Returns '' when the body held + * nothing usable, so callers can fall back to a status-derived message. + */ +function errorMessageOf(error: unknown): string { + if (typeof error === 'string') return error.trim(); + if (error && typeof error === 'object' && 'message' in error) { + const message = (error as { message?: unknown }).message; + if (typeof message === 'string') return message.trim(); + } + return ''; +} + +/** Fallback for a failed transition whose response body said nothing. */ +function failureMessage(action: LifecycleAction, httpStatus: number | undefined): string { + return httpStatus ? `Failed to ${action}: the gateway answered HTTP ${httpStatus}` : `Failed to ${action}`; +} + /** * 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 @@ -126,12 +145,19 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro // 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; + const msg = errorMessageOf(result.error); toast.warning(`${action} is not implemented by this gateway${msg ? `: ${msg}` : ''}`); return; } - if (result.error) { - const message = result.error.message || `Failed to ${action}`; + // Success is the HTTP status, never the truthiness of `error`. + // openapi-fetch yields a falsy `error` on a failed request whenever + // the body carries nothing it can parse - `undefined` for 204/HEAD + // or `Content-Length: 0`, `''` for an empty body with no + // Content-Length - which a proxy or an aborting gateway produces on + // a 5xx. Branching on `error` reports those as a completed + // transition and marks the gateway as able to actuate. + if (!result.response?.ok) { + const message = errorMessageOf(result.error) || failureMessage(action, httpStatus); setError(message); toast.error(`Failed to ${action} ${entityId}: ${message}`); return; From 74372424dab42a2f8cab373cd8c14cea7e6b952c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 21 Aug 2026 20:39:45 +0200 Subject: [PATCH 14/23] fix: disable Force restart on an entity that is not running Restart, Shutdown and Force shutdown were already unavailable on a notReady entity; Force restart sat enabled beside them, opened the confirmation dialog and dispatched a restart against a stopped entity. Start is now the only action offered in that state, which is what the README describes. --- src/components/EntityStatusControl.test.tsx | 11 +++++++++++ src/components/EntityStatusControl.tsx | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx index 1d5e217..c13dace 100644 --- a/src/components/EntityStatusControl.test.tsx +++ b/src/components/EntityStatusControl.test.tsx @@ -209,6 +209,17 @@ describe('EntityStatusControl', () => { expect(screen.getByRole('button', { name: /^shutdown/i })).toBeDisabled(); }); + it('leaves Start as the only enabled action when status is notReady', async () => { + // Every restart and shutdown variant interrupts a running entity, so on a + // stopped one they are all unavailable - Force restart included. + seedStatus('apps:planner', 'notReady'); + renderControl(); + expect(await screen.findByRole('button', { name: /^start$/i })).toBeEnabled(); + for (const name of [/^restart$/i, /force restart/i, /^shutdown$/i, /force shutdown/i]) { + expect(screen.getByRole('button', { name })).toBeDisabled(); + } + }); + // ----------------------------------------------------------------------- // Task 3: confirmation dialog for non-Start actions // ----------------------------------------------------------------------- diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx index 81e532e..85226e4 100644 --- a/src/components/EntityStatusControl.tsx +++ b/src/components/EntityStatusControl.tsx @@ -54,7 +54,7 @@ const ACTIONS: ActionConfig[] = [ /** Transitions disabled for a given cached readiness value. */ const DISABLED_BY_STATUS: Record> = { ready: new Set(['start']), - notReady: new Set(['restart', 'shutdown', 'force-shutdown']), + notReady: new Set(['restart', 'force-restart', 'shutdown', 'force-shutdown']), }; /** Destructive transitions get the destructive confirm-button variant. */ From 49fc037fb30d6200aa3b4f2017d473857eb39d67 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 21 Aug 2026 20:42:12 +0200 Subject: [PATCH 15/23] fix: keep an in-flight transition off the next selected entity The control is rendered at a fixed position in EntityDetailPanel and AppsPanel, so selecting another entity changes entityId without remounting. A restart still in flight left its spinner and its disabled buttons on the new entity, and its failure wrote an inline error under the new entity's badge. The per-entity UI state is cleared on selection change, and a response is only written back when the entity it was dispatched against is still the one shown. --- src/components/EntityStatusControl.test.tsx | 38 +++++++++++++++++++++ src/components/EntityStatusControl.tsx | 30 ++++++++++++---- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx index c13dace..53769a3 100644 --- a/src/components/EntityStatusControl.test.tsx +++ b/src/components/EntityStatusControl.test.tsx @@ -312,6 +312,44 @@ describe('EntityStatusControl', () => { expect(refresh).toHaveBeenCalledTimes(1); }); + // ----------------------------------------------------------------------- + // The control sits at a fixed position in EntityDetailPanel and AppsPanel, + // so selecting another entity changes entityId without remounting. + // ----------------------------------------------------------------------- + + it('does not carry a pending action over to the next selected entity', async () => { + const user = userEvent.setup(); + let finish: (value: unknown) => void = () => {}; + mockSetStatus.mockReturnValue(new Promise((resolve) => (finish = resolve))); + useAppStore.setState({ + statusByEntity: { 'apps:alpha': 'ready', 'apps:beta': 'ready' }, + fetchEntityStatus: vi.fn(), + client: fakeClient, + }); + + const { rerender } = renderControl(); + await user.click(screen.getByRole('button', { name: /^shutdown$/i })); + await user.click(await screen.findByRole('button', { name: /confirm/i })); + await waitFor(() => expect(mockSetStatus).toHaveBeenCalledTimes(1)); + expect(screen.getByRole('button', { name: /^restart$/i })).toBeDisabled(); + + rerender( + + + + ); + + // beta is ready and has nothing in flight: everything but Start is live. + await waitFor(() => expect(screen.getByRole('button', { name: /^restart$/i })).toBeEnabled()); + + finish(errResult(500, 'alpha refused')); + + // alpha's failure is still reported, but it must not land on beta's panel. + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('alpha'))); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^restart$/i })).toBeEnabled(); + }); + // ----------------------------------------------------------------------- // Task C: disable + "not implemented" note when actuationSupported === false // ----------------------------------------------------------------------- diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx index 85226e4..bc3c223 100644 --- a/src/components/EntityStatusControl.tsx +++ b/src/components/EntityStatusControl.tsx @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useRef, 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'; @@ -105,11 +105,23 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro const [confirmAction, setConfirmAction] = useState(null); const [error, setError] = useState(null); + // The entity this control is currently showing. A transition already in + // flight captured the entity it was dispatched against, and resolves against + // whatever is selected by then; comparing the two is what keeps its result + // off an unrelated entity's panel. + const shownKey = entityStatusKey(entityType, entityId); + const shownKeyRef = useRef(shownKey); + shownKeyRef.current = shownKey; + // 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. + // The control is rendered at a fixed position in EntityDetailPanel and + // AppsPanel, so a new selection changes entityId without remounting: the + // per-entity UI state has to be cleared here or it belongs to the previous + // entity. useEffect(() => { setError(null); + setPendingAction(null); + setConfirmAction(null); fetchEntityStatus(entityType, entityId); }, [entityType, entityId, fetchEntityStatus]); @@ -135,6 +147,8 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro const dispatchAction = useCallback( async (action: LifecycleAction) => { if (!client) return; + const dispatchedFor = entityStatusKey(entityType, entityId); + const stillShown = () => shownKeyRef.current === dispatchedFor; setPendingAction(action); setError(null); try { @@ -158,7 +172,9 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro // transition and marks the gateway as able to actuate. if (!result.response?.ok) { const message = errorMessageOf(result.error) || failureMessage(action, httpStatus); - setError(message); + // The toast names the entity, so it stays useful after the + // selection moves on; the inline error does not. + if (stillShown()) setError(message); toast.error(`Failed to ${action} ${entityId}: ${message}`); return; } @@ -168,10 +184,12 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro await fetchEntityStatus(entityType, entityId); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; - setError(message); + if (stillShown()) setError(message); toast.error(`Failed to ${action} ${entityId}: ${message}`); } finally { - setPendingAction(null); + // A late finish must not clear a spinner that now belongs to a + // transition dispatched against the newly selected entity. + if (stillShown()) setPendingAction(null); } }, [client, entityType, entityId, fetchEntityStatus, setActuationSupported] From 0514ecf6e89fc7d5f2eddde90996e3ffa7d2bda1 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 21 Aug 2026 20:42:14 +0200 Subject: [PATCH 16/23] fix: expose the tree readiness lamp to assistive technology The lamp was an empty span, which maps to the generic role - aria-label is prohibited there and browsers drop it, so the readiness never reached the accessibility tree and colour was the only channel left. It now carries role="img", and shape distinguishes the states as well as colour: a filled disc for ready, a hollow ring for notReady, a square for a state the UI has not established. --- src/components/EntityTreeNode.test.tsx | 47 ++++++++++++++++++++++++++ src/components/EntityTreeNode.tsx | 20 +++++++---- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/components/EntityTreeNode.test.tsx b/src/components/EntityTreeNode.test.tsx index ba63638..9736b98 100644 --- a/src/components/EntityTreeNode.test.tsx +++ b/src/components/EntityTreeNode.test.tsx @@ -62,6 +62,53 @@ describe('EntityTreeNode lifecycle lamp', () => { expect(screen.getByLabelText(/status: notReady/i)).toBeInTheDocument(); }); + it('exposes the lamp to the accessibility tree with a computed name', () => { + // getByLabelText reads the attribute; getByRole computes the accessible + // name the way a screen reader does, so it fails on an element whose + // implicit role forbids aria-label. + const fetchEntityStatus = vi.fn(); + useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' }, fetchEntityStatus } as never); + render( + + ); + expect(screen.getByRole('img', { name: /status: ready/i })).toBeInTheDocument(); + }); + + it('separates ready from notReady by more than colour', () => { + const fetchEntityStatus = vi.fn(); + useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' }, fetchEntityStatus } as never); + render( + + ); + const ready = screen.getByRole('img', { name: /status: ready/i }).className; + cleanup(); + + useAppStore.setState({ statusByEntity: { 'apps:talker': 'notReady' }, fetchEntityStatus } as never); + render( + + ); + const notReady = screen.getByRole('img', { name: /status: notReady/i }).className; + + // Drop every class carrying a colour token: what is left is the shape, + // and it has to differ on its own for a colour-blind reader. + const shapeOf = (cls: string) => + cls + .split(/\s+/) + .filter((c) => !/emerald|amber|muted-foreground|transparent/.test(c)) + .sort() + .join(' '); + expect(shapeOf(ready)).not.toBe(shapeOf(notReady)); + }); + it('area node renders no lamp and triggers no status fetch', () => { const fetchEntityStatus = vi.fn(); useAppStore.setState({ fetchEntityStatus } as never); diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index 91b43ee..eb6dbac 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -115,17 +115,19 @@ function getEntityColor(type: string, isSelected?: boolean): string { } /** - * Tailwind colour for the readiness lamp. Green = ready, amber = notReady, - * grey for unavailable / unknown / not-yet-fetched. + * Tailwind classes for the readiness lamp. Colour and shape both carry the + * state, so the lamp still reads for someone who cannot separate green from + * amber: ready is a filled disc, notReady a hollow ring, and anything the UI + * has not established a square. */ -function getLampColor(status: string | undefined): string { +function getLampClass(status: string | undefined): string { switch (status) { case 'ready': - return 'bg-emerald-500'; + return 'rounded-full bg-emerald-500'; case 'notReady': - return 'bg-amber-500'; + return 'rounded-full border-2 border-amber-500 bg-transparent'; default: - return 'bg-muted-foreground/40'; + return 'rounded-sm bg-muted-foreground/40'; } } @@ -255,9 +257,13 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { {isLifecycleEntity && ( + // role="img" is what carries the label: a span with no role + // and no content maps to `generic`, on which aria-label is + // prohibited and dropped, leaving colour as the only channel. )} From 56509087ceb12a28707f9f11287fba21bb0662ed Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 21 Aug 2026 21:24:07 +0200 Subject: [PATCH 17/23] feat: keep lifecycle readiness current while an entity is on screen Readiness was read once per mount and never again, so a tree lamp stayed at whatever the entity was when the branch was opened and a crashed app kept a green lamp indefinitely. The same gap made the post-transition read wrong: a gateway answers 202 (accepted) long before a node has restarted, so reading immediately returned the readiness from before the transition and the gating acted on it. Entities register interest through watchEntityStatus for as long as they are mounted, and a refresh loop re-reads exactly those. A transition now drops the cached value instead of reading it back, and a read carrying an older generation is discarded so one issued before the change cannot restore it. --- README.md | 4 +- src/components/EntityDetailPanel.test.tsx | 2 +- src/components/EntityStatusControl.test.tsx | 55 +++++++--- src/components/EntityStatusControl.tsx | 25 +++-- src/components/EntityTreeNode.test.tsx | 34 +++--- src/components/EntityTreeNode.tsx | 16 +-- src/lib/store.test.ts | 115 +++++++++++++++++++- src/lib/store.ts | 102 ++++++++++++++++- 8 files changed, 293 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 8667b10..e77cd85 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, with a readiness lamp on app and component nodes (green = ready, amber = not ready) +- **Entity Tree Sidebar** - Browse the hierarchical structure of SOVD entities with lazy-loading, with a readiness lamp on app and component nodes (a green disc for ready, an amber ring for not ready, a grey square for a readiness the UI has not established). The lamp is re-read while the branch is open, so it tracks an entity that stops or comes back - **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 +- **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. A transition is only reported as requested when the gateway accepts it; because acceptance is not completion, the readiness is dropped and re-established by the refresh rather than read back straight away 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 84e3a3c..2eea3f0 100644 --- a/src/components/EntityDetailPanel.test.tsx +++ b/src/components/EntityDetailPanel.test.tsx @@ -70,7 +70,7 @@ function setStore(overrides: Record) { // so the rendered control mounts without touching the network. client: null, statusByEntity: {}, - fetchEntityStatus: vi.fn(), + watchEntityStatus: vi.fn(() => () => {}), ...overrides, }; } diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx index 53769a3..c218c8a 100644 --- a/src/components/EntityStatusControl.test.tsx +++ b/src/components/EntityStatusControl.test.tsx @@ -22,8 +22,8 @@ import { TooltipProvider } from '@/components/ui/tooltip'; // // 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 +// stays real so the store module loads. watchEntityStatus is seeded as a no-op +// vi.fn() in every test so the on-mount read does not overwrite the seeded // status with 'unknown' against the fake client. // --------------------------------------------------------------------------- @@ -80,11 +80,14 @@ function httpResponse(status: number): Response { function seedStatus(key: string, value: string) { useAppStore.setState({ statusByEntity: { [key]: value as never }, - fetchEntityStatus: vi.fn(), + watchEntityStatus: noopWatch, client: fakeClient, }); } +/** watchEntityStatus stand-in: registers nothing, unsubscribes to nothing. */ +const noopWatch = vi.fn(() => () => {}); + const renderControl = (ui: React.ReactElement) => render({ui}); describe('EntityStatusControl', () => { @@ -93,7 +96,7 @@ describe('EntityStatusControl', () => { mockSetStatus.mockResolvedValue(ok(204)); useAppStore.setState({ statusByEntity: {}, - fetchEntityStatus: vi.fn(), + watchEntityStatus: noopWatch, client: fakeClient, actuationSupported: null, }); @@ -140,24 +143,39 @@ describe('EntityStatusControl', () => { expect(call[3]).toBe('restart'); }); - it('refreshes the status after a successful confirmed action', async () => { + it('drops the cached readiness after a successful confirmed action', async () => { const user = userEvent.setup(); - const refresh = vi.fn(); + const invalidate = vi.fn(); useAppStore.setState({ statusByEntity: { 'apps:motor': 'ready' }, - fetchEntityStatus: refresh, + watchEntityStatus: noopWatch, + invalidateEntityStatus: invalidate, client: fakeClient, }); renderControl(); - // 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)); + // 202 means accepted: reading now would return the pre-transition value, + // so the value is dropped and the refresh loop establishes the new one. + await waitFor(() => expect(invalidate).toHaveBeenCalledWith('apps', 'motor')); + }); + + it('watches the entity for as long as it is shown, and stops on unmount', () => { + const unwatch = vi.fn(); + const watch = vi.fn(() => unwatch); + useAppStore.setState({ + statusByEntity: { 'apps:motor': 'ready' }, + watchEntityStatus: watch, + client: fakeClient, + }); + const { unmount } = renderControl(); + expect(watch).toHaveBeenCalledWith('apps', 'motor'); + expect(unwatch).not.toHaveBeenCalled(); + + unmount(); + expect(unwatch).toHaveBeenCalledTimes(1); }); it('shows a disabled "not available" state when status is unavailable (501)', async () => { @@ -295,21 +313,22 @@ describe('EntityStatusControl', () => { expect(await screen.findByRole('alert')).toHaveTextContent(/502/); }); - it('does not refetch the status after a failed transition', async () => { - const refresh = vi.fn(); + it('keeps the known readiness after a failed transition', async () => { + const invalidate = vi.fn(); useAppStore.setState({ statusByEntity: { 'apps:planner': 'notReady' }, - fetchEntityStatus: refresh, + watchEntityStatus: noopWatch, + invalidateEntityStatus: invalidate, client: fakeClient, }); mockSetStatus.mockResolvedValue(emptyBodyFailure(503)); renderControl(); - await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); await userEvent.click(screen.getByRole('button', { name: /^start/i })); await waitFor(() => expect(toast.error).toHaveBeenCalled()); - expect(refresh).toHaveBeenCalledTimes(1); + // Nothing moved, so the readiness the UI already has is still correct. + expect(invalidate).not.toHaveBeenCalled(); }); // ----------------------------------------------------------------------- @@ -323,7 +342,7 @@ describe('EntityStatusControl', () => { mockSetStatus.mockReturnValue(new Promise((resolve) => (finish = resolve))); useAppStore.setState({ statusByEntity: { 'apps:alpha': 'ready', 'apps:beta': 'ready' }, - fetchEntityStatus: vi.fn(), + watchEntityStatus: noopWatch, client: fakeClient, }); diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx index bc3c223..f31f6cc 100644 --- a/src/components/EntityStatusControl.tsx +++ b/src/components/EntityStatusControl.tsx @@ -85,9 +85,10 @@ function failureMessage(action: LifecycleAction, httpStatus: number | undefined) * 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), and every transition except Start asks for - * confirmation before dispatch. + * source of truth, also feeding the tree readiness lamp) and kept current by + * the slice's refresh loop for as long as this control is mounted. Actions are + * gated by 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" @@ -97,7 +98,8 @@ function failureMessage(action: LifecycleAction, httpStatus: number | undefined) 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 watchEntityStatus = useAppStore((s) => s.watchEntityStatus); + const invalidateEntityStatus = useAppStore((s) => s.invalidateEntityStatus); const setActuationSupported = useAppStore((s) => s.setActuationSupported); const actuationSupported = useAppStore((s) => s.actuationSupported); @@ -113,7 +115,8 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro const shownKeyRef = useRef(shownKey); shownKeyRef.current = shownKey; - // Fetch the live status on mount; the slice de-dupes against the tree lamp. + // Watch the live status while this entity is shown; the slice reads it once + // now and keeps it in the refresh loop until the cleanup runs. // The control is rendered at a fixed position in EntityDetailPanel and // AppsPanel, so a new selection changes entityId without remounting: the // per-entity UI state has to be cleared here or it belongs to the previous @@ -122,8 +125,8 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro setError(null); setPendingAction(null); setConfirmAction(null); - fetchEntityStatus(entityType, entityId); - }, [entityType, entityId, fetchEntityStatus]); + return watchEntityStatus(entityType, entityId); + }, [entityType, entityId, watchEntityStatus]); const notAvailable = status === 'unavailable'; // A 501 from any transition means the gateway has no actuation provider: @@ -181,7 +184,11 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro // Any 2xx proves the gateway can actuate; clear a stale "unsupported". setActuationSupported(true); toast.success(`${action} requested for ${entityId}`); - await fetchEntityStatus(entityType, entityId); + // 202 means accepted, not applied: a node takes longer to come + // back than this round trip, so reading now returns the readiness + // from before the transition and the gating would act on it. Drop + // the value instead and let the refresh loop establish the new one. + invalidateEntityStatus(entityType, entityId); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; if (stillShown()) setError(message); @@ -192,7 +199,7 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro if (stillShown()) setPendingAction(null); } }, - [client, entityType, entityId, fetchEntityStatus, setActuationSupported] + [client, entityType, entityId, invalidateEntityStatus, setActuationSupported] ); const handleClick = useCallback( diff --git a/src/components/EntityTreeNode.test.tsx b/src/components/EntityTreeNode.test.tsx index 9736b98..2331143 100644 --- a/src/components/EntityTreeNode.test.tsx +++ b/src/components/EntityTreeNode.test.tsx @@ -36,29 +36,29 @@ describe('EntityTreeNode lifecycle lamp', () => { 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); + it('app node watches status on mount and renders a lamp from the cache', () => { + const watchEntityStatus = vi.fn(() => () => {}); + useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' }, watchEntityStatus } as never); render( ); - expect(fetchEntityStatus).toHaveBeenCalledWith('apps', 'talker'); + expect(watchEntityStatus).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); + it('component node watches status on mount mapped to the plural resource type', () => { + const watchEntityStatus = vi.fn(() => () => {}); + useAppStore.setState({ statusByEntity: { 'components:host1': 'notReady' }, watchEntityStatus } as never); render( ); - expect(fetchEntityStatus).toHaveBeenCalledWith('components', 'host1'); + expect(watchEntityStatus).toHaveBeenCalledWith('components', 'host1'); expect(screen.getByLabelText(/status: notReady/i)).toBeInTheDocument(); }); @@ -66,8 +66,8 @@ describe('EntityTreeNode lifecycle lamp', () => { // getByLabelText reads the attribute; getByRole computes the accessible // name the way a screen reader does, so it fails on an element whose // implicit role forbids aria-label. - const fetchEntityStatus = vi.fn(); - useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' }, fetchEntityStatus } as never); + const watchEntityStatus = vi.fn(() => () => {}); + useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' }, watchEntityStatus } as never); render( { }); it('separates ready from notReady by more than colour', () => { - const fetchEntityStatus = vi.fn(); - useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' }, fetchEntityStatus } as never); + const watchEntityStatus = vi.fn(() => () => {}); + useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' }, watchEntityStatus } as never); render( { const ready = screen.getByRole('img', { name: /status: ready/i }).className; cleanup(); - useAppStore.setState({ statusByEntity: { 'apps:talker': 'notReady' }, fetchEntityStatus } as never); + useAppStore.setState({ statusByEntity: { 'apps:talker': 'notReady' }, watchEntityStatus } as never); render( { expect(shapeOf(ready)).not.toBe(shapeOf(notReady)); }); - it('area node renders no lamp and triggers no status fetch', () => { - const fetchEntityStatus = vi.fn(); - useAppStore.setState({ fetchEntityStatus } as never); + it('area node renders no lamp and watches nothing', () => { + const watchEntityStatus = vi.fn(() => () => {}); + useAppStore.setState({ watchEntityStatus } as never); render(); - expect(fetchEntityStatus).not.toHaveBeenCalled(); + expect(watchEntityStatus).not.toHaveBeenCalled(); expect(screen.queryByLabelText(/status:/i)).not.toBeInTheDocument(); }); }); diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index eb6dbac..f46d4d3 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -164,16 +164,16 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { const status = useAppStore((s) => isLifecycleEntity ? s.statusByEntity[entityStatusKey(lifecycleType, node.id)] : undefined ); - const fetchEntityStatus = useAppStore((s) => s.fetchEntityStatus); + const watchEntityStatus = useAppStore((s) => s.watchEntityStatus); - // 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. + // This node only mounts while its parent is expanded, so watching from here + // is what scopes the refresh loop to the branches that are actually open - + // a collapsed branch stops costing requests, and an open one stops showing + // the readiness it had when it was opened. useEffect(() => { - if (isLifecycleEntity) { - fetchEntityStatus(lifecycleType, node.id); - } - }, [isLifecycleEntity, lifecycleType, node.id, fetchEntityStatus]); + if (!isLifecycleEntity) return; + return watchEntityStatus(lifecycleType, node.id); + }, [isLifecycleEntity, lifecycleType, node.id, watchEntityStatus]); const isExpanded = expandedPaths.includes(node.path); const isLoading = loadingPaths.includes(node.path); diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts index 4f66f21..0a195ce 100644 --- a/src/lib/store.test.ts +++ b/src/lib/store.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, 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 @@ -103,6 +103,119 @@ describe('lifecycle status cache across sessions', () => { }); }); +describe('readiness refresh loop', () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetStatusRequestCache(); + useAppStore.getState().stopStatusPolling(); + useAppStore.setState({ statusByEntity: {}, client: {} as never }); + getStatusMock.mockResolvedValue({ data: { status: 'ready' }, response: { status: 200 } } as never); + }); + + afterEach(() => { + useAppStore.getState().stopStatusPolling(); + vi.useRealTimers(); + }); + + it('re-reads a watched entity on the interval', async () => { + vi.useFakeTimers(); + useAppStore.getState().watchEntityStatus('apps', 'talker'); + expect(getStatusMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5000); + expect(getStatusMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(5000); + expect(getStatusMock).toHaveBeenCalledTimes(3); + }); + + it('stops re-reading an entity once nothing watches it', async () => { + vi.useFakeTimers(); + const unwatch = useAppStore.getState().watchEntityStatus('apps', 'talker'); + await vi.advanceTimersByTimeAsync(5000); + expect(getStatusMock).toHaveBeenCalledTimes(2); + + unwatch(); + await vi.advanceTimersByTimeAsync(15000); + + expect(getStatusMock).toHaveBeenCalledTimes(2); + expect(useAppStore.getState().statusPollingIntervalId).toBeNull(); + }); + + it('keeps watching while a second watcher is still mounted', async () => { + vi.useFakeTimers(); + // The control and the tree lamp watch the same entity at once. + const unwatchA = useAppStore.getState().watchEntityStatus('apps', 'talker'); + useAppStore.getState().watchEntityStatus('apps', 'talker'); + getStatusMock.mockClear(); + + unwatchA(); + await vi.advanceTimersByTimeAsync(5000); + + expect(getStatusMock).toHaveBeenCalledTimes(1); + }); + + it('disconnect stops the loop', async () => { + vi.useFakeTimers(); + useAppStore.getState().watchEntityStatus('apps', 'talker'); + getStatusMock.mockClear(); + + useAppStore.getState().disconnect(); + await vi.advanceTimersByTimeAsync(15000); + + expect(getStatusMock).not.toHaveBeenCalled(); + expect(useAppStore.getState().statusPollingIntervalId).toBeNull(); + }); + + it('splits the cache key on its first colon so ids keep their own separators', async () => { + vi.useFakeTimers(); + useAppStore.getState().watchEntityStatus('components', 'host1'); + await vi.advanceTimersByTimeAsync(5000); + + expect(getStatusMock).toHaveBeenLastCalledWith(expect.anything(), 'components', 'host1'); + }); +}); + +describe('invalidateEntityStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetStatusRequestCache(); + useAppStore.setState({ statusByEntity: {}, client: {} as never }); + }); + + it('drops the cached value', () => { + useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' } }); + useAppStore.getState().invalidateEntityStatus('apps', 'talker'); + expect(useAppStore.getState().statusByEntity[entityStatusKey('apps', 'talker')]).toBe('unknown'); + }); + + it('a read issued before the invalidation cannot restore the old value', async () => { + // This is the read the control's own mount effect (or the refresh loop) + // had in flight when the transition was dispatched. + let settle: (value: unknown) => void = () => {}; + getStatusMock.mockReturnValue(new Promise((resolve) => (settle = resolve)) as never); + const inFlight = useAppStore.getState().fetchEntityStatus('apps', 'talker'); + + useAppStore.getState().invalidateEntityStatus('apps', 'talker'); + settle({ data: { status: 'ready' }, response: { status: 200 } }); + await inFlight; + + expect(useAppStore.getState().statusByEntity[entityStatusKey('apps', 'talker')]).toBe('unknown'); + }); + + it('the next read after an invalidation is a fresh request', async () => { + getStatusMock.mockReturnValue(new Promise(() => {}) as never); + void useAppStore.getState().fetchEntityStatus('apps', 'talker'); + expect(getStatusMock).toHaveBeenCalledTimes(1); + + useAppStore.getState().invalidateEntityStatus('apps', 'talker'); + getStatusMock.mockResolvedValue({ data: { status: 'notReady' }, response: { status: 200 } } as never); + await useAppStore.getState().fetchEntityStatus('apps', 'talker'); + + expect(getStatusMock).toHaveBeenCalledTimes(2); + expect(useAppStore.getState().statusByEntity[entityStatusKey('apps', 'talker')]).toBe('notReady'); + }); +}); + describe('actuationSupported', () => { it('defaults to null and setActuationSupported updates it', () => { useAppStore.setState({ actuationSupported: null }); diff --git a/src/lib/store.ts b/src/lib/store.ts index 4b841aa..e71320f 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -55,6 +55,12 @@ import type { LogCollection, LogsConfiguration, LogsFetchResult, LogsQueryParams const STORAGE_KEY = 'ros2_medkit_web_ui_server_url'; const EXECUTION_POLL_INTERVAL_MS = 1000; + +// Lifecycle readiness is not pushed by the gateway, so a watched entity is +// re-read on this cadence. It also bounds how long the control shows 'unknown' +// after a transition, which is why it is well under the time a node takes to +// come back up rather than a round number. +const STATUS_POLL_INTERVAL_MS = 5000; const EXECUTION_CLEANUP_AFTER_MS = 5 * 60 * 1000; // 5 minutes export type TreeViewMode = 'logical' | 'functional'; @@ -120,6 +126,9 @@ export interface AppState { // gateway answered 501 (no actuation provider). Reset on every (re)connect. actuationSupported: boolean | null; + // Interval driving the readiness refresh for watched entities. + statusPollingIntervalId: ReturnType | null; + // Actions connect: (url: string) => Promise; disconnect: () => void; @@ -172,6 +181,18 @@ export interface AppState { // Lifecycle status action (apps/components only) - fills statusByEntity. fetchEntityStatus: (entityType: LifecycleEntityType, entityId: string) => Promise; + // Register interest in an entity's readiness: reads it once now and keeps it + // in the refresh loop until the returned unsubscribe runs. Components call + // this from a mount effect so the loop only covers what is on screen. + watchEntityStatus: (entityType: LifecycleEntityType, entityId: string) => () => void; + + // Drop a cached readiness value and any read still in flight for it, so a + // response issued before the change cannot restore the old value. + invalidateEntityStatus: (entityType: LifecycleEntityType, entityId: string) => void; + + startStatusPolling: () => void; + stopStatusPolling: () => void; + // Records whether the gateway supports lifecycle actuation (see actuationSupported). setActuationSupported: (value: boolean) => void; @@ -767,9 +788,27 @@ export function entityStatusKey(entityType: string, entityId: string): string { */ const inFlightStatusRequests = new Map>(); +/** + * Entities whose readiness is currently on screen, by cache key, counted + * because the control and the tree lamp can watch the same entity at once. + * The refresh loop reads this and nothing else, so a collapsed branch stops + * costing requests. + */ +const statusWatchers = new Map(); + +/** + * Read generation per cache key. `invalidateEntityStatus` bumps it, and a + * response carrying an older generation is discarded: without this, a read + * issued before a transition (possibly shared through the dedupe map) would + * resolve afterwards and write the pre-transition readiness back. + */ +const statusEpochs = new Map(); + /** Reset the status-request dedupe cache. Exposed for tests. */ export function __resetStatusRequestCache(): void { inFlightStatusRequests.clear(); + statusWatchers.clear(); + statusEpochs.clear(); } export async function fetchAllAppsDeduped(client: MedkitClient): Promise[]> { @@ -908,6 +947,7 @@ export const useAppStore = create()( // Gateway-wide lifecycle actuation support (unknown until observed). actuationSupported: null, + statusPollingIntervalId: null, // Connect to ros2_medkit gateway connect: async (url: string) => { @@ -982,6 +1022,7 @@ export const useAppStore = create()( // See connect: lifecycle readiness is scoped to one gateway, and // an in-flight request must not be handed to the next session. + get().stopStatusPolling(); __resetStatusRequestCache(); // Unsubscribe from fault stream @@ -1950,6 +1991,8 @@ export const useAppStore = create()( const client = get().client; if (!client) return; + const epoch = statusEpochs.get(key) ?? 0; + const request = (async () => { try { const result = await getStatus(client, entityType, entityId); @@ -1963,14 +2006,16 @@ export const useAppStore = create()( } catch { writeStatus('unknown'); } finally { - inFlightStatusRequests.delete(key); + if (inFlightStatusRequests.get(key) === request) inFlightStatusRequests.delete(key); } - // A response that outlived its session belongs to a gateway - // the UI is no longer talking to, and entity ids collide - // across gateways, so it must not land in the new cache. + // Two ways a response can be obsolete by the time it lands: it + // belongs to a gateway the UI has since left (entity ids + // collide across gateways), or it predates a change that + // invalidated the value it is carrying. function writeStatus(value: EntityStatusValue): void { if (get().client !== client) return; + if ((statusEpochs.get(key) ?? 0) !== epoch) return; set((s) => ({ statusByEntity: { ...s.statusByEntity, [key]: value } })); } })(); @@ -1979,6 +2024,55 @@ export const useAppStore = create()( return request; }, + watchEntityStatus: (entityType: LifecycleEntityType, entityId: string) => { + const key = entityStatusKey(entityType, entityId); + statusWatchers.set(key, (statusWatchers.get(key) ?? 0) + 1); + void get().fetchEntityStatus(entityType, entityId); + get().startStatusPolling(); + + return () => { + const remaining = (statusWatchers.get(key) ?? 1) - 1; + if (remaining > 0) { + statusWatchers.set(key, remaining); + } else { + statusWatchers.delete(key); + } + }; + }, + + invalidateEntityStatus: (entityType: LifecycleEntityType, entityId: string) => { + const key = entityStatusKey(entityType, entityId); + statusEpochs.set(key, (statusEpochs.get(key) ?? 0) + 1); + inFlightStatusRequests.delete(key); + set((s) => ({ statusByEntity: { ...s.statusByEntity, [key]: 'unknown' } })); + }, + + startStatusPolling: () => { + if (get().statusPollingIntervalId || !get().client) return; + + const intervalId = setInterval(() => { + if (!get().client || statusWatchers.size === 0) { + get().stopStatusPolling(); + return; + } + for (const key of [...statusWatchers.keys()]) { + const separator = key.indexOf(':'); + const entityType = key.slice(0, separator) as LifecycleEntityType; + void get().fetchEntityStatus(entityType, key.slice(separator + 1)); + } + }, STATUS_POLL_INTERVAL_MS); + + set({ statusPollingIntervalId: intervalId }); + }, + + stopStatusPolling: () => { + const { statusPollingIntervalId } = get(); + if (statusPollingIntervalId) { + clearInterval(statusPollingIntervalId); + set({ statusPollingIntervalId: null }); + } + }, + setActuationSupported: (value: boolean) => set({ actuationSupported: value }), // =========================================================================== From b7b74016a9d71c34b78c381216162d220787a9e0 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 21 Aug 2026 21:25:36 +0200 Subject: [PATCH 18/23] fix: offer no transition on a readiness the UI does not have Only 501 counted as "no lifecycle here", so the 404 a gateway without the routes returns fell through to unknown - and unknown has no entry in the gating table, which left all five actions live under a grey badge. The same gap covered the window before the first read landed. A 404 now reads as unavailable like a 501, and an unestablished readiness disables the actions with a tooltip that says what is being waited for. --- src/components/EntityDetailPanel.test.tsx | 17 +++++++++++++++-- src/components/EntityStatusControl.test.tsx | 20 ++++++++++++++++++++ src/components/EntityStatusControl.tsx | 6 ++++++ src/lib/store.test.ts | 14 ++++++++++++++ src/lib/store.ts | 7 ++++++- 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/components/EntityDetailPanel.test.tsx b/src/components/EntityDetailPanel.test.tsx index 2eea3f0..8597a57 100644 --- a/src/components/EntityDetailPanel.test.tsx +++ b/src/components/EntityDetailPanel.test.tsx @@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; +import { TooltipProvider } from '@/components/ui/tooltip'; import { EntityDetailPanel } from './EntityDetailPanel'; // Mock heavy child components - we only care about the top-level routing @@ -92,7 +93,13 @@ describe('EntityDetailPanel - nested entity types', () => { }, }); - render( {}} />); + // App.tsx wraps the whole tree in a TooltipProvider, and the panel + // renders tooltips for any disabled lifecycle action. + render( + + {}} /> + + ); // Bug repro: subcomponent should fetch resource counts using the // 'components' entity type (gateway routes subcomponents through @@ -123,7 +130,13 @@ describe('EntityDetailPanel - nested entity types', () => { }, }); - render( {}} />); + // App.tsx wraps the whole tree in a TooltipProvider, and the panel + // renders tooltips for any disabled lifecycle action. + render( + + {}} /> + + ); // Bug repro: subarea should fetch resource counts using the 'areas' // entity type (gateway routes subareas through /api/v1/areas/{id}/...). diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx index c218c8a..1b61515 100644 --- a/src/components/EntityStatusControl.test.tsx +++ b/src/components/EntityStatusControl.test.tsx @@ -369,6 +369,26 @@ describe('EntityStatusControl', () => { expect(screen.getByRole('button', { name: /^restart$/i })).toBeEnabled(); }); + // ----------------------------------------------------------------------- + // Gating on a readiness the UI does not have + // ----------------------------------------------------------------------- + + it.each([ + ['the first read has not landed yet', undefined], + ['the read failed', 'unknown'], + ])('offers no transition while %s', async (_label, cached) => { + useAppStore.setState({ + statusByEntity: cached ? { 'apps:planner': cached as never } : {}, + watchEntityStatus: noopWatch, + client: fakeClient, + }); + renderControl(); + + for (const name of [/^start$/i, /^restart$/i, /force restart/i, /^shutdown$/i, /force shutdown/i]) { + expect(await screen.findByRole('button', { name })).toBeDisabled(); + } + }); + // ----------------------------------------------------------------------- // Task C: disable + "not implemented" note when actuationSupported === false // ----------------------------------------------------------------------- diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx index f31f6cc..d3fb3a4 100644 --- a/src/components/EntityStatusControl.tsx +++ b/src/components/EntityStatusControl.tsx @@ -132,16 +132,22 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro // A 501 from any transition means the gateway has no actuation provider: // disable every action (Start included), gateway-wide. const actuationUnsupported = actuationSupported === false; + // No readiness to gate on: either the first read has not landed, or it + // failed, or a transition dropped it. Every transition is conditional on the + // current state, so none can be offered here. The refresh loop settles it. + const readinessUnknown = status === undefined || status === 'unknown'; const isDisabled = (action: LifecycleAction): boolean => !client || notAvailable || actuationUnsupported || + readinessUnknown || pendingAction !== null || (DISABLED_BY_STATUS[status ?? '']?.has(action) ?? false); const tooltipFor = (action: LifecycleAction): string => { if (actuationUnsupported) return 'Not implemented by this gateway'; + if (readinessUnknown) return 'Waiting for the current status'; if (status === 'ready' && action === 'start') return 'Already running'; if (status === 'notReady' && DISABLED_BY_STATUS.notReady!.has(action)) return 'Entity is not running'; return ''; diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts index 0a195ce..5e06e97 100644 --- a/src/lib/store.test.ts +++ b/src/lib/store.test.ts @@ -38,6 +38,20 @@ describe('fetchEntityStatus', () => { expect(useAppStore.getState().statusByEntity[entityStatusKey('apps', 'planner')]).toBe('unavailable'); }); + it('maps a 404 response to "unavailable" like a 501', async () => { + // A gateway built without the lifecycle routes answers 404, which tells + // the UI the same thing a 501 does: there is nothing to actuate here. + getStatusMock.mockResolvedValue({ data: undefined, response: { status: 404 } } as never); + await useAppStore.getState().fetchEntityStatus('apps', 'planner'); + expect(useAppStore.getState().statusByEntity[entityStatusKey('apps', 'planner')]).toBe('unavailable'); + }); + + it('maps any other failed read to "unknown"', async () => { + getStatusMock.mockResolvedValue({ data: undefined, response: { status: 500 } } as never); + await useAppStore.getState().fetchEntityStatus('apps', 'planner'); + expect(useAppStore.getState().statusByEntity[entityStatusKey('apps', 'planner')]).toBe('unknown'); + }); + 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([ diff --git a/src/lib/store.ts b/src/lib/store.ts index e71320f..b4466b1 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -1997,7 +1997,12 @@ export const useAppStore = create()( try { const result = await getStatus(client, entityType, entityId); let value: EntityStatusValue = 'unknown'; - if (result.response?.status === 501) { + // 501 is a gateway with no lifecycle provider; 404 is a + // gateway built without the routes at all. Both mean the + // same thing here, and only these two are a capability + // answer - every other failure leaves the readiness + // genuinely unknown rather than known to be absent. + if (result.response?.status === 501 || result.response?.status === 404) { value = 'unavailable'; } else if (result.data?.status === 'ready' || result.data?.status === 'notReady') { value = result.data.status; From 155435fb93d6503c47eaa002795ad94e65eb0365 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 21 Aug 2026 21:44:40 +0200 Subject: [PATCH 19/23] fix: record missing lifecycle actuation per entity, not gateway-wide A single 501 set one flag that disabled all five actions on every app and every component, and the only writer able to clear it was a 2xx transition - which no longer had an enabled button to come from, so a reconnect was the only way out. It also disagreed with the read side, which already scopes a 501 to the entity that produced it. A provider is registered per entity, so the transition side now remembers the answer per entity too. --- README.md | 2 +- src/components/EntityDetailPanel.test.tsx | 1 + src/components/EntityStatusControl.test.tsx | 49 ++++++++++++++++----- src/components/EntityStatusControl.tsx | 30 +++++++------ src/lib/store.test.ts | 25 +++++++---- src/lib/store.ts | 27 +++++++----- 6 files changed, 88 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index e77cd85..8dbd187 100644 --- a/README.md +++ b/README.md @@ -15,7 +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, with a readiness lamp on app and component nodes (a green disc for ready, an amber ring for not ready, a grey square for a readiness the UI has not established). The lamp is re-read while the branch is open, so it tracks an entity that stops or comes back - **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. A transition is only reported as requested when the gateway accepts it; because acceptance is not completion, the readiness is dropped and re-established by the refresh rather than read back straight away +- **Entity Lifecycle Status Control** - View readiness and request lifecycle transitions (start, restart, force-restart, shutdown, force-shutdown) for apps and components, degrading gracefully on an entity with no lifecycle provider, without taking the entities that have one with it. 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. A transition is only reported as requested when the gateway accepts it; because acceptance is not completion, the readiness is dropped and re-established by the refresh rather than read back straight away 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 8597a57..2437c0a 100644 --- a/src/components/EntityDetailPanel.test.tsx +++ b/src/components/EntityDetailPanel.test.tsx @@ -71,6 +71,7 @@ function setStore(overrides: Record) { // so the rendered control mounts without touching the network. client: null, statusByEntity: {}, + actuationByEntity: {}, watchEntityStatus: vi.fn(() => () => {}), ...overrides, }; diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx index 1b61515..e377c96 100644 --- a/src/components/EntityStatusControl.test.tsx +++ b/src/components/EntityStatusControl.test.tsx @@ -98,7 +98,7 @@ describe('EntityStatusControl', () => { statusByEntity: {}, watchEntityStatus: noopWatch, client: fakeClient, - actuationSupported: null, + actuationByEntity: {}, }); }); @@ -269,9 +269,9 @@ describe('EntityStatusControl', () => { // Task B: response-driven transition feedback (replaces the 501 no-op) // ----------------------------------------------------------------------- - it('501 transition warns "not implemented" and sets actuationSupported false', async () => { + it('501 transition warns "not implemented" and records it for that entity', async () => { seedStatus('apps:planner', 'notReady'); - useAppStore.setState({ actuationSupported: null }); + useAppStore.setState({ actuationByEntity: {} }); mockSetStatus.mockResolvedValue(errResult(501, 'no actuation provider')); renderControl(); @@ -279,19 +279,19 @@ describe('EntityStatusControl', () => { await waitFor(() => expect(toast.warning).toHaveBeenCalled()); expect(toast.error).not.toHaveBeenCalled(); - expect(useAppStore.getState().actuationSupported).toBe(false); + expect(useAppStore.getState().actuationByEntity['apps:planner']).toBe(false); }); - it('2xx transition reports success and sets actuationSupported true', async () => { + it('2xx transition reports success and records the entity as actuable', async () => { seedStatus('apps:planner', 'notReady'); - useAppStore.setState({ actuationSupported: null }); + useAppStore.setState({ actuationByEntity: {} }); 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); + expect(useAppStore.getState().actuationByEntity['apps:planner']).toBe(true); }); it.each([ @@ -299,7 +299,7 @@ describe('EntityStatusControl', () => { ['empty-string error (empty body, no Content-Length)', ''], ])('treats a 502 with an %s as a failure, not a success', async (_label, errorValue) => { seedStatus('apps:planner', 'notReady'); - useAppStore.setState({ actuationSupported: null }); + useAppStore.setState({ actuationByEntity: {} }); mockSetStatus.mockResolvedValue(emptyBodyFailure(502, errorValue)); renderControl(); @@ -308,7 +308,7 @@ describe('EntityStatusControl', () => { await waitFor(() => expect(toast.error).toHaveBeenCalled()); expect(toast.success).not.toHaveBeenCalled(); // A failed transition proves nothing about actuation support. - expect(useAppStore.getState().actuationSupported).toBeNull(); + expect(useAppStore.getState().actuationByEntity['apps:planner']).toBeUndefined(); // The gateway said nothing usable, so the status has to carry the message. expect(await screen.findByRole('alert')).toHaveTextContent(/502/); }); @@ -389,17 +389,42 @@ describe('EntityStatusControl', () => { } }); + it('a 501 on one entity leaves another entity actuable', async () => { + const user = userEvent.setup(); + useAppStore.setState({ + statusByEntity: { 'apps:alpha': 'notReady', 'apps:beta': 'notReady' }, + watchEntityStatus: noopWatch, + client: fakeClient, + }); + mockSetStatus.mockResolvedValue(errResult(501, 'no actuation provider')); + + const { rerender } = renderControl(); + await user.click(screen.getByRole('button', { name: /^start$/i })); + await waitFor(() => expect(toast.warning).toHaveBeenCalled()); + expect(screen.getByRole('button', { name: /^start$/i })).toBeDisabled(); + + rerender( + + + + ); + + // beta may well have a provider: alpha's answer says nothing about it. + expect(screen.getByRole('button', { name: /^start$/i })).toBeEnabled(); + expect(screen.queryByText(/not implemented for this entity/i)).not.toBeInTheDocument(); + }); + // ----------------------------------------------------------------------- - // Task C: disable + "not implemented" note when actuationSupported === false + // Task C: disable + "not implemented" note when the entity answered 501 // ----------------------------------------------------------------------- it('disables all transition buttons and shows a note when actuation is unsupported', async () => { seedStatus('apps:planner', 'notReady'); - useAppStore.setState({ actuationSupported: false }); + useAppStore.setState({ actuationByEntity: { 'apps:planner': 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(); + expect(screen.getByText(/not implemented for this entity/i)).toBeInTheDocument(); }); }); diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx index d3fb3a4..0b49cdc 100644 --- a/src/components/EntityStatusControl.tsx +++ b/src/components/EntityStatusControl.tsx @@ -90,18 +90,20 @@ function failureMessage(action: LifecycleAction, httpStatus: number | undefined) * gated by 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 + * The gateway returns 501 for an entity with no lifecycle provider. That case * surfaces as the cached value `'unavailable'` -> a disabled "not available" * state rather than an error toast, so the control degrades gracefully on stock - * gateways. + * gateways. A provider is registered per entity, so both the read side and the + * transition side remember the answer per entity: one entity without a provider + * does not disable the ones that have one. */ export function EntityStatusControl({ entityType, entityId }: EntityStatusControlProps) { const client = useAppStore((s) => s.client); const status = useAppStore((s) => s.statusByEntity[entityStatusKey(entityType, entityId)]); const watchEntityStatus = useAppStore((s) => s.watchEntityStatus); const invalidateEntityStatus = useAppStore((s) => s.invalidateEntityStatus); - const setActuationSupported = useAppStore((s) => s.setActuationSupported); - const actuationSupported = useAppStore((s) => s.actuationSupported); + const setEntityActuation = useAppStore((s) => s.setEntityActuation); + const actuationSupported = useAppStore((s) => s.actuationByEntity[entityStatusKey(entityType, entityId)]); const [pendingAction, setPendingAction] = useState(null); const [confirmAction, setConfirmAction] = useState(null); @@ -129,8 +131,10 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro }, [entityType, entityId, watchEntityStatus]); const notAvailable = status === 'unavailable'; - // A 501 from any transition means the gateway has no actuation provider: - // disable every action (Start included), gateway-wide. + // A 501 from a transition means this entity has no actuation provider: + // disable every action on it (Start included). It says nothing about any + // other entity, so the answer is remembered per entity, the same way the + // read side already records a 501 from GET /status. const actuationUnsupported = actuationSupported === false; // No readiness to gate on: either the first read has not landed, or it // failed, or a transition dropped it. Every transition is conditional on the @@ -146,7 +150,7 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro (DISABLED_BY_STATUS[status ?? '']?.has(action) ?? false); const tooltipFor = (action: LifecycleAction): string => { - if (actuationUnsupported) return 'Not implemented by this gateway'; + if (actuationUnsupported) return 'Not implemented for this entity'; if (readinessUnknown) return 'Waiting for the current status'; if (status === 'ready' && action === 'start') return 'Already running'; if (status === 'notReady' && DISABLED_BY_STATUS.notReady!.has(action)) return 'Entity is not running'; @@ -167,9 +171,9 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro // 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); + setEntityActuation(entityType, entityId, false); const msg = errorMessageOf(result.error); - toast.warning(`${action} is not implemented by this gateway${msg ? `: ${msg}` : ''}`); + toast.warning(`${action} is not implemented for ${entityId}${msg ? `: ${msg}` : ''}`); return; } // Success is the HTTP status, never the truthiness of `error`. @@ -187,8 +191,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); + // Any 2xx proves this entity can actuate; clear a stale "unsupported". + setEntityActuation(entityType, entityId, true); toast.success(`${action} requested for ${entityId}`); // 202 means accepted, not applied: a node takes longer to come // back than this round trip, so reading now returns the readiness @@ -205,7 +209,7 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro if (stillShown()) setPendingAction(null); } }, - [client, entityType, entityId, invalidateEntityStatus, setActuationSupported] + [client, entityType, entityId, invalidateEntityStatus, setEntityActuation] ); const handleClick = useCallback( @@ -305,7 +309,7 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro {actuationUnsupported && ( - Transitions not implemented by this gateway (yet) + Transitions not implemented for this entity (yet) )} diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts index 5e06e97..d252554 100644 --- a/src/lib/store.test.ts +++ b/src/lib/store.test.ts @@ -230,15 +230,22 @@ describe('invalidateEntityStatus', () => { }); }); -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 }); +describe('actuationByEntity', () => { + it('records the answer under the entity that produced it', () => { + useAppStore.setState({ actuationByEntity: {} }); + useAppStore.getState().setEntityActuation('apps', 'planner', false); + expect(useAppStore.getState().actuationByEntity).toEqual({ 'apps:planner': false }); + }); + + it('leaves every other entity untouched', () => { + useAppStore.setState({ actuationByEntity: { 'apps:talker': true } }); + useAppStore.getState().setEntityActuation('apps', 'planner', false); + expect(useAppStore.getState().actuationByEntity['apps:talker']).toBe(true); + }); + + it('disconnect clears what the session learned', () => { + useAppStore.setState({ actuationByEntity: { 'apps:planner': false } }); useAppStore.getState().disconnect(); - expect(useAppStore.getState().actuationSupported).toBeNull(); + expect(useAppStore.getState().actuationByEntity).toEqual({}); }); }); diff --git a/src/lib/store.ts b/src/lib/store.ts index b4466b1..52fd841 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -121,10 +121,12 @@ 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; + // Lifecycle actuation support per entity, keyed like statusByEntity and + // derived from observed transition responses: absent = never exercised, + // true = a transition succeeded (2xx), false = the gateway answered 501 (no + // actuation provider for that entity). A provider is registered per entity, + // so one entity's answer says nothing about the next. Reset on (re)connect. + actuationByEntity: Record; // Interval driving the readiness refresh for watched entities. statusPollingIntervalId: ReturnType | null; @@ -193,8 +195,8 @@ export interface AppState { startStatusPolling: () => void; stopStatusPolling: () => void; - // Records whether the gateway supports lifecycle actuation (see actuationSupported). - setActuationSupported: (value: boolean) => void; + // Records whether an entity supports lifecycle actuation (see actuationByEntity). + setEntityActuation: (entityType: LifecycleEntityType, entityId: string, supported: boolean) => void; // Faults actions fetchFaults: () => Promise; @@ -945,8 +947,8 @@ export const useAppStore = create()( // Lifecycle status cache statusByEntity: {}, - // Gateway-wide lifecycle actuation support (unknown until observed). - actuationSupported: null, + // Per-entity lifecycle actuation support (unknown until observed). + actuationByEntity: {}, statusPollingIntervalId: null, // Connect to ros2_medkit gateway @@ -960,7 +962,7 @@ export const useAppStore = create()( set({ isConnecting: true, connectionError: null, - actuationSupported: null, + actuationByEntity: {}, statusByEntity: {}, }); @@ -1040,7 +1042,7 @@ export const useAppStore = create()( selectedPath: null, selectedEntity: null, activeExecutions: new Map(), - actuationSupported: null, + actuationByEntity: {}, statusByEntity: {}, }); }, @@ -2078,7 +2080,10 @@ export const useAppStore = create()( } }, - setActuationSupported: (value: boolean) => set({ actuationSupported: value }), + setEntityActuation: (entityType: LifecycleEntityType, entityId: string, supported: boolean) => { + const key = entityStatusKey(entityType, entityId); + set((s) => ({ actuationByEntity: { ...s.actuationByEntity, [key]: supported } })); + }, // =========================================================================== // FAULTS ACTIONS (Diagnostic Trouble Codes) From a079e9596c5f2adbd58ee94090ccdf35b480ce4c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 22 Aug 2026 14:19:16 +0200 Subject: [PATCH 20/23] fix: keep an unavailable transition reachable by keyboard Each disabled button sat inside a focusable span with no role and no name, so a keyboard user tabbed through five stops that announced nothing while the reason stayed on the buttons themselves, which a disabled attribute had already removed from the accessibility tree. The buttons now carry aria-disabled, keep their name and their place in the tab order, and reject the action in the handler; focusing one opens the tooltip that says why it cannot be used. The Radix floating layer constructs a ResizeObserver as soon as it opens, so the test setup provides one - jsdom has none, and without it a tooltip throws on render instead of failing an assertion. --- README.md | 2 +- src/components/EntityStatusControl.test.tsx | 77 ++++++++++++++++----- src/components/EntityStatusControl.tsx | 25 ++++--- src/test/setup.ts | 11 +++ 4 files changed, 86 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 8dbd187..b60bc51 100644 --- a/README.md +++ b/README.md @@ -15,7 +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, with a readiness lamp on app and component nodes (a green disc for ready, an amber ring for not ready, a grey square for a readiness the UI has not established). The lamp is re-read while the branch is open, so it tracks an entity that stops or comes back - **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 on an entity with no lifecycle provider, without taking the entities that have one with it. 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. A transition is only reported as requested when the gateway accepts it; because acceptance is not completion, the readiness is dropped and re-established by the refresh rather than read back straight away +- **Entity Lifecycle Status Control** - View readiness and request lifecycle transitions (start, restart, force-restart, shutdown, force-shutdown) for apps and components, degrading gracefully on an entity with no lifecycle provider, without taking the entities that have one with it. Actions are gated by the current status (a transition the current status does not allow is marked unavailable and rejected, and stays focusable so the tooltip explaining why reaches a screen reader), and every destructive transition (all but Start) asks for confirmation before dispatch. A transition is only reported as requested when the gateway accepts it; because acceptance is not completion, the readiness is dropped and re-established by the refresh rather than read back straight away 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/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx index e377c96..a9cd0ea 100644 --- a/src/components/EntityStatusControl.test.tsx +++ b/src/components/EntityStatusControl.test.tsx @@ -184,9 +184,9 @@ describe('EntityStatusControl', () => { renderControl(); 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(); + expect(screen.getByRole('button', { name: /^start$/i })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: /^restart$/i })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: /^shutdown$/i })).toHaveAttribute('aria-disabled', 'true'); }); it('shows the "not available" state when the cached status is unavailable for components', async () => { @@ -205,7 +205,7 @@ describe('EntityStatusControl', () => { 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(); + expect(screen.getByRole('button', { name: /^start$/i })).not.toHaveAttribute('aria-disabled'); }); // ----------------------------------------------------------------------- @@ -215,16 +215,16 @@ describe('EntityStatusControl', () => { 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(); + expect(await screen.findByRole('button', { name: /^start$/i })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: /^restart$/i })).not.toHaveAttribute('aria-disabled'); }); 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(); + expect(await screen.findByRole('button', { name: /^start/i })).not.toHaveAttribute('aria-disabled'); + expect(screen.getByRole('button', { name: /^restart/i })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: /^shutdown/i })).toHaveAttribute('aria-disabled', 'true'); }); it('leaves Start as the only enabled action when status is notReady', async () => { @@ -232,9 +232,9 @@ describe('EntityStatusControl', () => { // stopped one they are all unavailable - Force restart included. seedStatus('apps:planner', 'notReady'); renderControl(); - expect(await screen.findByRole('button', { name: /^start$/i })).toBeEnabled(); + expect(await screen.findByRole('button', { name: /^start$/i })).not.toHaveAttribute('aria-disabled'); for (const name of [/^restart$/i, /force restart/i, /^shutdown$/i, /force shutdown/i]) { - expect(screen.getByRole('button', { name })).toBeDisabled(); + expect(screen.getByRole('button', { name })).toHaveAttribute('aria-disabled', 'true'); } }); @@ -350,7 +350,7 @@ describe('EntityStatusControl', () => { await user.click(screen.getByRole('button', { name: /^shutdown$/i })); await user.click(await screen.findByRole('button', { name: /confirm/i })); await waitFor(() => expect(mockSetStatus).toHaveBeenCalledTimes(1)); - expect(screen.getByRole('button', { name: /^restart$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^restart$/i })).toHaveAttribute('aria-disabled', 'true'); rerender( @@ -359,14 +359,16 @@ describe('EntityStatusControl', () => { ); // beta is ready and has nothing in flight: everything but Start is live. - await waitFor(() => expect(screen.getByRole('button', { name: /^restart$/i })).toBeEnabled()); + await waitFor(() => + expect(screen.getByRole('button', { name: /^restart$/i })).not.toHaveAttribute('aria-disabled') + ); finish(errResult(500, 'alpha refused')); // alpha's failure is still reported, but it must not land on beta's panel. await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('alpha'))); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: /^restart$/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /^restart$/i })).not.toHaveAttribute('aria-disabled'); }); // ----------------------------------------------------------------------- @@ -385,7 +387,7 @@ describe('EntityStatusControl', () => { renderControl(); for (const name of [/^start$/i, /^restart$/i, /force restart/i, /^shutdown$/i, /force shutdown/i]) { - expect(await screen.findByRole('button', { name })).toBeDisabled(); + expect(await screen.findByRole('button', { name })).toHaveAttribute('aria-disabled', 'true'); } }); @@ -401,7 +403,7 @@ describe('EntityStatusControl', () => { const { rerender } = renderControl(); await user.click(screen.getByRole('button', { name: /^start$/i })); await waitFor(() => expect(toast.warning).toHaveBeenCalled()); - expect(screen.getByRole('button', { name: /^start$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^start$/i })).toHaveAttribute('aria-disabled', 'true'); rerender( @@ -410,10 +412,47 @@ describe('EntityStatusControl', () => { ); // beta may well have a provider: alpha's answer says nothing about it. - expect(screen.getByRole('button', { name: /^start$/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /^start$/i })).not.toHaveAttribute('aria-disabled'); expect(screen.queryByText(/not implemented for this entity/i)).not.toBeInTheDocument(); }); + // ----------------------------------------------------------------------- + // Unavailable actions stay reachable so their reason is announced + // ----------------------------------------------------------------------- + + it('keeps an unavailable action in the accessibility tree with its name', async () => { + seedStatus('components:host1', 'ready'); + renderControl(); + + // getByRole ignores elements removed from the accessibility tree, and a + // `disabled` button is not focusable - both of which hide the reason. + const start = await screen.findByRole('button', { name: /^start$/i }); + expect(start).toHaveAttribute('aria-disabled', 'true'); + expect(start).not.toHaveAttribute('disabled'); + start.focus(); + expect(start).toHaveFocus(); + }); + + it('rejects a click on an unavailable action', async () => { + const user = userEvent.setup(); + seedStatus('components:host1', 'ready'); + renderControl(); + + await user.click(screen.getByRole('button', { name: /^start$/i })); + + expect(mockSetStatus).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('describes an unavailable action when it takes focus', async () => { + const user = userEvent.setup(); + seedStatus('components:host1', 'ready'); + renderControl(); + + await user.tab(); + await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent(/already running/i)); + }); + // ----------------------------------------------------------------------- // Task C: disable + "not implemented" note when the entity answered 501 // ----------------------------------------------------------------------- @@ -423,8 +462,8 @@ describe('EntityStatusControl', () => { useAppStore.setState({ actuationByEntity: { 'apps:planner': false } }); renderControl(); - expect(await screen.findByRole('button', { name: /^start/i })).toBeDisabled(); - expect(screen.getByRole('button', { name: /^restart/i })).toBeDisabled(); + expect(await screen.findByRole('button', { name: /^start/i })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: /^restart/i })).toHaveAttribute('aria-disabled', 'true'); expect(screen.getByText(/not implemented for this entity/i)).toBeInTheDocument(); }); }); diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx index 0b49cdc..9d316b5 100644 --- a/src/components/EntityStatusControl.tsx +++ b/src/components/EntityStatusControl.tsx @@ -26,6 +26,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; import { useAppStore, entityStatusKey } from '@/lib/store'; import { setStatus, type LifecycleEntityType } from '@/lib/api-dispatch'; import type { LifecycleAction } from '@/lib/types'; @@ -271,15 +272,25 @@ export function EntityStatusControl({ entityType, entityId }: EntityStatusContro
{ACTIONS.map(({ action, label, icon: Icon, variant }) => { const isPending = pendingAction === action; - const disabled = isDisabled(action); - const tip = disabled ? tooltipFor(action) : ''; + const unavailable = isDisabled(action); + const tip = unavailable ? tooltipFor(action) : ''; + // aria-disabled rather than disabled: a disabled button leaves + // the accessibility tree and the tab order, so the reason it + // cannot be used becomes unreachable by keyboard. Marked this + // way the button keeps its name, announces as unavailable, and + // focusing it opens the tooltip that says why. The handler + // rejects the action, which the attribute alone does not. 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} - + {button} {tip} ); diff --git a/src/test/setup.ts b/src/test/setup.ts index 738051a..5ce1669 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -11,3 +11,14 @@ if (typeof Blob !== 'undefined' && !Blob.prototype.text) { }); }; } + +// jsdom has no ResizeObserver, which Radix's floating layer (tooltip, popover) +// constructs as soon as it opens - without it those components throw on render +// rather than failing an assertion. +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; +} From 707ff6f08e158a914db1dd2451082a3ca42ae5d2 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 22 Aug 2026 17:08:42 +0200 Subject: [PATCH 21/23] fix: keep the entity name as its label and put the description beside it The description replaced the name in the tree row and in the detail card title, leaving the name only in a title attribute that keyboard and touch users never see. A description is entity metadata, not an identifier - a component's is the host's OS, so every component on one host rendered the same truncated string. The name leads again in both places, with the description alongside it. --- src/components/EntityDetailPanel.tsx | 9 ++++++--- src/components/EntityTreeNode.test.tsx | 13 +++++++++---- src/components/EntityTreeNode.tsx | 14 +++++++++++--- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 8c354fd..b5b39a4 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -778,9 +778,12 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit {getEntityTypeIcon()}
- - {selectedEntity.description || selectedEntity.name} - + {selectedEntity.name} + {selectedEntity.description && ( +

+ {selectedEntity.description} +

+ )} {selectedEntity.type} diff --git a/src/components/EntityTreeNode.test.tsx b/src/components/EntityTreeNode.test.tsx index 2331143..94f7254 100644 --- a/src/components/EntityTreeNode.test.tsx +++ b/src/components/EntityTreeNode.test.tsx @@ -124,8 +124,12 @@ describe('EntityTreeNode label', () => { }); afterEach(() => cleanup()); - it('shows the entity description as the label when present', () => { - useAppStore.setState({ fetchEntityStatus: vi.fn() } as never); + it('keeps the name visible and shows the description beside it', () => { + // A component's description is host metadata, so it is the same string + // for every component on that host: it can add to the name but must not + // stand in for it, or the rows stop being distinguishable without a + // hover that keyboard and touch users do not have. + useAppStore.setState({ watchEntityStatus: vi.fn(() => () => {}) } as never); render( { depth={0} /> ); + expect(screen.getByText('a3d9')).toBeInTheDocument(); 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); + it('shows the name alone when there is no description', () => { + useAppStore.setState({ watchEntityStatus: vi.fn(() => () => {}) } as never); render( )} - - {(typeof node.description === 'string' && node.description) || - (typeof node.name === 'string' ? node.name : String(node.name || node.id || ''))} + + {nodeLabel} + {nodeDescription && {nodeDescription}} {/* Topic direction indicators */} From d5df0d656bd2457b4025648d50093cf7dcfec3ce Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 22 Aug 2026 17:12:31 +0200 Subject: [PATCH 22/23] fix: release the readiness request slot by generation, not by identity Comparing the parked promise against the one still being constructed reads a variable before it is assigned, which the project build rejects. The generation already captured for the write guard identifies the request just as precisely: a slot is only cleared while it still belongs to the generation that filled it. --- src/lib/store.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/store.ts b/src/lib/store.ts index 52fd841..c5d20f9 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -2013,7 +2013,10 @@ export const useAppStore = create()( } catch { writeStatus('unknown'); } finally { - if (inFlightStatusRequests.get(key) === request) inFlightStatusRequests.delete(key); + // Only clear the slot if it is still this request's. A + // later generation means an invalidation already dropped + // it and something newer may be parked there. + if ((statusEpochs.get(key) ?? 0) === epoch) inFlightStatusRequests.delete(key); } // Two ways a response can be obsolete by the time it lands: it From aa87ce228108da86062166984a011d934871fe48 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 22 Aug 2026 17:12:33 +0200 Subject: [PATCH 23/23] fix: make the typecheck script check something The root tsconfig carries `files: []` and two project references, so `tsc --noEmit` against it resolved zero input files and reported success on any source at all. It now builds the referenced projects, which is what the build step already type-checks. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 547df9d..79ea1ce 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test:coverage": "vitest --coverage", "format": "prettier --write .", "format:check": "prettier --check .", - "typecheck": "tsc --noEmit", + "typecheck": "tsc -b --emitDeclarationOnly false --noEmit", "prepare": "husky" }, "lint-staged": {