Skip to content

Surface entity lifecycle status control (apps + components) - #87

Open
bburda wants to merge 11 commits into
mainfrom
feat/entity-lifecycle-status
Open

Surface entity lifecycle status control (apps + components)#87
bburda wants to merge 11 commits into
mainfrom
feat/entity-lifecycle-status

Conversation

@bburda

@bburda bburda commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Add an EntityStatusControl to the Apps and Components entity detail, consuming the gateway 0.6.0 lifecycle status API. It shows the live readiness status (ready / notReady) and offers the transition actions (start / restart / force-restart / shutdown / force-shutdown) via the typed client. The 501 "no lifecycle provider configured" case is surfaced as a disabled not-available state. Lifecycle status exists only for apps and components (not areas/functions).


Issue


Type

  • Bug fix
  • New feature
  • Breaking change
  • Documentation only

Testing

In a worktree branched from origin/main (post-0.6.0-migration):

  • npm run lint (eslint) - clean
  • npm run typecheck (tsc --noEmit) - clean
  • npm test -- --run (vitest) - 421 passed (18 files, incl. 7 new EntityStatusControl tests)
  • npm run build - succeeds

Checklist

  • Breaking changes are clearly described (none - additive feature)
  • Linting passes (npm run lint)
  • Build succeeds (npm run build)
  • Docs were updated if behavior or public API changed (README feature list)

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).
Copilot AI review requested due to automatic review settings June 25, 2026 08:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new UI control to surface gateway 0.6.0 lifecycle status/transition actions for apps and components, wiring it into the existing entity detail views via the typed OpenAPI client dispatch layer.

Changes:

  • Added lifecycle status/action type definitions and API dispatch helpers (getStatus / setStatus) for apps/components.
  • Introduced EntityStatusControl UI component + Vitest coverage for status rendering and action handling (incl. 501 “not available”).
  • Integrated the control into Apps and Component detail UIs and documented the feature in the README.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/lib/types.ts Adds lifecycle action/status union types for gateway 0.6.0 lifecycle API.
src/lib/api-dispatch.ts Adds app/component-only lifecycle status GET and transition PUT dispatch helpers.
src/components/EntityStatusControl.tsx New UI control showing readiness badge + lifecycle transition buttons with 501 handling.
src/components/EntityStatusControl.test.tsx New tests validating rendering, action calls, refresh behavior, and 501 handling.
src/components/EntityDetailPanel.tsx Renders lifecycle control for components in the detail header.
src/components/AppsPanel.tsx Renders lifecycle control for apps in the app header area.
README.md Documents the new lifecycle status control feature.

Comment thread src/components/EntityStatusControl.tsx Outdated
Comment on lines +65 to +69
const [currentStatus, setCurrentStatus] = useState<LifecycleStatus | null>(toLifecycleStatus(status));
const [pendingAction, setPendingAction] = useState<LifecycleAction | null>(null);
const [notAvailable, setNotAvailable] = useState(false);
const [error, setError] = useState<string | null>(null);

