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
49 changes: 33 additions & 16 deletions src/components/FaultsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ function getSeverityBadgeVariant(severity: FaultSeverity): 'default' | 'secondar
}
}

/**
* Get badge variant for fault status
*/
function getStatusBadgeVariant(status: FaultStatus): 'default' | 'secondary' | 'outline' {
switch (status) {
case 'active':
return 'default';
case 'pending':
return 'secondary';
default:
return 'outline';
}
}

/**
* Get icon for fault severity
*/
Expand Down Expand Up @@ -128,7 +142,9 @@ function FaultRow({
<Collapsible open={isExpanded} onOpenChange={onToggle}>
<div className="rounded-lg border bg-card">
<CollapsibleTrigger asChild>
<div className="flex items-start gap-3 p-3 cursor-pointer hover:bg-muted/50 transition-colors">
<div
className={`flex items-start gap-3 p-3 cursor-pointer hover:bg-muted/50 transition-colors ${fault.status === 'healed' ? 'opacity-60' : ''}`}
>
{/* Expand/Collapse Icon */}
<div className="shrink-0 mt-0.5">
{isExpanded ? (
Expand All @@ -151,14 +167,8 @@ function FaultRow({
{fault.severity}
</Badge>
<Badge
variant={
fault.status === 'active'
? 'default'
: fault.status === 'pending'
? 'secondary'
: 'outline'
}
className="text-xs"
variant={getStatusBadgeVariant(fault.status)}
className={`text-xs ${fault.status === 'healed' ? 'text-green-600 border-green-300 dark:text-green-400 dark:border-green-700' : ''}`}
>
{fault.status}
</Badge>
Expand Down Expand Up @@ -528,16 +538,17 @@ export function FaultsDashboard() {
});
}, [filteredFaults]);

// Count by severity
// Count by severity (active faults only — CONFIRMED + PREFAILED)
const activeFaults = useMemo(() => faults.filter((f) => f.status === 'active' || f.status === 'pending'), [faults]);
const counts = useMemo(() => {
return {
critical: faults.filter((f) => f.severity === 'critical').length,
error: faults.filter((f) => f.severity === 'error').length,
warning: faults.filter((f) => f.severity === 'warning').length,
info: faults.filter((f) => f.severity === 'info').length,
total: faults.length,
critical: activeFaults.filter((f) => f.severity === 'critical').length,
error: activeFaults.filter((f) => f.severity === 'error').length,
warning: activeFaults.filter((f) => f.severity === 'warning').length,
info: activeFaults.filter((f) => f.severity === 'info').length,
total: activeFaults.length,
};
}, [faults]);
}, [activeFaults]);

// Toggle severity filter
const toggleSeverity = (severity: FaultSeverity) => {
Expand Down Expand Up @@ -745,6 +756,12 @@ export function FaultsDashboard() {
>
Cleared
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={statusFilters.has('healed')}
onCheckedChange={() => toggleStatus('healed')}
>
Healed
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>

Expand Down
9 changes: 7 additions & 2 deletions src/components/FaultsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ function FaultRow({
<Collapsible open={isExpanded} onOpenChange={onToggle}>
<div className="rounded-lg border bg-card">
<CollapsibleTrigger asChild>
<div className="flex items-start gap-3 p-3 cursor-pointer hover:bg-muted/50 transition-colors">
<div
className={`flex items-start gap-3 p-3 cursor-pointer hover:bg-muted/50 transition-colors ${fault.status === 'healed' ? 'opacity-60' : ''}`}
>
{/* Expand/Collapse Icon */}
<div className="shrink-0 mt-0.5">
{isExpanded ? (
Expand Down Expand Up @@ -146,7 +148,10 @@ function FaultRow({
<Badge variant={getSeverityBadgeVariant(fault.severity)} className="text-xs">
{fault.severity}
</Badge>
<Badge variant={getStatusBadgeVariant(fault.status)} className="text-xs">
<Badge
variant={getStatusBadgeVariant(fault.status)}
className={`text-xs ${fault.status === 'healed' ? 'text-green-600 border-green-300 dark:text-green-400 dark:border-green-700' : ''}`}
>
{fault.status}
</Badge>
</div>
Expand Down
81 changes: 81 additions & 0 deletions src/lib/sovd-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,87 @@ describe('SovdApiClient', () => {
});
});

describe('listAllFaults', () => {
const makeFaultItem = (overrides: Record<string, unknown> = {}) => ({
fault_code: 'TEST_FAULT',
description: 'A test fault',
severity: 2,
severity_label: 'error',
status: 'CONFIRMED',
first_occurred: 1700000000,
reporting_sources: ['/test/node'],
...overrides,
});

it('passes status query parameter when provided', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [] }),
} as Response);

await client.listAllFaults('all');

expect(fetch).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/faults?status=all',
expect.objectContaining({ method: 'GET' })
);
});

it('omits status parameter when not provided', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [] }),
} as Response);

