Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 56 additions & 71 deletions src/components/ActionStatusPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { useEffect, useCallback } from 'react';
import { useEffect, useCallback, useState } from 'react';
import { useShallow } from 'zustand/shallow';
import { Activity, RefreshCw, XCircle, CheckCircle, AlertCircle, Clock, Loader2, Navigation } from 'lucide-react';
import { Activity, RefreshCw, XCircle, CheckCircle, AlertCircle, Clock, Navigation } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useAppStore, type AppState } from '@/lib/store';
import type { ExecutionStatus, SovdResourceEntityType } from '@/lib/types';
import { useAppStore, type AppState, type TrackedExecution } from '@/lib/store';
import type { ExecutionStatus } from '@/lib/types';

/** Delay before hiding terminal state panel (ms) */
const TERMINAL_STATE_DISPLAY_DELAY_MS = 3000;

interface ActionStatusPanelProps {
entityId: string;
operationName: string;
/** Execution ID to display and monitor */
executionId: string;
entityType?: SovdResourceEntityType;
}

/**
Expand All @@ -34,6 +35,7 @@ function getStatusStyle(status: ExecutionStatus): {
bgColor: 'bg-blue-500/10',
};
case 'succeeded':
case 'completed':
return {
variant: 'default',
icon: CheckCircle,
Expand Down Expand Up @@ -68,7 +70,7 @@ function getStatusStyle(status: ExecutionStatus): {
* Check if status is terminal (no more updates expected)
*/
function isTerminalStatus(status: ExecutionStatus): boolean {
return ['succeeded', 'canceled', 'failed'].includes(status);
return ['succeeded', 'canceled', 'failed', 'completed'].includes(status);
}

/**
Expand All @@ -78,69 +80,68 @@ function isActiveStatus(status: ExecutionStatus): boolean {
return ['pending', 'running'].includes(status);
}

export function ActionStatusPanel({
entityId,
operationName,
executionId,
entityType = 'components',
}: ActionStatusPanelProps) {
const {
activeExecutions,
autoRefreshExecutions,
refreshExecutionStatus,
cancelExecution,
setAutoRefreshExecutions,
} = useAppStore(
useShallow((state: AppState) => ({
activeExecutions: state.activeExecutions,
autoRefreshExecutions: state.autoRefreshExecutions,
refreshExecutionStatus: state.refreshExecutionStatus,
cancelExecution: state.cancelExecution,
setAutoRefreshExecutions: state.setAutoRefreshExecutions,
}))
);
export function ActionStatusPanel({ executionId }: ActionStatusPanelProps) {
const { activeExecutions, autoRefreshExecutions, cancelExecution, startExecutionPolling, refreshExecutionStatus } =
useAppStore(
useShallow((state: AppState) => ({
activeExecutions: state.activeExecutions,
autoRefreshExecutions: state.autoRefreshExecutions,
cancelExecution: state.cancelExecution,
startExecutionPolling: state.startExecutionPolling,
refreshExecutionStatus: state.refreshExecutionStatus,
}))
);

const execution = activeExecutions.get(executionId);
const execution = activeExecutions.get(executionId) as TrackedExecution | undefined;
const statusStyle = execution ? getStatusStyle(execution.status) : null;
const StatusIcon = statusStyle?.icon || Clock;
const isTerminal = execution ? isTerminalStatus(execution.status) : false;
const isActive = execution ? isActiveStatus(execution.status) : false;
const canCancel = execution && ['pending', 'running'].includes(execution.status);

// Track whether to hide terminal state panel (with delay)
const [shouldHide, setShouldHide] = useState(false);

// Manual refresh
const handleRefresh = useCallback(() => {
refreshExecutionStatus(entityId, operationName, executionId, entityType);
}, [entityId, operationName, executionId, refreshExecutionStatus, entityType]);
if (execution) {
refreshExecutionStatus(execution.entityId, execution.operationName, executionId, execution.entityType);
}
}, [execution, executionId, refreshExecutionStatus]);

// Cancel action
const handleCancel = useCallback(async () => {
await cancelExecution(entityId, operationName, executionId, entityType);
}, [entityId, operationName, executionId, cancelExecution, entityType]);
if (execution) {
await cancelExecution(execution.entityId, execution.operationName, executionId, execution.entityType);
}
}, [execution, executionId, cancelExecution]);

// Auto-refresh effect
// Start polling on mount if auto-refresh is enabled and execution is active
// Polling is managed by the store, so this just ensures it's started
useEffect(() => {
if (!autoRefreshExecutions || isTerminal) return;

const interval = setInterval(() => {
refreshExecutionStatus(entityId, operationName, executionId, entityType);
}, 1000); // Refresh every second

return () => clearInterval(interval);
}, [autoRefreshExecutions, isTerminal, entityId, operationName, executionId, refreshExecutionStatus, entityType]);
if (autoRefreshExecutions && isActive) {
startExecutionPolling();
}
}, [autoRefreshExecutions, isActive, startExecutionPolling]);

// Initial fetch
// Delay hiding terminal state so users can see the final status
useEffect(() => {
if (!execution) {
refreshExecutionStatus(entityId, operationName, executionId, entityType);
if (isTerminal && !shouldHide) {
const timer = setTimeout(() => {
setShouldHide(true);
}, TERMINAL_STATE_DISPLAY_DELAY_MS);
return () => clearTimeout(timer);
}
}, [executionId, execution, entityId, operationName, refreshExecutionStatus, entityType]);
// Reset shouldHide if execution becomes active again (e.g., new execution)
if (!isTerminal && shouldHide) {
setShouldHide(false);
}
return undefined;
}, [isTerminal, shouldHide]);

if (!execution) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
);
// Don't render if no execution or if terminal state delay has passed
if (!execution || (isTerminal && shouldHide)) {
return null;
}

return (
Expand All @@ -167,23 +168,6 @@ export function ActionStatusPanel({
</div>

<div className="flex items-center gap-2">
{/* Auto-refresh checkbox */}
<label
htmlFor={`auto-refresh-${executionId}`}
className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer"
>
<input
id={`auto-refresh-${executionId}`}
type="checkbox"
checked={autoRefreshExecutions}
onChange={(e) => setAutoRefreshExecutions(e.target.checked)}
className="rounded border-muted-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary"
disabled={isTerminal}
aria-label="Auto-refresh execution status"
/>
Auto-refresh
</label>

