Watch
1
0
Fork
You've already forked RedFlag
0

ui: command primitives extracted; agent page split-button sizing restored

CommandCard and CommandStatusBadge pulled out of Agents.tsx; restart
host split button back to flush edges with stretched trigger.
This commit is contained in:
Fimeg 2026-06-10 20:08:05 -04:00
commit 469d61c0dc
11 changed files with 520 additions and 200 deletions

View file

@ -0,0 +1,92 @@
import React from 'react';
import { RefreshCw, Clock, CheckCircle, XCircle, Activity, Package, Search } from 'lucide-react';
import { cn, formatRelativeTime } from '@/lib/utils';
import { getCommandStatus } from './CommandStatusBadge';
function getCommandDisplayInfo(command: any): { icon: React.ReactNode; label: string } {
const getPackageName = (cmd: any): string => {
if (cmd.package_name) return cmd.package_name;
if (cmd.params?.package_name) return cmd.params.package_name;
if (cmd.params?.update_id && cmd.update_name) return cmd.update_name;
return 'unknown package';
};
const actionMap: Record<string, { icon: React.ReactNode; label: string }> = {
scan: { icon: <RefreshCw className="h-4 w-4" />, label: 'System scan' },
install_updates: { icon: <Package className="h-4 w-4" />, label: `Installing ${getPackageName(command)}` },
dry_run_update: { icon: <Search className="h-4 w-4" />, label: `Checking dependencies for ${getPackageName(command)}` },
confirm_dependencies: { icon: <CheckCircle className="h-4 w-4" />, label: `Installing ${getPackageName(command)}` },
};
return actionMap[command.command_type] ?? {
icon: <Activity className="h-4 w-4" />,
label: command.command_type.replace('_', ' '),
};
}
function commandTimestamp(createdAt: string): string {
const createdTime = new Date(createdAt);
const hoursOld = (Date.now() - createdTime.getTime()) / (1000 * 60 * 60);
return hoursOld > 1 ? createdTime.toLocaleString() : formatRelativeTime(createdAt);
}
interface CommandCardProps {
command: any;
onCancel: (id: string) => void;
onDismiss: (id: string) => void;
disabled?: boolean;
}
const CommandCard: React.FC<CommandCardProps> = ({ command, onCancel, onDismiss, disabled }) => {
const displayInfo = getCommandDisplayInfo(command);
const statusInfo = getCommandStatus(command.status);
const isActive = command.status === 'running' || command.status === 'sent' || command.status === 'pending';
return (
<div className="flex items-start space-x-2 p-2 bg-gray-50 rounded border border-gray-200">
<div className="flex-shrink-0 mt-0.5">{displayInfo.icon}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900 truncate">
<span className="flex items-center space-x-1">
<span className={cn(
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border',
statusInfo.color
)}>
{command.status === 'running' && <RefreshCw className="h-3 w-3 animate-spin mr-1" />}
{command.status === 'pending' && <Clock className="h-3 w-3 mr-1" />}
{command.status === 'completed' && <CheckCircle className="h-3 w-3 mr-1" />}
{command.status === 'failed' && <XCircle className="h-3 w-3 mr-1" />}
{isActive ? command.status.replace('_', ' ') : statusInfo.text}
</span>
<span className="ml-1">{displayInfo.label}</span>
</span>
</span>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-gray-500">{commandTimestamp(command.created_at)}</span>
{(command.status === 'pending' || command.status === 'sent') && (
<button
onClick={() => onCancel(command.id)}
disabled={disabled}
className="text-xs text-red-600 hover:text-red-800 disabled:opacity-50"
>
Cancel
</button>
)}
{(command.status === 'timed_out' || command.status === 'failed') && (
<button
onClick={() => onDismiss(command.id)}
disabled={disabled}
className="text-xs text-gray-600 hover:text-gray-800 disabled:opacity-50"
>
Dismiss
</button>
)}
</div>
</div>
</div>
);
};
export default CommandCard;