await client.listAllFaults();

expect(fetch).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/faults',
expect.objectContaining({ method: 'GET' })
);
});

it('maps HEALED API status to healed', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [makeFaultItem({ status: 'HEALED' })] }),
} as Response);

const result = await client.listAllFaults('all');
expect(result.items[0]?.status).toBe('healed');
});

it('maps PREPASSED API status to healed', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [makeFaultItem({ status: 'PREPASSED' })] }),
} as Response);

const result = await client.listAllFaults('all');
expect(result.items[0]?.status).toBe('healed');
});

it('maps CONFIRMED API status to active', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [makeFaultItem({ status: 'CONFIRMED' })] }),
} as Response);

const result = await client.listAllFaults();
expect(result.items[0]?.status).toBe('active');
});

it('maps CLEARED API status to cleared', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [makeFaultItem({ status: 'CLEARED' })] }),
} as Response);

const result = await client.listAllFaults();
expect(result.items[0]?.status).toBe('cleared');
});
});

describe('listBulkDataCategories', () => {
it('returns categories array', async () => {
vi.mocked(fetch).mockResolvedValue({
Expand Down
9 changes: 7 additions & 2 deletions src/lib/sovd-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,8 @@ export class SovdApiClient {
status = 'pending';
} else if (apiStatus === 'cleared' || apiStatus === 'resolved') {
status = 'cleared';
} else if (apiStatus === 'healed' || apiStatus === 'prepassed') {
status = 'healed';
}

// Extract entity info from reporting_sources
Expand Down Expand Up @@ -1591,9 +1593,12 @@ export class SovdApiClient {

/**
* List all faults across the system
* @param status Optional status filter (e.g. 'all' to include healed faults)
*/
async listAllFaults(): Promise<ListFaultsResponse> {
const response = await fetchWithTimeout(this.getUrl('faults'), {
async listAllFaults(status?: FaultStatus | 'all'): Promise<ListFaultsResponse> {
const baseUrl = this.getUrl('faults');
const url = status ? `${baseUrl}?status=${encodeURIComponent(status)}` : baseUrl;
const response = await fetchWithTimeout(url, {
method: 'GET',
headers: { Accept: 'application/json' },
});
Expand Down
2 changes: 1 addition & 1 deletion src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1367,7 +1367,7 @@ export const useAppStore = create<AppState>()(
}

try {
const result = await client.listAllFaults();
const result = await client.listAllFaults('all');
// Skip state update if faults haven't changed to avoid unnecessary re-renders.
// Compare by serializing fault codes + statuses (cheap and covers all meaningful changes).
const newKey = result.items.map((f) => `${f.code}:${f.status}:${f.severity}`).join('|');
Expand Down
2 changes: 1 addition & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ export type FaultSeverity = 'info' | 'warning' | 'error' | 'critical';
/**
* Fault status values (legacy)
*/
export type FaultStatusValue = 'active' | 'pending' | 'cleared';
export type FaultStatusValue = 'active' | 'pending' | 'cleared' | 'healed';

/**
* Alias for backwards compatibility
Expand Down