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
@@ -100,14 +106,29 @@ function InstanceCard({
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button
size="sm"
variant="outline"
onClick={() => setShowAllActions(!showAllActions)}
title="More actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
{/* Secondary actions - collapsible */}
{showAllActions && (
<div className="flex items-center gap-2 pt-2 border-t border-border">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={handleLogs} onClick={handleLogs}
title="View logs" title="View logs"
data-testid="view-logs-button" data-testid="view-logs-button"
className="flex-1"
> >
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4 mr-1" />
Logs
</Button> </Button>
<Button <Button
@@ -121,6 +142,7 @@ function InstanceCard({
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -113,6 +113,10 @@ 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)
@@ -136,6 +140,10 @@ 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)
@@ -159,6 +167,10 @@ 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)
@@ -170,7 +182,9 @@ afterEach(() => {
}) })
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()
}) })
}) })
@@ -279,6 +312,10 @@ 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'))