Watch
1
0
Fork
You've already forked RedFlag
0

refactor: StatusBadge + SeverityBadge primitives — one way to render state

Extract the repeated cn('badge', getStatusColor/getSeverityColor) span
into primitives; port Updates, Agents, Docker, LiveOperations,
PackageDetail, AgentUpdatesEnhanced. Docker's local colour maps deleted —
its image-lifecycle statuses join the central getStatusColor map, and its
severity palette aligns with the app-wide one. AgentUpdatesEnhanced's
divergent local severity palette replaced by the shared map.
This commit is contained in:
Fimeg 2026-06-11 08:05:58 -04:00
commit aee87c476d
9 changed files with 84 additions and 85 deletions

View file

@ -16,7 +16,7 @@ import {
CheckCircle,
XCircle,
} from 'lucide-react';
import { formatRelativeTime, formatBytes } from '@/lib/utils';
import { formatRelativeTime, formatBytes, getSeverityColor } from '@/lib/utils';
import { updateApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
@ -225,19 +225,6 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
const totalCount = updateData?.total || 0;
const totalPages = Math.ceil(totalCount / pageSize);
const getSeverityColor = (severity: string) => {
switch (severity.toLowerCase()) {
case 'critical': return 'text-red-600 bg-red-50';
case 'important':
case 'high': return 'text-orange-600 bg-orange-50';
case 'moderate':
case 'medium': return 'text-yellow-600 bg-yellow-50';
case 'low':
case 'none': return 'text-blue-600 bg-blue-50';
default: return 'text-gray-600 bg-gray-50';
}
};
const handleSelectUpdate = (updateId: string, checked: boolean) => {
if (checked) {
setSelectedUpdates([...selectedUpdates, updateId]);

View file

@ -0,0 +1,22 @@
import React from 'react';
import { cn, getStatusColor, getSeverityColor } from '@/lib/utils';
// State badge primitives — the one way to render a lifecycle status or a
// severity as a badge. Colour mapping lives in lib/utils (getStatusColor /
// getSeverityColor); these wrap the repeated `cn('badge', ...)` span so pages
// stop hand-rolling it (and drifting palettes while they're at it).
interface BadgeProps {
/** Extra classes merged onto the badge (e.g. 'text-[10px]', 'ml-auto'). */
className?: string;
/** Override the rendered text; defaults to the status/severity value. */
label?: React.ReactNode;
}
export const StatusBadge: React.FC<BadgeProps & { status: string }> = ({ status, className, label }) => (
<span className={cn('badge', getStatusColor(status), className)}>{label ?? status}</span>
);
export const SeverityBadge: React.FC<BadgeProps & { severity: string }> = ({ severity, className, label }) => (
<span className={cn('badge', getSeverityColor(severity), className)}>{label ?? severity}</span>
);

View file

@ -13,3 +13,4 @@ export { default as CommandCard } from './CommandCard';
export { default as CommandStatusBadge, getCommandStatus } from './CommandStatusBadge';
export { default as SortableTable } from './SortableTable';
export type { Column } from './SortableTable';
export { StatusBadge, SeverityBadge } from './StateBadge';

View file

@ -139,6 +139,19 @@ export const getStatusColor = (status: string): string => {
return 'text-danger-600 bg-danger-100';
case 'ignored':
return 'text-gray-500 bg-gray-100';
// Docker image lifecycle (docker_images.status)
case 'up-to-date':
return 'text-success-600 bg-success-100';
case 'update-available':
return 'text-info-600 bg-info-100';
case 'update-approved':
return 'text-orange-600 bg-orange-100';
case 'update-scheduled':
return 'text-purple-600 bg-purple-100';
case 'update-installing':
return 'text-indigo-600 bg-indigo-100';
case 'update-failed':
return 'text-danger-600 bg-danger-100';
default:
return 'text-gray-600 bg-gray-100';
}

View file

@ -30,6 +30,7 @@ import {
ProcessTable,
CommandCard,
SortableTable,
StatusBadge,
} from '@/components/primitives';
import type { Column } from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
@ -39,7 +40,7 @@ import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand }
import { useHeartbeatStatus } from '@/hooks/useHeartbeat';
import { agentApi } from '@/lib/api';
import { useQueryClient } from '@tanstack/react-query';
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
import { formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import { AgentStorage } from '@/components/AgentStorage';
@ -450,9 +451,10 @@ const Agents: React.FC = () => {
key: 'status', label: 'Status', sortKey: 'status',
render: (agent) => (
<div className="flex flex-col space-y-1 items-start">
<span className={cn('badge', getStatusColor(isOnline(agent.last_seen) ? 'online' : 'offline'))}>
{isOnline(agent.last_seen) ? 'Online' : 'Offline'}
</span>
<StatusBadge
status={isOnline(agent.last_seen) ? 'online' : 'offline'}
label={isOnline(agent.last_seen) ? 'Online' : 'Offline'}
/>
{agent.reboot_required && (
<span className="inline-flex items-center text-xs text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded-full w-fit">
<Power className="h-3 w-3 mr-1" />
@ -726,9 +728,10 @@ const Agents: React.FC = () => {
'w-3 h-3 rounded-full',
isOnline(selectedAgent.last_seen) ? 'bg-green-500' : 'bg-gray-400'
)}></div>
<span className={cn('badge', getStatusColor(isOnline(selectedAgent.last_seen) ? 'online' : 'offline'))}>
{isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
</span>
<StatusBadge
status={isOnline(selectedAgent.last_seen) ? 'online' : 'offline'}
label={isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
/>
</div>
{/* Heartbeat Status Indicator */}

View file

@ -5,7 +5,7 @@ import type { DockerContainer, DockerImage, RuntimeDockerContainer, RuntimeDocke
import { formatRelativeTime, cn } from '@/lib/utils';
import { useMultimodalFilter } from '@/hooks/useMultimodalFilter';
import { useColumnSort } from '@/hooks/useColumnSort';
import { SearchInput, FilterPill, FilterCountButton, PageState, SortableTable } from '@/components/primitives';
import { SearchInput, FilterPill, FilterCountButton, PageState, SortableTable, StatusBadge, SeverityBadge } from '@/components/primitives';
import type { Column } from '@/components/primitives';
// ─── Filter config ────────────────────────────────────────────────────────────
@ -19,28 +19,6 @@ const FILTER_CONFIG = {
// ─── Colour helpers ───────────────────────────────────────────────────────────
const getStatusColor = (status: string) => {
switch (status) {
case 'up-to-date': return 'bg-green-100 text-green-800';
case 'update-available': return 'bg-blue-100 text-blue-800';
case 'update-approved': return 'bg-orange-100 text-orange-800';
case 'update-scheduled': return 'bg-purple-100 text-purple-800';
case 'update-installing': return 'bg-yellow-100 text-yellow-800';
case 'update-failed': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'low': return 'bg-gray-100 text-gray-800';
case 'medium': return 'bg-blue-100 text-blue-800';
case 'high': return 'bg-orange-100 text-orange-800';
case 'critical': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const getContainerStateColor = (state: string) => {
switch (state.toLowerCase()) {
case 'running': return 'bg-green-50 text-green-700 border-green-200';
@ -266,13 +244,13 @@ const Docker: React.FC = () => {
<div className="text-gray-900 font-mono">{r.currentVersion}</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-green-600 font-medium font-mono"> {r.availableVersion}</span>
{r.status && <span className={cn('badge', getStatusColor(r.status))}>{r.status}</span>}
{r.status && <StatusBadge status={r.status} />}
</div>
</>
) : (
<div className="flex items-center gap-1.5">
<span className="text-gray-700 font-mono">{r.currentVersion}</span>
{r.status && <span className={cn('badge', getStatusColor(r.status))}>{r.status}</span>}
{r.status && <StatusBadge status={r.status} />}
</div>
)}
</div>
@ -283,7 +261,7 @@ const Docker: React.FC = () => {
key: 'severity', label: 'Risk', sortKey: 'severity',
render: (r) => (
r.availableVersion && r.severity
? <span className={cn('badge', getSeverityColor(r.severity))}>{r.severity}</span>
? <SeverityBadge severity={r.severity} />
: r.availableVersion
? <span className="badge bg-gray-100 text-gray-600">unknown</span>
: <span className="text-gray-300 text-sm"></span>

View file

@ -23,13 +23,13 @@ import {
Shield,
ShieldCheck,
} from 'lucide-react';
import { SearchInput, FilterDropdown, PageState } from '@/components/primitives';
import { SearchInput, FilterDropdown, PageState, StatusBadge } from '@/components/primitives';
import { useQuery } from '@tanstack/react-query';
import { useAgents } from '@/hooks/useAgents';
import { useActiveCommands, useRetryCommand, useCancelCommand, useClearFailedCommands } from '@/hooks/useCommands';
import { useUpdates } from '@/hooks/useUpdates';
import { logApi } from '@/lib/api';
import { getStatusColor, formatRelativeTime } from '@/lib/utils';
import { formatRelativeTime } from '@/lib/utils';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
@ -556,10 +556,15 @@ const LiveOperations: React.FC = () => {
<span className="text-lg font-medium text-gray-900">
{operation.packageName}
</span>
<span className={cn('badge', getStatusColor(operation.status))}>
{getStatusIcon(operation.status)}
<span className="ml-1">{operation.status}</span>
</span>
<StatusBadge
status={operation.status}
label={
<>
{getStatusIcon(operation.status)}
<span className="ml-1">{operation.status}</span>
</>
}
/>
{operation.activeTokenId && (
<span className={cn(
'inline-flex items-center gap-1 text-xs font-medium border rounded-full px-2 py-0.5',

View file

@ -29,14 +29,13 @@ import {
} from '@/hooks/useUpdates';
import { useRecentCommands } from '@/hooks/useCommands';
import {
getSeverityColor,
getStatusColor,
formatBytes,
formatRelativeTime,
advisoryUrl,
cveSeverityBadge,
} from '@/lib/utils';
import { cn } from '@/lib/utils';
import { StatusBadge, SeverityBadge } from '@/components/primitives';
import toast from 'react-hot-toast';
import DependencyClosureTree from '@/components/DependencyClosureTree';
import type { PackageFleetAgent } from '@/types';
@ -169,9 +168,7 @@ const PackageDetail: React.FC = () => {
<h1 className="text-base font-semibold text-gray-900">{pkgName}</h1>
<span className="text-xs text-gray-500 font-mono bg-gray-100 rounded px-1.5 py-0.5">{pkgType}</span>
{summary.severity && summary.severity !== 'unknown' && (
<span className={cn('badge', getSeverityColor(summary.severity))}>
{summary.severity}
</span>
<SeverityBadge severity={summary.severity} />
)}
{vulns.length > 0 && (
<span className="badge bg-amber-50 text-amber-700 border border-amber-200 text-xs inline-flex items-center gap-1">
@ -328,9 +325,7 @@ const PackageDetail: React.FC = () => {
)}
</td>
<td className="py-2">
<span className={cn('badge text-[10px]', getStatusColor(a.status))}>
{a.status.replace(/_/g, ' ')}
</span>
<StatusBadge status={a.status} className="text-[10px]" label={a.status.replace(/_/g, ' ')} />
</td>
<td className="py-2 text-right">
{a.can_approve && (
@ -568,9 +563,7 @@ const AgentDetailPane: React.FC<AgentDetailPaneProps> = ({
<div className="card flex items-center gap-3 text-sm text-gray-700">
<Computer className="h-4 w-4 text-gray-400 flex-shrink-0" />
<span className="font-medium">{agentRow.hostname}</span>
<span className={cn('badge', getStatusColor(agentRow.status))}>
{agentRow.status.replace(/_/g, ' ')}
</span>
<StatusBadge status={agentRow.status} label={agentRow.status.replace(/_/g, ' ')} />
<span className="text-xs text-gray-500 font-mono ml-auto">
{agentRow.current_version}
<ChevronRight className="inline h-3 w-3 mx-0.5 text-gray-400" />

View file

@ -23,13 +23,13 @@ import {
HardDrive,
FileText,
} from 'lucide-react';
import { SearchInput, FilterDropdown, FilterPill, StatCard, StatCardGroup, PageState, Pagination, Modal } from '@/components/primitives';
import { SearchInput, FilterDropdown, FilterPill, StatCard, StatCardGroup, PageState, Pagination, Modal, StatusBadge, SeverityBadge } from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
import { useQueryClient } from '@tanstack/react-query';
import { useUpdates, useUpdate, usePackages, usePackageFleet, usePackageVersions, useUpdateLifecycle, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useReopenUpdate, useResolveUpdate, useCancelCommand } from '@/hooks/useUpdates';
import { useRecentCommands } from '@/hooks/useCommands';
import type { UpdatePackage } from '@/types';
import { getSeverityColor, getStatusColor, getPackageTypeIcon, formatBytes, formatRelativeTime, advisoryUrl, cveSeverityBadge } from '@/lib/utils';
import { getPackageTypeIcon, formatBytes, formatRelativeTime, advisoryUrl, cveSeverityBadge } from '@/lib/utils';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import { updateApi } from '@/lib/api';
@ -495,19 +495,20 @@ const Updates: React.FC = () => {
<h1 className="text-2xl font-bold text-gray-900 truncate">
{selectedUpdate.package_name}
</h1>
<span className={cn('badge', getSeverityColor(selectedUpdate.severity))}>
{selectedUpdate.severity}
</span>
<span className={cn('badge', getStatusColor(selectedUpdate.status))}>
{selectedUpdate.status === 'checking_dependencies' ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
checking dependencies
</span>
) : (
selectedUpdate.status.replace(/_/g, ' ')
)}
</span>
<SeverityBadge severity={selectedUpdate.severity} />
<StatusBadge
status={selectedUpdate.status}
label={
selectedUpdate.status === 'checking_dependencies' ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
checking dependencies
</span>
) : (
selectedUpdate.status.replace(/_/g, ' ')
)
}
/>
{vulns.length > 0 && (
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2.5 py-0.5">
<Shield className="w-3.5 h-3.5" />
@ -885,9 +886,7 @@ const Updates: React.FC = () => {
<span className="text-xs text-gray-500 font-mono flex-shrink-0">
{fa.current_version} <ChevronRight className="inline h-3 w-3 text-gray-400" /> {fa.available_version}
</span>
<span className={cn('badge ml-auto flex-shrink-0', getStatusColor(fa.status))}>
{fa.status.replace(/_/g, ' ')}
</span>
<StatusBadge status={fa.status} className="ml-auto flex-shrink-0" label={fa.status.replace(/_/g, ' ')} />
</button>
</li>
))}
@ -1790,9 +1789,7 @@ const Updates: React.FC = () => {
<span className="text-sm font-mono text-gray-900">{versionLabel}</span>
</td>
<td className="table-cell">
<span className={cn('badge', getSeverityColor(pkg.max_severity))}>
{pkg.max_severity}
</span>
<SeverityBadge severity={pkg.max_severity} />
{pkg.has_vulns && (
<span
title="Known vulnerabilities detected"