mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-06 17:14:28 +00:00
Merge pull request #38 from lordmathis/feat/instance-card
feat: Redesign instance card
This commit is contained in:
@@ -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"?')
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user