View file

@ -0,0 +1,37 @@
import React from 'react';
import { cn } from '@/lib/utils';
type CommandStatus = 'pending' | 'sent' | 'running' | 'completed' | 'failed' | 'timed_out' | string;
interface StatusInfo {
text: string;
color: string;
}
export function getCommandStatus(status: CommandStatus): StatusInfo {
switch (status) {
case 'pending': return { text: 'Pending', color: 'text-amber-600 bg-amber-50 border-amber-200' };
case 'sent': return { text: 'Sent to agent', color: 'text-blue-600 bg-blue-50 border-blue-200' };
case 'running': return { text: 'Running', color: 'text-green-600 bg-green-50 border-green-200' };
case 'completed': return { text: 'Completed', color: 'text-gray-600 bg-gray-50 border-gray-200' };
case 'failed': return { text: 'Failed', color: 'text-red-600 bg-red-50 border-red-200' };
case 'timed_out': return { text: 'Timed out', color: 'text-red-600 bg-red-50 border-red-200' };
default: return { text: status, color: 'text-gray-600 bg-gray-50 border-gray-200' };
}
}
interface CommandStatusBadgeProps {
status: CommandStatus;
className?: string;
}
const CommandStatusBadge: React.FC<CommandStatusBadgeProps> = ({ status, className }) => {
const { text, color } = getCommandStatus(status);
return (
<span className={cn('inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border', color, className)}>
{text}
</span>
);
};
export default CommandStatusBadge;

View file

@ -9,3 +9,5 @@ export { default as StatCard, StatCardGroup } from './StatCard';
export { default as ScreenshotCard } from './ScreenshotCard';
export { default as MetricItem } from './MetricItem';
export { default as ProcessTable } from './ProcessTable';
export { default as CommandCard } from './CommandCard';
export { default as CommandStatusBadge, getCommandStatus } from './CommandStatusBadge';

View file

