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
12 changes: 11 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { useSearchShortcut } from '@/hooks/useSearchShortcut';
import { useAppStore } from '@/lib/store';

type ViewMode = 'entity' | 'faults-dashboard';
type ViewMode = 'entity' | 'faults-dashboard' | 'updates-dashboard';

function App() {
const { isConnected, serverUrl, connect, clearSelection, selectedPath } = useAppStore(
Expand Down Expand Up @@ -46,6 +46,15 @@ function App() {
}
}, [clearSelection]);

// Handle updates dashboard navigation
const handleUpdatesDashboardClick = useCallback(() => {
clearSelection();
setViewMode('updates-dashboard');
if (window.innerWidth < 768) {
setSidebarOpen(false);
}
}, [clearSelection]);

// When entity is selected, switch back to entity view
const handleEntitySelect = useCallback(() => {
setViewMode('entity');
Expand Down Expand Up @@ -109,6 +118,7 @@ function App() {
<EntityTreeSidebar
onSettingsClick={() => setShowConnectionDialog(true)}
onFaultsDashboardClick={handleFaultsDashboardClick}
onUpdatesDashboardClick={handleUpdatesDashboardClick}
/>
</div>

Expand Down
14 changes: 13 additions & 1 deletion src/components/EntityDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { AppsPanel } from '@/components/AppsPanel';
import { FunctionsPanel } from '@/components/FunctionsPanel';
import { ServerInfoPanel } from '@/components/ServerInfoPanel';
import { FaultsDashboard } from '@/components/FaultsDashboard';
import { UpdatesDashboard } from '@/components/UpdatesDashboard';
import { useAppStore, type AppState } from '@/lib/store';
import type { ComponentTopic, Parameter, SovdResourceEntityType } from '@/lib/types';

Expand Down Expand Up @@ -330,7 +331,7 @@ function ParameterDetailCard({ entity, entityId, entityType }: ParameterDetailCa

interface EntityDetailPanelProps {
onConnectClick: () => void;
viewMode?: 'entity' | 'faults-dashboard';
viewMode?: 'entity' | 'faults-dashboard' | 'updates-dashboard';
onEntitySelect?: () => void;
}

Expand Down Expand Up @@ -465,6 +466,17 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
);
}

// Updates Dashboard view
if (viewMode === 'updates-dashboard' && !selectedPath) {
return (
<main className="flex-1 overflow-y-auto p-6 bg-background">
<div className="max-w-4xl mx-auto">
<UpdatesDashboard />
</div>
</main>
);
}

// No selection - show server info
if (!selectedPath) {
return (
Expand Down
46 changes: 32 additions & 14 deletions src/components/EntityTreeSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useMemo } from 'react';
import { useShallow } from 'zustand/shallow';
import { Server, Settings, RefreshCw, Search, X, AlertTriangle, Layers, GitBranch } from 'lucide-react';
import { Server, Settings, RefreshCw, Search, X, AlertTriangle, Layers, GitBranch, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EntityTreeNode } from '@/components/EntityTreeNode';
Expand All @@ -14,6 +14,7 @@ import type { EntityTreeNode as EntityTreeNodeType } from '@/lib/types';
interface EntityTreeSidebarProps {
onSettingsClick: () => void;
onFaultsDashboardClick?: () => void;
Comment thread
bburda marked this conversation as resolved.
onUpdatesDashboardClick?: () => void;
}

/**
Expand Down Expand Up @@ -41,7 +42,11 @@ function filterTree(nodes: EntityTreeNodeType[], query: string): EntityTreeNodeT
return result;
}

export function EntityTreeSidebar({ onSettingsClick, onFaultsDashboardClick }: EntityTreeSidebarProps) {
export function EntityTreeSidebar({
onSettingsClick,
onFaultsDashboardClick,
onUpdatesDashboardClick,
}: EntityTreeSidebarProps) {
const [searchQuery, setSearchQuery] = useState('');
const [isRefreshing, setIsRefreshing] = useState(false);

Expand Down Expand Up @@ -217,19 +222,32 @@ export function EntityTreeSidebar({ onSettingsClick, onFaultsDashboardClick }: E
)}
</div>

{/* Quick Actions - Faults Dashboard */}
{/* Quick Actions */}
{isConnected && (
<div className="p-2 border-t">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
onClick={onFaultsDashboardClick}
>
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span>Faults Dashboard</span>
<FaultsCountBadge />
</Button>
<div className="px-2 py-1.5 border-t flex gap-1">
{onFaultsDashboardClick && (
<Button
variant="ghost"
size="sm"
className="flex-1 justify-center gap-1.5 h-8 text-xs"
onClick={onFaultsDashboardClick}
>
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
Faults Dashboard
<FaultsCountBadge />
</Button>
)}
{onUpdatesDashboardClick && (
<Button
variant="ghost"
size="sm"
className="flex-1 justify-center gap-1.5 h-8 text-xs"
onClick={onUpdatesDashboardClick}
>
<Package className="w-3.5 h-3.5 text-blue-500" />
Software Updates
</Button>
)}
</div>
)}
</aside>
Expand Down
141 changes: 141 additions & 0 deletions src/components/UpdateCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright 2026 bburda
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UpdateCard } from './UpdateCard';
import type { UpdateEntry } from '@/lib/types';

describe('UpdateCard', () => {
it('renders update ID', () => {
const entry: UpdateEntry = {
id: 'update-abc-123',
status: { status: 'pending' },
};

render(<UpdateCard entry={entry} />);

expect(screen.getByText(/update-abc-123/)).toBeInTheDocument();
});

it('shows status unavailable when status is null', () => {
const entry: UpdateEntry = {
id: 'update-failed-status',
status: null,
};

render(<UpdateCard entry={entry} />);

expect(screen.getByText('Status unavailable')).toBeInTheDocument();
});

it('shows pending badge', () => {
const entry: UpdateEntry = {
id: 'update-pending',
status: { status: 'pending' },
};

render(<UpdateCard entry={entry} />);

expect(screen.getByText('pending')).toBeInTheDocument();
});

it('shows inProgress badge with progress bar', () => {
const entry: UpdateEntry = {
id: 'update-inprogress',
status: { status: 'inProgress', progress: 42 },
};

render(<UpdateCard entry={entry} />);

expect(screen.getByText('inProgress')).toBeInTheDocument();
const progressBar = screen.getByRole('progressbar');
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveAttribute('aria-valuenow', '42');
expect(progressBar).toHaveAttribute('aria-valuemin', '0');
expect(progressBar).toHaveAttribute('aria-valuemax', '100');
});

it('shows completed badge', () => {
const entry: UpdateEntry = {
id: 'update-done',
status: { status: 'completed' },
};

render(<UpdateCard entry={entry} />);

expect(screen.getByText('completed')).toBeInTheDocument();
});

it('shows failed badge with error message text', () => {
const entry: UpdateEntry = {
id: 'update-failed',
status: { status: 'failed', error: 'Checksum verification failed' },
};

render(<UpdateCard entry={entry} />);

expect(screen.getByText('failed')).toBeInTheDocument();
expect(screen.getByText('Checksum verification failed')).toBeInTheDocument();
});

it('shows sub-progress list when present', () => {
const entry: UpdateEntry = {
id: 'update-sub',
status: {
status: 'inProgress',
progress: 60,
sub_progress: [
{ name: 'Download', progress: 100 },
{ name: 'Verify', progress: 20 },
],
},
};

render(<UpdateCard entry={entry} />);

expect(screen.getByText('Download')).toBeInTheDocument();
expect(screen.getByText('100%')).toBeInTheDocument();
expect(screen.getByText('Verify')).toBeInTheDocument();
expect(screen.getByText('20%')).toBeInTheDocument();
});

it('does not show progress bar when progress is undefined', () => {
const entry: UpdateEntry = {
id: 'update-no-progress',
status: { status: 'pending' },
};

render(<UpdateCard entry={entry} />);

expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});

it('calls onAction with correct id and action when action button clicked', async () => {
const user = userEvent.setup();
const onAction = vi.fn();
const entry: UpdateEntry = {
id: 'update-act',
status: { status: 'pending' },
};

render(<UpdateCard entry={entry} onAction={onAction} />);

const prepareButton = screen.getByRole('button', { name: /prepare/i });
await user.click(prepareButton);

expect(onAction).toHaveBeenCalledWith('update-act', 'prepare');
});
});
Loading
Loading