Comment on lines +141 to +144
{/* Lifecycle status control (gateway 0.6.0 lifecycle API) */}
<div className="mt-4">
<EntityStatusControl entityType="apps" entityId={appId} />
</div>
Comment thread src/lib/types.ts
Comment on lines +81 to +85
/**
* Lifecycle readiness value reported by GET /{entity}/{id}/status and carried
* on AppDetail/ComponentDetail.
*/
export type LifecycleStatus = 'ready' | 'notReady';
Comment thread src/components/EntityStatusControl.tsx Outdated
Comment on lines +76 to +83
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);
}
{/* Lifecycle status control (gateway 0.6.0 lifecycle API) */}
{isComponent && (
<div className="mt-4">
<EntityStatusControl entityType="components" entityId={entityId} />
@bburda
bburda force-pushed the feat/entity-lifecycle-status branch from 2f5f6eb to 35c2b5d Compare June 25, 2026 12:19
…tus 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.
@bburda bburda self-assigned this Jul 28, 2026
toast.warning(`${action} is not implemented by this gateway${msg ? `: ${msg}` : ''}`);
return;
}
if (result.error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

major - a failed transition is reported as success when the error body is empty.

Success here is decided by the truthiness of result.error, and openapi-fetch does not always populate it on failure. node_modules/openapi-fetch/src/index.js:245 returns { error: undefined, response } for a non-ok response when status === 204, the method is HEAD, or Content-Length: 0. Line 268 is the other case: error = await response.text(), which is '' for an empty body with no Content-Length.

So a 500/502/503/504 with an empty body (nginx, a proxy in front of the gateway, the gateway aborting) misses the 501 branch, misses this branch, and lands on the success path: setActuationSupported(true), a green "shutdown requested" toast, and a status refetch. The user is told a destructive transition succeeded when the gateway never accepted it, and the gateway gets marked as supporting actuation on the strength of a failure.

Branch on the HTTP status instead (!result.response.ok), and fall back to a status-derived message when result.error is empty.

Comment thread src/lib/store.ts
selectedPath: null,
selectedEntity: null,
activeExecutions: new Map(),
actuationSupported: null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium - statusByEntity and the in-flight map survive disconnect/reconnect.

disconnect resets actuationSupported but not statusByEntity, connect resets only actuationSupported, and the module-level inFlightStatusRequests is never cleared (__resetStatusRequestCache is exported and called from nowhere, tests included).

Connecting to a second gateway therefore renders the previous gateway's readiness for every colliding entity id (components:host1, apps:talker are not unique per robot), and a cached 'unavailable' disables all five buttons for that window.

Worse, the dedupe is not connection-scoped. If a fetchEntityStatus('components','host1') is still in flight when the user disconnects and reconnects, the newly mounted node calls fetchEntityStatus for the same key, gets the old gateway's promise back, and never issues a request against the new one. The old gateway's answer is written into the cache, and since nothing refetches afterwards it stays there.

Clear statusByEntity and call __resetStatusRequestCache() in both connect and disconnect.

// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium - one entity's 501 latches the control off gateway-wide with no way back.

actuationSupported is store-wide, so a single 501 makes actuationUnsupported true in every mounted EntityStatusControl and isDisabled returns true for all five actions on every app and every component. The only writer that can clear it is setActuationSupported(true) on a 2xx transition, which is now unreachable because every button is disabled. A reconnect is the only escape.

It is also inconsistent with the read side: fetchEntityStatus scopes a 501 per entity (statusByEntity[key] = 'unavailable'), while the write side generalises it to the whole gateway. If actuation is configured for some entities and not others, the first entity without a provider locks the user out of the ones that have one for the rest of the session.

Key this per entity the way the read side already does, or add a re-probe that does not require reconnecting.

// Any 2xx proves the gateway can actuate; clear a stale "unsupported".
setActuationSupported(true);
toast.success(`${action} requested for ${entityId}`);
await fetchEntityStatus(entityType, entityId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium - this refetch races the transition it just requested.

It fires the moment the PUT resolves. The test file asserts on ok(202), and 202 means accepted, not applied - restarting or shutting down a ROS 2 node takes longer than the round trip of this GET, so it almost always reads pre-transition readiness.

The stale value drives the gating, so it is not cosmetic: after a confirmed Shutdown the badge stays ready, Start stays disabled with "Already running", Shutdown stays enabled. Nothing refetches afterwards, so the control stays wrong until the panel unmounts.

Two aggravators: the dedupe in fetchEntityStatus can hand back a promise created before the PUT was even sent, so this may resolve against an even older read; and there is no retry.

Poll a few times with a short backoff after a 2xx, or set the cached value to 'unknown' and let a periodic refresh settle it.

// control's own fetch.
useEffect(() => {
if (isLifecycleEntity) {
fetchEntityStatus(lifecycleType, node.id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor - readiness is fetched once per mount and never refreshed.

A tree node stays mounted for as long as its parent is expanded, so its lamp is frozen at whatever the entity's readiness was when the branch was first opened. If an app crashes the lamp stays green indefinitely. Same for the badge in EntityStatusControl, which fetches only in its mount effect.

The store already runs an SSE fault stream and startExecutionPolling for other live state, so a green lamp reads as current when it is not. Refresh on an interval while entities are visible, or invalidate from the fault stream.

{typeof node.name === 'string' ? node.name : String(node.name || node.id || '')}
{isLifecycleEntity && (
<span
aria-label={`status: ${status ?? 'unknown'}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor - the lamp is invisible to assistive tech and encodes state in colour only.

This is an empty <span> with no role. A span with no content and no role maps to the generic role, on which aria-label is prohibited by ARIA in HTML and dropped by the browsers, so the readiness never reaches the accessibility tree. The getByLabelText assertions in EntityTreeNode.test.tsx pass because jsdom reads the attribute rather than computing the accessible name, so the tests do not catch it.

With the label not exposed, bg-emerald-500 vs bg-amber-500 is the only remaining channel in the tree. Add role="img" (or role="status") or a visually hidden text node, plus a shape/icon difference for the colour side.

/** Transitions disabled for a given cached readiness value. */
const DISABLED_BY_STATUS: Record<string, Set<LifecycleAction>> = {
ready: new Set<LifecycleAction>(['start']),
notReady: new Set<LifecycleAction>(['restart', 'shutdown', 'force-shutdown']),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor - force-restart is missing from the notReady set.

On a notReady entity, Restart is disabled with "Entity is not running" while Force restart sits enabled right beside it, opens the confirm dialog, and dispatches a restart against a stopped entity. force-shutdown being in the set makes this look like an oversight rather than a decision.

The README added in this PR states that transitions unavailable for the current status are disabled with an explanatory tooltip, which is not true for this one.

Comment thread src/lib/store.ts
try {
const result = await getStatus(client, entityType, entityId);
let value: EntityStatusValue = 'unknown';
if (result.response?.status === 501) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor - only 501 counts as "not implemented".

Every other non-2xx, including the 404 a gateway without the lifecycle routes returns, falls through to 'unknown'. DISABLED_BY_STATUS['unknown'] is undefined in the control, so the gating short-circuits and all five buttons render enabled under a grey "unknown" badge. The graceful degradation the PR describes only holds for a gateway that answers exactly 501.

The same gap covers the window before the first fetch resolves, where status is undefined and every action is live. Treat 404 like 501, and distinguish "not loaded yet" from "fetch failed" so the buttons are not enabled on a status the UI does not have.

return (
<Tooltip key={action}>
<TooltipTrigger asChild>
<span tabIndex={0}>{button}</span>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor - the tooltip wrapper adds unnamed tab stops.

The span has no role and no accessible name. On a gateway where lifecycle status is unavailable all five buttons are disabled, so a keyboard user tabs through five empty stops that announce nothing. The tooltip text is also not wired to the button as a description, so it never reaches a screen reader.

Put the tooltip text on the wrapper via aria-label/aria-describedby, or keep the button enabled and reject the action with the reason so it stays in the accessibility tree.

)}

<span className="text-sm truncate flex-1" title={typeof node.name === 'string' ? node.name : node.id}>
{(typeof node.description === 'string' && node.description) ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor - the label switches to a description that is not unique per entity.

The description is entity metadata, not an identifier. The test in this PR uses 'Ubuntu 24.04.4 LTS on x86_64' as a component description, which is a host property: every component on that host now renders the same truncated string in the sidebar, and the only way to tell them apart is hovering the title, which is unavailable to keyboard and touch users. EntityDetailPanel.tsx:782 does the same to the card title, so the id disappears from the header too.

Keep the name as the label and put the description in secondary text or the tooltip.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Surface entity lifecycle status control (apps + components)

3 participants