Merge pull request #38 from lordmathis/feat/instance-card

feat: Redesign instance card
This commit is contained in:
2025-09-23 19:48:20 +02:00
committed by GitHub
4 changed files with 139 additions and 84 deletions

View File

@@ -160,7 +160,7 @@ describe('App Component - Critical Business Logic Only', () => {
expect(screen.getAllByTitle('Start instance').length).toBeGreaterThan(0) expect(screen.getAllByTitle('Start instance').length).toBeGreaterThan(0)
expect(screen.getAllByTitle('Stop instance').length).toBeGreaterThan(0) expect(screen.getAllByTitle('Stop instance').length).toBeGreaterThan(0)
expect(screen.getAllByTitle('Edit instance').length).toBe(2) expect(screen.getAllByTitle('Edit instance').length).toBe(2)
expect(screen.getAllByTitle('Delete instance').length).toBeGreaterThan(0) expect(screen.getAllByTitle('More actions').length).toBe(2)
}) })
it('delete confirmation calls correct API', async () => { it('delete confirmation calls correct API', async () => {
@@ -174,8 +174,17 @@ describe('App Component - Critical Business Logic Only', () => {
expect(screen.getByText('test-instance-1')).toBeInTheDocument() expect(screen.getByText('test-instance-1')).toBeInTheDocument()
}) })
const deleteButtons = screen.getAllByTitle('Delete instance') // First click the "More actions" button to reveal the delete button
await user.click(deleteButtons[0]) const moreActionsButtons = screen.getAllByTitle('More actions')
await user.click(moreActionsButtons[0])
// Wait for the delete button to appear and click it
await waitFor(() => {
expect(screen.getByTitle('Delete instance')).toBeInTheDocument()
})
const deleteButton = screen.getByTitle('Delete instance')
await user.click(deleteButton)
// Verify confirmation and API call // Verify confirmation and API call
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete instance "test-instance-1"?') expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete instance "test-instance-1"?')

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { BackendType, type BackendTypeValue } from "@/types/instance"; import { BackendType, type BackendTypeValue } from "@/types/instance";
import { Cpu, Zap, Server } from "lucide-react"; import { Server } from "lucide-react";
interface BackendBadgeProps { interface BackendBadgeProps {
backend?: BackendTypeValue; backend?: BackendTypeValue;
@@ -12,19 +12,6 @@ const BackendBadge: React.FC<BackendBadgeProps> = ({ backend }) => {
return null; return null;
} }
const getIcon = () => {
switch (backend) {
case BackendType.LLAMA_CPP:
return <Cpu className="h-3 w-3" />;
case BackendType.MLX_LM:
return <Zap className="h-3 w-3" />;
case BackendType.VLLM:
return <Server className="h-3 w-3" />;
default:
return <Server className="h-3 w-3" />;
}
};
const getText = () => { const getText = () => {
switch (backend) { switch (backend) {
case BackendType.LLAMA_CPP: case BackendType.LLAMA_CPP:
@@ -38,25 +25,25 @@ const BackendBadge: React.FC<BackendBadgeProps> = ({ backend }) => {
} }
}; };
const getVariant = () => { const getColorClasses = () => {
switch (backend) { switch (backend) {
case BackendType.LLAMA_CPP: case BackendType.LLAMA_CPP:
return "secondary"; return "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900 dark:text-blue-200 dark:border-blue-800";
case BackendType.MLX_LM: case BackendType.MLX_LM:
return "outline"; return "bg-green-100 text-green-800 border-green-200 dark:bg-green-900 dark:text-green-200 dark:border-green-800";
case BackendType.VLLM: case BackendType.VLLM:
return "default"; return "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900 dark:text-purple-200 dark:border-purple-800";
default: default:
return "secondary"; return "bg-gray-100 text-gray-800 border-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:border-gray-800";
} }
}; };
return ( return (
<Badge <Badge
variant={getVariant()} variant="outline"
className="flex items-center gap-1.5" className={`flex items-center gap-1.5 ${getColorClasses()}`}
> >
{getIcon()} <Server className="h-3 w-3" />
<span className="text-xs">{getText()}</span> <span className="text-xs">{getText()}</span>
</Badge> </Badge>
); );

View File

@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Instance } from "@/types/instance"; import type { Instance } from "@/types/instance";
import { Edit, FileText, Play, Square, Trash2 } from "lucide-react"; import { Edit, FileText, Play, Square, Trash2, MoreHorizontal } from "lucide-react";
import LogsDialog from "@/components/LogDialog"; import LogsDialog from "@/components/LogDialog";
import HealthBadge from "@/components/HealthBadge"; import HealthBadge from "@/components/HealthBadge";
import BackendBadge from "@/components/BackendBadge"; import BackendBadge from "@/components/BackendBadge";
@@ -25,6 +25,7 @@ function InstanceCard({
editInstance, editInstance,
}: InstanceCardProps) { }: InstanceCardProps) {
const [isLogsOpen, setIsLogsOpen] = useState(false); const [isLogsOpen, setIsLogsOpen] = useState(false);
const [showAllActions, setShowAllActions] = useState(false);
const health = useInstanceHealth(instance.name, instance.status); const health = useInstanceHealth(instance.name, instance.status);
const handleStart = () => { const handleStart = () => {
@@ -55,39 +56,44 @@ function InstanceCard({
return ( return (
<> <>
<Card> <Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3"> <CardHeader className="pb-4">
<div className="flex items-center justify-between"> {/* Header with instance name and status badges */}
<CardTitle className="text-lg">{instance.name}</CardTitle> <div className="space-y-3">
<div className="flex flex-col items-end gap-2"> <CardTitle className="text-lg font-semibold leading-tight break-words">
{running && <HealthBadge health={health} />} {instance.name}
</CardTitle>
{/* Badges row */}
<div className="flex items-center gap-2 flex-wrap">
<BackendBadge backend={instance.options?.backend_type} /> <BackendBadge backend={instance.options?.backend_type} />
{running && <HealthBadge health={health} />}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="pt-0">
<div className="flex gap-1"> {/* Primary actions - always visible */}
<div className="flex items-center gap-2 mb-3">
<Button <Button
size="sm" size="sm"
variant="outline" variant={running ? "outline" : "default"}
onClick={handleStart} onClick={running ? handleStop : handleStart}
disabled={running} className="flex-1"
title="Start instance" title={running ? "Stop instance" : "Start instance"}
data-testid="start-instance-button" data-testid={running ? "stop-instance-button" : "start-instance-button"}
> >
<Play className="h-4 w-4" /> {running ? (
</Button> <>
<Square className="h-4 w-4 mr-1" />
<Button Stop
size="sm" </>
variant="outline" ) : (
onClick={handleStop} <>
disabled={!running} <Play className="h-4 w-4 mr-1" />
title="Stop instance" Start
data-testid="stop-instance-button" </>
> )}
<Square className="h-4 w-4" />
</Button> </Button>
<Button <Button
@@ -103,24 +109,40 @@ function InstanceCard({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={handleLogs} onClick={() => setShowAllActions(!showAllActions)}
title="View logs" title="More actions"
data-testid="view-logs-button"
> >
<FileText className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleDelete}
disabled={running}
title="Delete instance"
data-testid="delete-instance-button"
>
<Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* Secondary actions - collapsible */}
{showAllActions && (
<div className="flex items-center gap-2 pt-2 border-t border-border">
<Button
size="sm"
variant="outline"
onClick={handleLogs}
title="View logs"
data-testid="view-logs-button"
className="flex-1"
>
<FileText className="h-4 w-4 mr-1" />
Logs
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleDelete}
disabled={running}
title="Delete instance"
data-testid="delete-instance-button"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -102,7 +102,7 @@ afterEach(() => {
it('opens logs dialog when logs button clicked', async () => { it('opens logs dialog when logs button clicked', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceCard <InstanceCard
instance={stoppedInstance} instance={stoppedInstance}
@@ -113,9 +113,13 @@ afterEach(() => {
/> />
) )
// First click "More actions" to reveal the logs button
const moreActionsButton = screen.getByTitle('More actions')
await user.click(moreActionsButton)
const logsButton = screen.getByTitle('View logs') const logsButton = screen.getByTitle('View logs')
await user.click(logsButton) await user.click(logsButton)
// Should open logs dialog (we can verify this by checking if dialog title appears) // Should open logs dialog (we can verify this by checking if dialog title appears)
expect(screen.getByText(`Logs: ${stoppedInstance.name}`)).toBeInTheDocument() expect(screen.getByText(`Logs: ${stoppedInstance.name}`)).toBeInTheDocument()
}) })
@@ -125,7 +129,7 @@ afterEach(() => {
it('shows confirmation dialog and calls deleteInstance when confirmed', async () => { it('shows confirmation dialog and calls deleteInstance when confirmed', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
render( render(
<InstanceCard <InstanceCard
instance={stoppedInstance} instance={stoppedInstance}
@@ -136,19 +140,23 @@ afterEach(() => {
/> />
) )
// First click "More actions" to reveal the delete button
const moreActionsButton = screen.getByTitle('More actions')
await user.click(moreActionsButton)
const deleteButton = screen.getByTitle('Delete instance') const deleteButton = screen.getByTitle('Delete instance')
await user.click(deleteButton) await user.click(deleteButton)
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete instance "test-instance"?') expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete instance "test-instance"?')
expect(mockDeleteInstance).toHaveBeenCalledWith('test-instance') expect(mockDeleteInstance).toHaveBeenCalledWith('test-instance')
confirmSpy.mockRestore() confirmSpy.mockRestore()
}) })
it('does not call deleteInstance when confirmation cancelled', async () => { it('does not call deleteInstance when confirmation cancelled', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
render( render(
<InstanceCard <InstanceCard
instance={stoppedInstance} instance={stoppedInstance}
@@ -159,18 +167,24 @@ afterEach(() => {
/> />
) )
// First click "More actions" to reveal the delete button
const moreActionsButton = screen.getByTitle('More actions')
await user.click(moreActionsButton)
const deleteButton = screen.getByTitle('Delete instance') const deleteButton = screen.getByTitle('Delete instance')
await user.click(deleteButton) await user.click(deleteButton)
expect(confirmSpy).toHaveBeenCalled() expect(confirmSpy).toHaveBeenCalled()
expect(mockDeleteInstance).not.toHaveBeenCalled() expect(mockDeleteInstance).not.toHaveBeenCalled()
confirmSpy.mockRestore() confirmSpy.mockRestore()
}) })
}) })
describe('Button State Based on Instance Status', () => { describe('Button State Based on Instance Status', () => {
it('disables start button and enables stop button for running instance', () => { it('disables start button and enables stop button for running instance', async () => {
const user = userEvent.setup()
render( render(
<InstanceCard <InstanceCard
instance={runningInstance} instance={runningInstance}
@@ -181,12 +195,19 @@ afterEach(() => {
/> />
) )
expect(screen.getByTitle('Start instance')).toBeDisabled() expect(screen.queryByTitle('Start instance')).not.toBeInTheDocument()
expect(screen.getByTitle('Stop instance')).not.toBeDisabled() expect(screen.getByTitle('Stop instance')).not.toBeDisabled()
// Expand more actions to access delete button
const moreActionsButton = screen.getByTitle('More actions')
await user.click(moreActionsButton)
expect(screen.getByTitle('Delete instance')).toBeDisabled() // Can't delete running instance expect(screen.getByTitle('Delete instance')).toBeDisabled() // Can't delete running instance
}) })
it('enables start button and disables stop button for stopped instance', () => { it('enables start button and disables stop button for stopped instance', async () => {
const user = userEvent.setup()
render( render(
<InstanceCard <InstanceCard
instance={stoppedInstance} instance={stoppedInstance}
@@ -198,11 +219,18 @@ afterEach(() => {
) )
expect(screen.getByTitle('Start instance')).not.toBeDisabled() expect(screen.getByTitle('Start instance')).not.toBeDisabled()
expect(screen.getByTitle('Stop instance')).toBeDisabled() expect(screen.queryByTitle('Stop instance')).not.toBeInTheDocument()
// Expand more actions to access delete button
const moreActionsButton = screen.getByTitle('More actions')
await user.click(moreActionsButton)
expect(screen.getByTitle('Delete instance')).not.toBeDisabled() // Can delete stopped instance expect(screen.getByTitle('Delete instance')).not.toBeDisabled() // Can delete stopped instance
}) })
it('edit and logs buttons are always enabled', () => { it('edit and logs buttons are always enabled', async () => {
const user = userEvent.setup()
render( render(
<InstanceCard <InstanceCard
instance={runningInstance} instance={runningInstance}
@@ -214,6 +242,11 @@ afterEach(() => {
) )
expect(screen.getByTitle('Edit instance')).not.toBeDisabled() expect(screen.getByTitle('Edit instance')).not.toBeDisabled()
// Expand more actions to access logs button
const moreActionsButton = screen.getByTitle('More actions')
await user.click(moreActionsButton)
expect(screen.getByTitle('View logs')).not.toBeDisabled() expect(screen.getByTitle('View logs')).not.toBeDisabled()
}) })
}) })
@@ -268,7 +301,7 @@ afterEach(() => {
describe('Integration with LogsModal', () => { describe('Integration with LogsModal', () => {
it('passes correct props to LogsModal', async () => { it('passes correct props to LogsModal', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceCard <InstanceCard
instance={runningInstance} instance={runningInstance}
@@ -279,20 +312,24 @@ afterEach(() => {
/> />
) )
// First click "More actions" to reveal the logs button
const moreActionsButton = screen.getByTitle('More actions')
await user.click(moreActionsButton)
// Open logs dialog // Open logs dialog
await user.click(screen.getByTitle('View logs')) await user.click(screen.getByTitle('View logs'))
// Verify dialog opened with correct instance data // Verify dialog opened with correct instance data
expect(screen.getByText('Logs: running-instance')).toBeInTheDocument() expect(screen.getByText('Logs: running-instance')).toBeInTheDocument()
// Close dialog to test close functionality // Close dialog to test close functionality
const closeButtons = screen.getAllByText('Close') const closeButtons = screen.getAllByText('Close')
const dialogCloseButton = closeButtons.find(button => const dialogCloseButton = closeButtons.find(button =>
button.closest('[data-slot="dialog-content"]') button.closest('[data-slot="dialog-content"]')
) )
expect(dialogCloseButton).toBeTruthy() expect(dialogCloseButton).toBeTruthy()
await user.click(dialogCloseButton!) await user.click(dialogCloseButton!)
// Modal should close // Modal should close
expect(screen.queryByText('Logs: running-instance')).not.toBeInTheDocument() expect(screen.queryByText('Logs: running-instance')).not.toBeInTheDocument()
}) })