diff --git a/README.md b/README.md
index e5e894c..b60bc51 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,9 @@ Simple, open-source web UI for browsing SOVD (Service-Oriented Vehicle Diagnosti
ros2_medkit_web_ui is a lightweight single-page application that connects to a SOVD server and visualizes the entity hierarchy. It provides:
- **Server Connection Dialog** - Enter the URL of your SOVD server (supports both `http://ip:port` and `ip:port` formats)
-- **Entity Tree Sidebar** - Browse the hierarchical structure of SOVD entities with lazy-loading
+- **Entity Tree Sidebar** - Browse the hierarchical structure of SOVD entities with lazy-loading, with a readiness lamp on app and component nodes (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 (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/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": {
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.test.tsx b/src/components/EntityDetailPanel.test.tsx
index 4ed4dc4..2437c0a 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
@@ -49,6 +50,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 +67,12 @@ 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: {},
+ actuationByEntity: {},
+ watchEntityStatus: vi.fn(() => () => {}),
...overrides,
};
}
@@ -84,7 +94,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
@@ -115,7 +131,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/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx
index ca03e14..b5b39a4 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';
@@ -778,6 +779,11 @@ 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..a9cd0ea
--- /dev/null
+++ b/src/components/EntityStatusControl.test.tsx
@@ -0,0 +1,469 @@
+// Copyright 2026 bburda
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TooltipProvider } from '@/components/ui/tooltip';
+
+// ---------------------------------------------------------------------------
+// Mocks
+//
+// Status is read from the real store slice (statusByEntity), seeded per-test.
+// Only setStatus (the transition dispatch) is mocked; the rest of api-dispatch
+// stays real so the store module loads. 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.
+// ---------------------------------------------------------------------------
+
+const mockSetStatus = vi.fn();
+
+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(), warning: vi.fn() },
+}));
+
+import { toast } from 'react-toastify';
+import { useAppStore } from '@/lib/store';
+import { EntityStatusControl } from './EntityStatusControl';
+
+const fakeClient = { __fake: true } as never;
+
+/**
+ * Build an openapi-fetch style result. `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: httpResponse(status) };
+}
+
+function errResult(status: number, message: string) {
+ 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;
+}
+
+/**
+ * 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 },
+ 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', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSetStatus.mockResolvedValue(ok(204));
+ useAppStore.setState({
+ statusByEntity: {},
+ watchEntityStatus: noopWatch,
+ client: fakeClient,
+ actuationByEntity: {},
+ });
+ });
+
+ 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', () => {
+ 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();
+ expect(screen.getByRole('button', { name: /^shutdown$/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /force shutdown/i })).toBeInTheDocument();
+ });
+
+ it('calls setStatus with client, entityType, entityId and action on confirmed restart', async () => {
+ const user = userEvent.setup();
+ // ready leaves Restart enabled.
+ seedStatus('components:host-1', 'ready');
+ renderControl();
+
+ await user.click(screen.getByRole('button', { name: /^restart$/i }));
+ await user.click(await screen.findByRole('button', { name: /confirm/i }));
+
+ await waitFor(() => expect(mockSetStatus).toHaveBeenCalledTimes(1));
+ const call = mockSetStatus.mock.calls[0]!;
+ expect(call[0]).toBe(fakeClient);
+ expect(call[1]).toBe('components');
+ expect(call[2]).toBe('host-1');
+ expect(call[3]).toBe('restart');
+ });
+
+ it('drops the cached readiness after a successful confirmed action', async () => {
+ const user = userEvent.setup();
+ const invalidate = vi.fn();
+ useAppStore.setState({
+ statusByEntity: { 'apps:motor': 'ready' },
+ watchEntityStatus: noopWatch,
+ invalidateEntityStatus: invalidate,
+ client: fakeClient,
+ });
+ renderControl();
+
+ await user.click(screen.getByRole('button', { name: /^shutdown$/i }));
+ await user.click(await screen.findByRole('button', { name: /confirm/i }));
+
+ // 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 () => {
+ // 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();
+ 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 () => {
+ 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 the action enabled', async () => {
+ const user = userEvent.setup();
+ mockSetStatus.mockResolvedValue(errResult(400, 'invalid transition'));
+ // start is enabled when notReady and dispatches immediately.
+ seedStatus('apps:motor', 'notReady');
+ renderControl();
+
+ 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.toHaveAttribute('aria-disabled');
+ });
+
+ // -----------------------------------------------------------------------
+ // 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 })).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 })).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 () => {
+ // 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 })).not.toHaveAttribute('aria-disabled');
+ for (const name of [/^restart$/i, /force restart/i, /^shutdown$/i, /force shutdown/i]) {
+ expect(screen.getByRole('button', { name })).toHaveAttribute('aria-disabled', 'true');
+ }
+ });
+
+ // -----------------------------------------------------------------------
+ // 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'));
+ });
+
+ // -----------------------------------------------------------------------
+ // Task B: response-driven transition feedback (replaces the 501 no-op)
+ // -----------------------------------------------------------------------
+
+ it('501 transition warns "not implemented" and records it for that entity', async () => {
+ seedStatus('apps:planner', 'notReady');
+ useAppStore.setState({ actuationByEntity: {} });
+ 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().actuationByEntity['apps:planner']).toBe(false);
+ });
+
+ it('2xx transition reports success and records the entity as actuable', async () => {
+ seedStatus('apps:planner', 'notReady');
+ 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().actuationByEntity['apps:planner']).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({ actuationByEntity: {} });
+ 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().actuationByEntity['apps:planner']).toBeUndefined();
+ // The gateway said nothing usable, so the status has to carry the message.
+ expect(await screen.findByRole('alert')).toHaveTextContent(/502/);
+ });
+
+ it('keeps the known readiness after a failed transition', async () => {
+ const invalidate = vi.fn();
+ useAppStore.setState({
+ statusByEntity: { 'apps:planner': 'notReady' },
+ watchEntityStatus: noopWatch,
+ invalidateEntityStatus: invalidate,
+ client: fakeClient,
+ });
+ mockSetStatus.mockResolvedValue(emptyBodyFailure(503));
+ renderControl();
+
+ await userEvent.click(screen.getByRole('button', { name: /^start/i }));
+
+ await waitFor(() => expect(toast.error).toHaveBeenCalled());
+ // Nothing moved, so the readiness the UI already has is still correct.
+ expect(invalidate).not.toHaveBeenCalled();
+ });
+
+ // -----------------------------------------------------------------------
+ // 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' },
+ watchEntityStatus: noopWatch,
+ 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 })).toHaveAttribute('aria-disabled', 'true');
+
+ rerender(
+
+
+
+ );
+
+ // beta is ready and has nothing in flight: everything but Start is live.
+ 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 })).not.toHaveAttribute('aria-disabled');
+ });
+
+ // -----------------------------------------------------------------------
+ // 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 })).toHaveAttribute('aria-disabled', 'true');
+ }
+ });
+
+ 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 })).toHaveAttribute('aria-disabled', 'true');
+
+ rerender(
+
+
+
+ );
+
+ // beta may well have a provider: alpha's answer says nothing about it.
+ 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
+ // -----------------------------------------------------------------------
+
+ it('disables all transition buttons and shows a note when actuation is unsupported', async () => {
+ seedStatus('apps:planner', 'notReady');
+ useAppStore.setState({ actuationByEntity: { 'apps:planner': false } });
+ renderControl();
+
+ 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
new file mode 100644
index 0000000..9d316b5
--- /dev/null
+++ b/src/components/EntityStatusControl.tsx
@@ -0,0 +1,356 @@
+// 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 { 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';
+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 { cn } from '@/lib/utils';
+import { useAppStore, entityStatusKey } from '@/lib/store';
+import { setStatus, type LifecycleEntityType } from '@/lib/api-dispatch';
+import type { LifecycleAction } from '@/lib/types';
+
+interface EntityStatusControlProps {
+ entityType: LifecycleEntityType;
+ entityId: 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' },
+];
+
+/** Transitions disabled for a given cached readiness value. */
+const DISABLED_BY_STATUS: Record> = {
+ ready: new Set(['start']),
+ notReady: new Set(['restart', 'force-restart', 'shutdown', 'force-shutdown']),
+};
+
+/** 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
+ * lifecycle transitions as buttons.
+ *
+ * Status is read from the shared `statusByEntity` store slice (the single
+ * 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 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. 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 setEntityActuation = useAppStore((s) => s.setEntityActuation);
+ const actuationSupported = useAppStore((s) => s.actuationByEntity[entityStatusKey(entityType, entityId)]);
+
+ const [pendingAction, setPendingAction] = useState(null);
+ 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;
+
+ // 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
+ // entity.
+ useEffect(() => {
+ setError(null);
+ setPendingAction(null);
+ setConfirmAction(null);
+ return watchEntityStatus(entityType, entityId);
+ }, [entityType, entityId, watchEntityStatus]);
+
+ const notAvailable = status === 'unavailable';
+ // 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
+ // 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 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';
+ return '';
+ };
+
+ const dispatchAction = useCallback(
+ async (action: LifecycleAction) => {
+ if (!client) return;
+ const dispatchedFor = entityStatusKey(entityType, entityId);
+ const stillShown = () => shownKeyRef.current === dispatchedFor;
+ setPendingAction(action);
+ setError(null);
+ try {
+ const result = await setStatus(client, entityType, entityId, action);
+ 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.
+ setEntityActuation(entityType, entityId, false);
+ const msg = errorMessageOf(result.error);
+ toast.warning(`${action} is not implemented for ${entityId}${msg ? `: ${msg}` : ''}`);
+ return;
+ }
+ // 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);
+ // 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;
+ }
+ // 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
+ // 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);
+ toast.error(`Failed to ${action} ${entityId}: ${message}`);
+ } finally {
+ // 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, invalidateEntityStatus, setEntityActuation]
+ );
+
+ 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 (
+
+ ready
+
+ );
+ }
+ if (status === 'notReady') {
+ return (
+
+ notReady
+
+ );
+ }
+ return unknown;
+ })();
+
+ return (
+
+
+
+
+ Lifecycle
+
+ {statusBadge}
+ {notAvailable && (
+
+
+ not available
+
+ )}
+
+
+
+ {ACTIONS.map(({ action, label, icon: Icon, variant }) => {
+ const isPending = pendingAction === 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 = (
+
+ );
+
+ if (tip) {
+ return (
+
+ {button}
+ {tip}
+
+ );
+ }
+ return button;
+ })}
+
+
+ {actuationUnsupported && (
+
+
+ Transitions not implemented for this entity (yet)
+
+ )}
+
+ {error && !notAvailable && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/EntityTreeNode.test.tsx b/src/components/EntityTreeNode.test.tsx
new file mode 100644
index 0000000..94f7254
--- /dev/null
+++ b/src/components/EntityTreeNode.test.tsx
@@ -0,0 +1,161 @@
+// 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 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(watchEntityStatus).toHaveBeenCalledWith('apps', 'talker');
+ expect(screen.getByLabelText(/status: ready/i)).toBeInTheDocument();
+ });
+
+ 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(watchEntityStatus).toHaveBeenCalledWith('components', 'host1');
+ 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 watchEntityStatus = vi.fn(() => () => {});
+ useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' }, watchEntityStatus } as never);
+ render(
+
+ );
+ expect(screen.getByRole('img', { name: /status: ready/i })).toBeInTheDocument();
+ });
+
+ it('separates ready from notReady by more than colour', () => {
+ 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' }, watchEntityStatus } 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 watches nothing', () => {
+ const watchEntityStatus = vi.fn(() => () => {});
+ useAppStore.setState({ watchEntityStatus } as never);
+ render();
+ expect(watchEntityStatus).not.toHaveBeenCalled();
+ expect(screen.queryByLabelText(/status:/i)).not.toBeInTheDocument();
+ });
+});
+
+describe('EntityTreeNode label', () => {
+ beforeEach(() => {
+ useAppStore.setState({ statusByEntity: {}, expandedPaths: [], loadingPaths: [], selectedPath: null });
+ });
+ afterEach(() => cleanup());
+
+ 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(
+
+ );
+ expect(screen.getByText('a3d9')).toBeInTheDocument();
+ expect(screen.getByText('Ubuntu 24.04.4 LTS on x86_64')).toBeInTheDocument();
+ });
+
+ it('shows the name alone when there is no description', () => {
+ useAppStore.setState({ watchEntityStatus: vi.fn(() => () => {}) } as never);
+ render(
+
+ );
+ expect(screen.getByText('talker')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx
index 4bd8c30..451b9b3 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,23 @@ function getEntityColor(type: string, isSelected?: boolean): string {
}
}
+/**
+ * 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 getLampClass(status: string | undefined): string {
+ switch (status) {
+ case 'ready':
+ return 'rounded-full bg-emerald-500';
+ case 'notReady':
+ return 'rounded-full border-2 border-amber-500 bg-transparent';
+ default:
+ return 'rounded-sm bg-muted-foreground/40';
+ }
+}
+
/**
* Check if node data is TopicNodeData (from topicsInfo)
*/
@@ -140,6 +157,32 @@ 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 watchEntityStatus = useAppStore((s) => s.watchEntityStatus);
+
+ // 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) return;
+ return watchEntityStatus(lifecycleType, node.id);
+ }, [isLifecycleEntity, lifecycleType, node.id, watchEntityStatus]);
+
+ // The name identifies the entity and the description qualifies it, so the
+ // name leads: a description is entity metadata (a component's is the host's
+ // OS, identical across every component on that host) and cannot stand in for
+ // an identifier.
+ const nodeLabel = typeof node.name === 'string' && node.name ? node.name : String(node.id || '');
+ const nodeDescription = typeof node.description === 'string' ? node.description : '';
+ const nodeTitle = nodeDescription ? `${nodeLabel} - ${nodeDescription}` : nodeLabel;
+
const isExpanded = expandedPaths.includes(node.path);
const isLoading = loadingPaths.includes(node.path);
const isSelected = selectedPath === node.path;
@@ -221,8 +264,20 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) {
-
- {typeof node.name === 'string' ? node.name : String(node.name || node.id || '')}
+ {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.
+
+ )}
+
+
+ {nodeLabel}
+ {nodeDescription && {nodeDescription}}
{/* Topic direction indicators */}
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/store.test.ts b/src/lib/store.test.ts
new file mode 100644
index 0000000..d252554
--- /dev/null
+++ b/src/lib/store.test.ts
@@ -0,0 +1,251 @@
+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
+// module instead and drive the return value per-test.
+vi.mock('./api-dispatch', () => ({
+ getStatus: vi.fn(),
+ setStatus: vi.fn(),
+}));
+
+import { useAppStore, entityStatusKey, __resetStatusRequestCache } 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('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([
+ useAppStore.getState().fetchEntityStatus('apps', 'planner'),
+ useAppStore.getState().fetchEntityStatus('apps', 'planner'),
+ ]);
+ expect(getStatusMock).toHaveBeenCalledTimes(1);
+ });
+});
+
+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('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('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().actuationByEntity).toEqual({});
+ });
+});
diff --git a/src/lib/store.ts b/src/lib/store.ts
index 10e08dc..c5d20f9 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,11 +48,19 @@ import {
getEntityLogs,
getEntityLogsConfiguration,
putEntityLogsConfiguration,
+ getStatus,
+ type LifecycleEntityType,
} from './api-dispatch';
import type { LogCollection, LogsConfiguration, LogsFetchResult, LogsQueryParams } from './log-types';
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';
@@ -108,6 +117,20 @@ export interface AppState {
isLoadingFaults: boolean;
faultStreamCleanup: (() => void) | null;
+ // Lifecycle status cache (apps/components only).
+ // Key is `${entityType}:${entityId}` (plural type); see entityStatusKey.
+ statusByEntity: Record;
+
+ // 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;
+
// Actions
connect: (url: string) => Promise;
disconnect: () => void;
@@ -157,6 +180,24 @@ export interface AppState {
startExecutionPolling: () => void;
stopExecutionPolling: () => void;
+ // 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 an entity supports lifecycle actuation (see actuationByEntity).
+ setEntityActuation: (entityType: LifecycleEntityType, entityId: string, supported: boolean) => void;
+
// Faults actions
fetchFaults: () => Promise;
clearFault: (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => Promise;
@@ -734,6 +775,44 @@ 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>();
+
+/**
+ * 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[]> {
if (inFlightAppsRequest) return inFlightAppsRequest;
inFlightAppsRequest = client
@@ -865,9 +944,27 @@ export const useAppStore = create()(
isLoadingFaults: false,
faultStreamCleanup: null,
+ // Lifecycle status cache
+ statusByEntity: {},
+
+ // Per-entity lifecycle actuation support (unknown until observed).
+ actuationByEntity: {},
+ statusPollingIntervalId: null,
+
// Connect to ros2_medkit gateway
connect: async (url: string) => {
- set({ isConnecting: true, connectionError: 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,
+ actuationByEntity: {},
+ statusByEntity: {},
+ });
try {
const client = createMedkitClient({ baseUrl: url, fetch: fetch.bind(globalThis) });
@@ -925,6 +1022,11 @@ 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.
+ get().stopStatusPolling();
+ __resetStatusRequestCache();
+
// Unsubscribe from fault stream
get().unsubscribeFaultStream();
@@ -940,6 +1042,8 @@ export const useAppStore = create()(
selectedPath: null,
selectedEntity: null,
activeExecutions: new Map(),
+ actuationByEntity: {},
+ statusByEntity: {},
});
},
@@ -1877,6 +1981,113 @@ 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 epoch = statusEpochs.get(key) ?? 0;
+
+ const request = (async () => {
+ try {
+ const result = await getStatus(client, entityType, entityId);
+ let value: EntityStatusValue = 'unknown';
+ // 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;
+ }
+ writeStatus(value);
+ } catch {
+ writeStatus('unknown');
+ } finally {
+ // 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
+ // 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 } }));
+ }
+ })();
+
+ inFlightStatusRequests.set(key, request);
+ 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 });
+ }
+ },
+
+ setEntityActuation: (entityType: LifecycleEntityType, entityId: string, supported: boolean) => {
+ const key = entityStatusKey(entityType, entityId);
+ set((s) => ({ actuationByEntity: { ...s.actuationByEntity, [key]: supported } }));
+ },
+
// ===========================================================================
// FAULTS ACTIONS (Diagnostic Trouble Codes)
// ===========================================================================
diff --git a/src/lib/types.ts b/src/lib/types.ts
index c482b73..7d75b98 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -64,6 +64,29 @@ 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
+ * in the `status` field of the GET /apps/{id} and GET /components/{id} responses.
+ */
+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
*/
@@ -103,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 */
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 {}
+ };
+}