{/* Manual refresh */}
<Button
variant="ghost"
Expand Down Expand Up @@ -261,7 +245,8 @@ export function ActionStatusPanel({
<div className={`text-xs ${statusStyle?.color} flex items-center gap-1.5 font-medium`}>
<StatusIcon className="w-4 h-4" />
<span>
{execution.status === 'succeeded' && 'Execution completed successfully'}
{(execution.status === 'succeeded' || execution.status === 'completed') &&
'Execution completed successfully'}
{execution.status === 'canceled' && 'Execution was canceled'}
{execution.status === 'failed' && 'Execution failed'}
</span>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ConfigurationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export function ConfigurationPanel({ entityId, highlightParam, entityType = 'com
No parameters available for this component.
</div>
) : (
<div className="space-y-2">
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-1">
{parameters.map((param) => (
<ParameterRow
key={param.name}
Expand Down
6 changes: 3 additions & 3 deletions src/components/EntityDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ function DataTabContent({
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-2 max-h-[500px] overflow-y-auto pr-1">
{topics.map((topic) => {
const cleanName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic;
const encodedName = encodeURIComponent(cleanName);
Expand Down Expand Up @@ -213,7 +213,7 @@ function DataTabContent({
</div>
</CardHeader>
<CardContent>
<div className="space-y-1">
<div className="space-y-1 max-h-[300px] overflow-y-auto pr-1">
{topicsInfo.publishes.map((topic: string) => {
const cleanName = topic.startsWith('/') ? topic.slice(1) : topic;
const encodedName = encodeURIComponent(cleanName);
Expand Down Expand Up @@ -247,7 +247,7 @@ function DataTabContent({
</div>
</CardHeader>
<CardContent>
<div className="space-y-1">
<div className="space-y-1 max-h-[300px] overflow-y-auto pr-1">
{topicsInfo.subscribes.map((topic: string) => {
const cleanName = topic.startsWith('/') ? topic.slice(1) : topic;
const encodedName = encodeURIComponent(cleanName);
Expand Down
100 changes: 85 additions & 15 deletions src/components/OperationResponse.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { CheckCircle, XCircle, Clock, Loader2, Hash } from 'lucide-react';
import { CheckCircle, XCircle, Clock, Loader2, Hash, FileJson } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { CreateExecutionResponse, ExecutionStatus } from '@/lib/types';
import { useAppStore } from '@/lib/store';
import type { AppState } from '@/lib/store';
import { useShallow } from 'zustand/shallow';

interface OperationResponseProps {
response: CreateExecutionResponse;
/** Optional executionId to get live status from store */
executionId?: string;
}

function getStatusConfig(status: ExecutionStatus): {
Expand All @@ -13,6 +18,7 @@ function getStatusConfig(status: ExecutionStatus): {
} {
switch (status) {
case 'succeeded':
case 'completed':
return { icon: CheckCircle, color: 'text-green-500', variant: 'default' };
case 'running':
case 'pending':
Expand All @@ -26,33 +32,97 @@ function getStatusConfig(status: ExecutionStatus): {
}
}

export function OperationResponseDisplay({ response }: OperationResponseProps) {
const isSuccess = response.status === 'succeeded';
const statusConfig = getStatusConfig(response.status);
/**
* Check if response is a service call (no execution ID, has direct result/parameters)
*/
function isServiceResponse(response: CreateExecutionResponse): boolean {
return !response.id;
}

/**
* Extract result from response - handles both action and service response formats
*/
function extractResult(response: CreateExecutionResponse): unknown {
// Service responses: result is directly in the response (often as `parameters`)
if (isServiceResponse(response)) {
// Prefer explicit parameters field when present
if (response.parameters !== undefined) {
return response.parameters;
}
// Fallback: remove internal envelope fields before returning the result
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { status: _s, kind: _k, error: _e, ...publicResult } = response;
return Object.keys(publicResult).length > 0 ? publicResult : undefined;
}
// Action responses: result is in the result field
return response.result;
}

/**
* Get border and background classes based on execution status
*/
function getStatusBorderClass(status: ExecutionStatus): string {
if (status === 'succeeded' || status === 'completed') {
return 'border-green-500/30 bg-green-500/5';
}
if (status === 'failed') {
return 'border-destructive/30 bg-destructive/5';
}
return 'border-muted bg-muted/5';
}

export function OperationResponseDisplay({ response, executionId }: OperationResponseProps) {
// Get live status from store if executionId is provided
const activeExecution = useAppStore(
useShallow((state: AppState) => (executionId ? state.activeExecutions.get(executionId) : undefined))
);

const isService = isServiceResponse(response);

// Use live status if available (for actions), otherwise infer from response
// Services complete immediately, so status is always 'completed'
const currentStatus: ExecutionStatus = activeExecution?.status ?? (isService ? 'completed' : response.status);
const currentResult = activeExecution?.result ?? extractResult(response);

const statusConfig = getStatusConfig(currentStatus);
const StatusIcon = statusConfig.icon;

return (
<div
className={`rounded-lg border ${isSuccess ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/5'}`}
>
<div className={`rounded-lg border ${getStatusBorderClass(currentStatus)}`}>
{/* Header */}
<div className="flex items-center gap-3 px-3 py-2 border-b border-inherit">
<StatusIcon
className={`w-4 h-4 ${statusConfig.color} ${response.status === 'running' ? 'animate-spin' : ''}`}
className={`w-4 h-4 ${statusConfig.color} ${currentStatus === 'running' || currentStatus === 'pending' ? 'animate-spin' : ''}`}
/>
<div className="flex items-center gap-2 flex-1">
<Badge variant={statusConfig.variant}>{response.status}</Badge>
<Badge variant={statusConfig.variant}>{currentStatus}</Badge>
{isService && <span className="text-xs text-muted-foreground">(service)</span>}
</div>
</div>

{/* Body */}
<div className="p-3 space-y-2 text-sm">
{/* Execution ID */}
<div className="flex items-center gap-2">
<Hash className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-muted-foreground text-xs">Execution ID:</span>
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">{response.id}</code>
</div>
{/* Execution ID - only for actions */}
{response.id && (
<div className="flex items-center gap-2">
<Hash className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-muted-foreground text-xs">Execution ID:</span>
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">{response.id}</code>
</div>
)}

{/* Result (for services or completed actions) */}
{currentResult !== undefined && currentResult !== null && (
<div className="space-y-1">
<div className="flex items-center gap-2">
<FileJson className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-muted-foreground text-xs">Result:</span>
</div>
<pre className="bg-muted/50 p-2 rounded text-xs font-mono overflow-x-auto max-h-[200px] overflow-y-auto">
{typeof currentResult === 'string' ? currentResult : JSON.stringify(currentResult, null, 2)}
</pre>
</div>
)}
</div>
</div>
);
Expand Down
Loading