@ -3,7 +3,6 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import {
Computer,
RefreshCw,
Search,
ChevronRight as ChevronRightIcon,
ChevronDown,
Activity,
@ -18,7 +17,6 @@ import {
Download,
CheckCircle,
AlertCircle,
XCircle,
Power,
MonitorPlay,
Upload,
@ -30,6 +28,7 @@ import {
ScreenshotCard,
MetricItem,
ProcessTable,
CommandCard,
} from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
@ -394,49 +393,6 @@ const Agents: React.FC = () => {
return activeCommandsData.commands.filter(cmd => cmd.agent_id === selectedAgent.id);
};
// Helper function to get command display info
const getCommandDisplayInfo = (command: any) => {
// Helper to get package name from command params
const getPackageName = (cmd: any) => {
if (cmd.package_name) return cmd.package_name;
if (cmd.params?.package_name) return cmd.params.package_name;
if (cmd.params?.update_id && cmd.update_name) return cmd.update_name;
return 'unknown package';
};
const actionMap: { [key: string]: { icon: React.ReactNode; label: string } } = {
'scan': { icon: <RefreshCw className="h-4 w-4" />, label: 'System scan' },
'install_updates': { icon: <Package className="h-4 w-4" />, label: `Installing ${getPackageName(command)}` },
'dry_run_update': { icon: <Search className="h-4 w-4" />, label: `Checking dependencies for ${getPackageName(command)}` },
'confirm_dependencies': { icon: <CheckCircle className="h-4 w-4" />, label: `Installing ${getPackageName(command)}` },
};
return actionMap[command.command_type] || {
icon: <Activity className="h-4 w-4" />,
label: command.command_type.replace('_', ' ')
};
};
// Get command status
const getCommandStatus = (command: any) => {
switch (command.status) {
case 'pending':
return { text: 'Pending', color: 'text-amber-600 bg-amber-50 border-amber-200' };
case 'sent':
return { text: 'Sent to agent', color: 'text-blue-600 bg-blue-50 border-blue-200' };
case 'running':
return { text: 'Running', color: 'text-green-600 bg-green-50 border-green-200' };
case 'completed':
return { text: 'Completed', color: 'text-gray-600 bg-gray-50 border-gray-200' };
case 'failed':
return { text: 'Failed', color: 'text-red-600 bg-red-50 border-red-200' };
case 'timed_out':
return { text: 'Timed out', color: 'text-red-600 bg-red-50 border-red-200' };
default:
return { text: command.status, color: 'text-gray-600 bg-gray-50 border-gray-200' };
}
};
// Get unique OS types for filter
const osTypes = [...new Set(agents.map(agent => agent.os_type))];
@ -718,84 +674,15 @@ const Agents: React.FC = () => {
);
}
return displayCommands.map((command, _index) => {
const displayInfo = getCommandDisplayInfo(command);
const statusInfo = getCommandStatus(command);
const isActive = command.status === 'running' || command.status === 'sent' || command.status === 'pending';
return (
<div key={command.id} className="flex items-start space-x-2 p-2 bg-gray-50 rounded border border-gray-200">
<div className="flex-shrink-0 mt-0.5">
{displayInfo.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900 truncate">
{isActive ? (
<span className="flex items-center space-x-1">
<span className={cn(
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border',
statusInfo.color
)}>
{command.status === 'running' && <RefreshCw className="h-3 w-3 animate-spin mr-1" />}
{command.status === 'pending' && <Clock className="h-3 w-3 mr-1" />}
{isActive ? command.status.replace('_', ' ') : statusInfo.text}
</span>
<span className="ml-1">{displayInfo.label}</span>
</span>
) : (
<span className="flex items-center space-x-1">
<span className={cn(
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border',
statusInfo.color
)}>
{command.status === 'completed' && <CheckCircle className="h-3 w-3 mr-1" />}
{command.status === 'failed' && <XCircle className="h-3 w-3 mr-1" />}
{statusInfo.text}
</span>
<span className="ml-1">{displayInfo.label}</span>
</span>
)}
</span>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-gray-500">
{(() => {
const createdTime = new Date(command.created_at);
const now = new Date();
const hoursOld = (now.getTime() - createdTime.getTime()) / (1000 * 60 * 60);
// Show exact time for commands older than 1 hour, relative time for recent ones
if (hoursOld > 1) {
return createdTime.toLocaleString();
} else {
return formatRelativeTime(command.created_at);
}
})()}
</span>
{isActive && (command.status === 'pending' || command.status === 'sent') && (
<button
onClick={() => handleCancelCommand(command.id)}
disabled={cancelCommandMutation.isPending}
className="text-xs text-red-600 hover:text-red-800 disabled:opacity-50"
>
Cancel
</button>
)}
{command.status === 'timed_out' && (
<button
onClick={() => handleCancelCommand(command.id)}
disabled={cancelCommandMutation.isPending}
className="text-xs text-gray-600 hover:text-gray-800 disabled:opacity-50"
>
Dismiss
</button>
)}
</div>
</div>
</div>
);
});
return displayCommands.map((command) => (
<CommandCard
key={command.id}
command={command}
onCancel={handleCancelCommand}
onDismiss={handleCancelCommand}
disabled={cancelCommandMutation.isPending}
/>
));
})()}
</div>
@ -1003,16 +890,16 @@ const Agents: React.FC = () => {
<Power className="h-4 w-4 mr-2" />
Restart Host
</button>
<div className="relative">
<div className="relative flex">
<button
onClick={() => setShowRestartDropdown(!showRestartDropdown)}
className="btn btn-warning rounded-l-none px-2"
className="btn btn-warning rounded-l-none px-2 self-stretch"
title="More actions"
>
<ChevronDown className="h-4 w-4" />
</button>
{showRestartDropdown && (
<div className="absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<div className="absolute right-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<button
onClick={() => {
setShowRestartDropdown(false);