Files
sencho/frontend/src/components/sidebar/StackRow.tsx
T
Anso c47b8eb8e9 perf(frontend): overlap stack list and status hydration requests (#1820)
* perf(frontend): overlap stack list and status hydration requests

Fire /stacks and /stacks/statuses concurrently while committing the list
first, so list_visible stays progressive and statuses overlap the list
fetch instead of serializing behind it.

The visible list now renders as soon as it commits (isLoading clears at
list commit, not at status completion), and an atomic HydrationEvidence
record derived at render time fails closed on node switches, list
changes, and partial status payloads: lifecycle actions across the
sidebar menu, editor toolbar, mobile editor, bulk bar, and deferred
dialogs recheck a ref-backed readiness predicate immediately before any
mutation.

Status payloads are validated (bulk object format with typed fields,
legacy string maps with recognized values); the legacy per-stack
fallback targets the captured node and tracks coverage, so a total
fallback failure escalates to a hard error instead of masquerading as
ok. Pending, error, stale, and incomplete hydration states render
distinctly in rows, filter chips, and the mobile masthead.

Measured on the SEN-531 workload contract (local node, 6 stacks, Dev
Mode ON): hydration gap 52.5ms median to 0ms, full hydration ~146ms to
~84-100ms (~42% improvement). 2590 frontend tests, visual regression
12/12, backend tsc clean.

* fix(frontend): recheck hydration readiness in deferred lifecycle executors

Deferred continuations (pre-deploy advisory proceed, external-network
continue, update-readiness proceed, service-update proceed) dispatched
against the node captured when the dialog opened, without rechecking
readiness at the actual mutation boundary. A dialog opened on node A
could therefore mutate node A after the operator switched to node B.

Each executor now rechecks the ref-backed readiness predicate before
starting any operation, releasing the pending deploy guard and surfacing
a toast when blocked. Plain Save stays independent of status readiness
(mobile editor and diff-preview plain-save mode are no longer gated);
only Save & Deploy / Save & Reapply requires authoritative runtime
evidence.

Adds two-node delayed-response tests proving late prior-node list and
status results cannot replace the current node's files, statuses,
evidence, or loading ownership, plus deferred readiness-loss regression
tests for the update and service-update dialogs.

* fix(frontend): bind deferred executor readiness to the captured node

The previous readiness rechecks validated the CURRENT node, but deferred
continuations (pre-deploy advisory proceed, external-network continue,
update-readiness proceed, service-update proceed) still dispatched with
the node captured when the dialog opened. A dialog opened on node A could
mutate node A after the operator switched to node B and B finished
hydrating.

The executor-boundary predicate now also requires the captured operation
node to equal the current active node (effect-updated ref), so a
fully-hydrated node switch still blocks the stale continuation.

Adds ready-after-switch regression tests for the deploy continuation,
stack update, and service update (switch to node B, hydrate, invoke the
stored proceed, assert no operation session starts), plus focused plain
Save vs Save & Deploy tests for the mobile editor and the diff preview.
Restores the unrelated frontend lockfile metadata drift.

* fix(frontend): guard external-network creation and reactive retry by node ownership

The missing-external-networks dialog's createAndContinue posted network
creation requests to the captured node before any ownership check, and
the reactive 409 retry callback started a deploy directly without the
executor guard. A dialog opened on node A could create Docker networks
or deploy on node A after the operator switched to node B.

Both paths now recheck hydrationReadyForNode before any mutation: the
proactive dialog closes with the pending guard released and a toast when
blocked, and the reactive retry refuses to start an operation session.

Adds ready-after-switch regression tests for both flows asserting no
/system/networks POST and no operation session after a switch to a fully
hydrated node B.

* fix(frontend): bind policy bypass, delete, and take-down confirmations to their opening node

Three deferred confirmation paths remained unsafe across node switches:
the policy-bypass retry validated current readiness but dispatched to the
policy block's captured node; Delete and Take Down dialogs stored only a
stack name and derived the operation node live at confirmation time, so
a dialog opened on node A would mutate node B's same-named stack after a
switch.

Delete and Take Down targets now carry the opening node id in the overlay
state (deleteTarget / takeDownTarget), and their confirm handlers verify
hydrationReadyForNode against that id before mutating. The policy-bypass
retry uses the same predicate against the block's captured node.

The reactive 409 regression test now reaches the reactive path: the
proactive preflight reports no missing networks, the deploy POST returns
the 409, and the reactive refetch opens the dialog, after which an
ownership change during network creation blocks the retried deploy.

Also restores the unrelated frontend lockfile drift again.

* fix(frontend): validate legacy fallback payloads and target stale refreshes at the current node

The legacy per-stack fallback counted any successfully decoded JSON body
as authoritative coverage, including a 200 non-array body or an array
with malformed entries, which could authorize lifecycle actions on
non-authoritative evidence. The fallback now requires a container array
whose entries satisfy the minimal container shape; malformed responses
fail closed and contribute zero coverage.

A refreshStacks callback captured on node A and invoked after the
operator switched to node B mixed a live list request (localStorage
target) with node A statuses and evidence. refreshStacks now reads the
current node from its render-synchronous ref, and the list request is
targeted explicitly so both requests always share one authoritative
node.

Adds malformed-fallback regressions (non-array, malformed-array,
partial-invalid) and a stale-callback-after-switch test asserting both
requests target the current node and its state stays authoritative.
2026-08-12 11:54:29 -04:00

201 lines
7.6 KiB
TypeScript

import type { ReactNode } from 'react';
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import type { CheckStatus } from '@/types/imageUpdates';
import { isConfirmedImageUpdate } from '@/types/imageUpdates';
import { Checkbox } from '@/components/ui/checkbox';
import type { Label } from '@/components/label-types';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { sidebarRowActive, sidebarRowBase, sidebarRowCheckboxSlot } from './sidebar-styles';
import { statusText, statusColor } from './stack-status-utils';
import type { StackRowStatus } from './stack-status-utils';
import { updateAvailableLabel } from '@/lib/updateAvailableLabel';
interface StackRowProps {
file: string;
displayName: string;
status: StackRowStatus;
// Running/total container counts (set for any stack with containers); consumed only for the partial-stack pill tooltip.
running?: number;
total?: number;
/** Hydration display projection: pending/error force unknown indicators,
* stale dims the pill, current/incomplete render normally. */
hydrationDisplay?: 'pending' | 'error' | 'current' | 'stale' | 'incomplete';
isBusy: boolean;
isActive: boolean;
labels: Label[];
hasUpdate: boolean;
/** Outdated service names for the update tooltip; empty keeps the generic label. */
outdatedServices?: string[];
// Last image-update check outcome. Incomplete/failed checks with hasUpdate
// use a distinct indicator so they are not mistaken for a confirmed update.
checkStatus?: CheckStatus;
lastError?: string;
hasGitPending: boolean;
onSelect: (file: string) => void;
kebabSlot: ReactNode;
bulkMode?: boolean;
isSelected?: boolean;
onToggleSelect?: (file: string) => void;
}
function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4} align="center">
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function appendErrorDetail(base: string, lastError?: string): string {
if (!lastError) return base;
return `${base} ${lastError}`;
}
function partialUpdateTooltip(hasUpdate: boolean, lastError?: string): string {
if (hasUpdate) {
// Neutral copy: partial + hasUpdate can mean newly detected OR retained;
// provenance is not persisted on the wire.
return appendErrorDetail(
'The last check was incomplete; an update was detected or retained, but the full stack could not be verified.',
lastError,
);
}
return appendErrorDetail(
'The last image-update check was incomplete; update status could not be fully verified.',
lastError,
);
}
function failedCheckTooltip(hasUpdate: boolean, lastError?: string): string {
if (hasUpdate) {
return appendErrorDetail(
'Previous update status retained; the last check failed.',
lastError,
);
}
return lastError ? `Update check failed: ${lastError}` : 'Update check failed';
}
export function StackRow(props: StackRowProps) {
const {
file, displayName, status, running, total, isBusy, isActive,
hasUpdate, outdatedServices, checkStatus, lastError, hasGitPending, onSelect, kebabSlot,
bulkMode = false, isSelected = false, onToggleSelect,
hydrationDisplay = 'pending',
} = props;
const staleEvidence = hydrationDisplay === 'stale';
const confirmedUpdate = isConfirmedImageUpdate({ hasUpdate, checkStatus });
const partialIncomplete = checkStatus === 'partial';
const failedCheck = checkStatus === 'failed';
const handleClick = () => {
if (bulkMode) onToggleSelect?.(file);
else onSelect(file);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
};
return (
<div
data-testid="stack-row"
data-bulk={bulkMode ? 'true' : undefined}
role="button"
tabIndex={0}
className={cn(sidebarRowBase, isActive && sidebarRowActive)}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<span
className={cn(sidebarRowCheckboxSlot, bulkMode && 'opacity-100 pointer-events-auto')}
onClick={e => { e.stopPropagation(); onToggleSelect?.(file); }}
aria-hidden={!bulkMode}
>
{bulkMode && (
<Checkbox
checked={isSelected}
className="w-3.5 h-3.5 border-muted-foreground/40 data-[state=checked]:border-brand data-[state=checked]:bg-brand"
tabIndex={-1}
aria-label={`Select ${displayName}`}
/>
)}
</span>
{/* Status pill. Partial stacks add a hover tooltip with the running/total
count. Hydration state shapes the pill: pending stays muted, error
turns the unknown indicator warning-colored (distinct from pending),
stale dims the retained value so it is never read as current. */}
<span
title={staleEvidence ? 'Status data is stale' : undefined}
className={cn(
'font-mono text-[10px] shrink-0 w-[22px] flex items-center',
hydrationDisplay === 'error' ? 'text-warning' : statusColor(status, isBusy),
staleEvidence && 'opacity-50',
)}
>
{isBusy ? (
<Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} />
) : status === 'partial' && running !== undefined && total !== undefined ? (
<RowTooltip trigger={<span>{statusText(status)}</span>} label={`${running}/${total} running`} />
) : (
statusText(status)
)}
</span>
{/* Stack name */}
<span className="flex-1 truncate font-mono text-sm min-w-0">{displayName}</span>
{/* Trailing: confirmed update > partial incomplete > failed > git pending */}
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0" data-testid="stack-row-trailing">
{confirmedUpdate ? (
<RowTooltip
trigger={(
<span className="relative inline-flex w-2 h-2" data-testid="stack-trailing-update">
<span className="absolute inset-0 rounded-full bg-update opacity-75 animate-ping" />
<span className="relative w-2 h-2 rounded-full bg-update" />
</span>
)}
label={updateAvailableLabel(outdatedServices)}
/>
) : partialIncomplete ? (
<RowTooltip
trigger={<span data-testid="stack-trailing-check-partial"><AlertCircle className="w-3 h-3 text-warning-foreground/80" strokeWidth={1.5} /></span>}
label={partialUpdateTooltip(hasUpdate, lastError)}
/>
) : failedCheck ? (
<RowTooltip
trigger={<span data-testid="stack-trailing-check-failed"><AlertCircle className="w-3 h-3 text-muted-foreground/70" strokeWidth={1.5} /></span>}
label={failedCheckTooltip(hasUpdate, lastError)}
/>
) : hasGitPending ? (
<RowTooltip
trigger={<span data-testid="stack-trailing-git-pending"><GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} /></span>}
label="Git source update pending"
/>
) : null}
</span>
{/* Kebab: always rightmost. Hover-revealed on desktop; always visible on
touch viewports where there is no hover. */}
<div
className="opacity-0 group-hover:opacity-100 max-md:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{kebabSlot}
</div>
</div>
);
}