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('Stop instance').length).toBeGreaterThan(0)
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 () => {
@@ -174,8 +174,17 @@ describe('App Component - Critical Business Logic Only', () => {
expect(screen.getByText('test-instance-1')).toBeInTheDocument()
})
const deleteButtons = screen.getAllByTitle('Delete instance')
await user.click(deleteButtons[0])
// First click the "More actions" button to reveal the delete button
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
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 { Badge } from "@/components/ui/badge";
import { BackendType, type BackendTypeValue } from "@/types/instance";
import { Cpu, Zap, Server } from "lucide-react";
import { Server } from "lucide-react";
interface BackendBadgeProps {
backend?: BackendTypeValue;
@@ -12,19 +12,6 @@ const BackendBadge: React.FC<BackendBadgeProps> = ({ backend }) => {
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 = () => {
switch (backend) {
case BackendType.LLAMA_CPP:
@@ -38,25 +25,25 @@ const BackendBadge: React.FC<BackendBadgeProps> = ({ backend }) => {
}
};
const getVariant = () => {
const getColorClasses = () => {
switch (backend) {
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:
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:
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:
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 (
<Badge
variant={getVariant()}
className="flex items-center gap-1.5"
variant="outline"
className={`flex items-center gap-1.5 ${getColorClasses()}`}
>
{getIcon()}
<Server className="h-3 w-3" />
<span className="text-xs">{getText()}</span>
</Badge>
);

View File

@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 HealthBadge from "@/components/HealthBadge";
import BackendBadge from "@/components/BackendBadge";
@@ -25,6 +25,7 @@ function InstanceCard({
editInstance,
}: InstanceCardProps) {
const [isLogsOpen, setIsLogsOpen] = useState(false);
const [showAllActions, setShowAllActions] = useState(false);
const health = useInstanceHealth(instance.name, instance.status);
const handleStart = () => {
@@ -55,39 +56,44 @@ function InstanceCard({
return (
<>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{instance.name}</CardTitle>
<div className="flex flex-col items-end gap-2">
{running && <HealthBadge health={health} />}
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-4">
{/* Header with instance name and status badges */}
<div className="space-y-3">
<CardTitle className="text-lg font-semibold leading-tight break-words">
{instance.name}
</CardTitle>
{/* Badges row */}
<div className="flex items-center gap-2 flex-wrap">
<BackendBadge backend={instance.options?.backend_type} />
{running && <HealthBadge health={health} />}
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-1">
<CardContent className="pt-0">
{/* Primary actions - always visible */}
<div className="flex items-center gap-2 mb-3">
<Button
size="sm"
variant="outline"
onClick={handleStart}
disabled={running}
title="Start instance"
data-testid="start-instance-button"
variant={running ? "outline" : "default"}
onClick={running ? handleStop : handleStart}
className="flex-1"
title={running ? "Stop instance" : "Start instance"}
data-testid={running ? "stop-instance-button" : "start-instance-button"}
>
<Play className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={handleStop}
disabled={!running}
title="Stop instance"
data-testid="stop-instance-button"
>
<Square className="h-4 w-4" />
{running ? (
<>
<Square className="h-4 w-4 mr-1" />
Stop
</>
) : (
<>
<Play className="h-4 w-4 mr-1" />
Start
</>
)}
</Button>
<Button
@@ -103,24 +109,40 @@ function InstanceCard({
<Button
size="sm"
variant="outline"
onClick={handleLogs}
title="View logs"
data-testid="view-logs-button"
onClick={() => setShowAllActions(!showAllActions)}
title="More actions"
>
<FileText 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" />
<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
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>
</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')
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')
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')
await user.click(deleteButton)
@@ -170,7 +182,9 @@ afterEach(() => {
})
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(
<InstanceCard
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()
// 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
})
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(
<InstanceCard
instance={stoppedInstance}
@@ -198,11 +219,18 @@ afterEach(() => {
)
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
})
it('edit and logs buttons are always enabled', () => {
it('edit and logs buttons are always enabled', async () => {
const user = userEvent.setup()
render(
<InstanceCard
instance={runningInstance}
@@ -214,6 +242,11 @@ afterEach(() => {
)
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()
})
})
@@ -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
await user.click(screen.getByTitle('View logs'))