mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
c47b8eb8e9
* 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.
201 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|