mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-05 16:44:22 +00:00
Refactor instance status handling on the frontend
This commit is contained in:
@@ -30,9 +30,9 @@ function App() {
|
||||
|
||||
const handleSaveInstance = (name: string, options: CreateInstanceOptions) => {
|
||||
if (editingInstance) {
|
||||
updateInstance(editingInstance.name, options);
|
||||
void updateInstance(editingInstance.name, options);
|
||||
} else {
|
||||
createInstance(name, options);
|
||||
void createInstance(name, options);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ function renderApp() {
|
||||
|
||||
describe('App Component - Critical Business Logic Only', () => {
|
||||
const mockInstances: Instance[] = [
|
||||
{ name: 'test-instance-1', running: false, options: { model: 'model1.gguf' } },
|
||||
{ name: 'test-instance-2', running: true, options: { model: 'model2.gguf' } }
|
||||
{ name: 'test-instance-1', status: 'stopped', options: { model: 'model1.gguf' } },
|
||||
{ name: 'test-instance-2', status: 'running', options: { model: 'model2.gguf' } }
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -81,7 +81,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
||||
const user = userEvent.setup()
|
||||
const newInstance: Instance = {
|
||||
name: 'new-test-instance',
|
||||
running: false,
|
||||
status: 'stopped',
|
||||
options: { model: 'new-model.gguf' }
|
||||
}
|
||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
|
||||
@@ -118,7 +118,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
||||
const user = userEvent.setup()
|
||||
const updatedInstance: Instance = {
|
||||
name: 'test-instance-1',
|
||||
running: false,
|
||||
status: 'stopped',
|
||||
options: { model: 'updated-model.gguf' }
|
||||
}
|
||||
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance)
|
||||
|
||||
@@ -27,6 +27,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
||||
return <XCircle className="h-3 w-3" />;
|
||||
case "unknown":
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
case "failed":
|
||||
return <XCircle className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,6 +42,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
||||
return "destructive";
|
||||
case "unknown":
|
||||
return "secondary";
|
||||
case "failed":
|
||||
return "destructive";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,6 +57,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
||||
return "Error";
|
||||
case "unknown":
|
||||
return "Unknown";
|
||||
case "failed":
|
||||
return "Failed";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ function InstanceCard({
|
||||
editInstance,
|
||||
}: InstanceCardProps) {
|
||||
const [isLogsOpen, setIsLogsOpen] = useState(false);
|
||||
const health = useInstanceHealth(instance.name, instance.running);
|
||||
const health = useInstanceHealth(instance.name, instance.status);
|
||||
|
||||
const handleStart = () => {
|
||||
startInstance(instance.name);
|
||||
@@ -50,13 +50,15 @@ function InstanceCard({
|
||||
setIsLogsOpen(true);
|
||||
};
|
||||
|
||||
const running = instance.status === "running";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">{instance.name}</CardTitle>
|
||||
{instance.running && <HealthBadge health={health} />}
|
||||
{running && <HealthBadge health={health} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -66,7 +68,7 @@ function InstanceCard({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={instance.running}
|
||||
disabled={running}
|
||||
title="Start instance"
|
||||
data-testid="start-instance-button"
|
||||
>
|
||||
@@ -77,7 +79,7 @@ function InstanceCard({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
disabled={!instance.running}
|
||||
disabled={!running}
|
||||
title="Stop instance"
|
||||
data-testid="stop-instance-button"
|
||||
>
|
||||
@@ -108,7 +110,7 @@ function InstanceCard({
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={instance.running}
|
||||
disabled={running}
|
||||
title="Delete instance"
|
||||
data-testid="delete-instance-button"
|
||||
>
|
||||
@@ -122,7 +124,7 @@ function InstanceCard({
|
||||
open={isLogsOpen}
|
||||
onOpenChange={setIsLogsOpen}
|
||||
instanceName={instance.name}
|
||||
isRunning={instance.running}
|
||||
isRunning={running}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,6 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
instance,
|
||||
}) => {
|
||||
const isEditing = !!instance;
|
||||
const isRunning = instance?.running || true; // Assume running if instance exists
|
||||
|
||||
const [instanceName, setInstanceName] = useState("");
|
||||
const [formData, setFormData] = useState<CreateInstanceOptions>({});
|
||||
@@ -114,6 +113,16 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
// Check if auto_restart is enabled
|
||||
const isAutoRestartEnabled = formData.auto_restart === true;
|
||||
|
||||
// Save button label logic
|
||||
let saveButtonLabel = "Create Instance";
|
||||
if (isEditing) {
|
||||
if (instance?.status === "running") {
|
||||
saveButtonLabel = "Update & Restart Instance";
|
||||
} else {
|
||||
saveButtonLabel = "Update Instance";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col">
|
||||
@@ -264,11 +273,7 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
disabled={!instanceName.trim() || !!nameError}
|
||||
data-testid="dialog-save-button"
|
||||
>
|
||||
{isEditing
|
||||
? isRunning
|
||||
? "Update & Restart Instance"
|
||||
: "Update Instance"
|
||||
: "Create Instance"}
|
||||
{saveButtonLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -17,13 +17,13 @@ describe('InstanceCard - Instance Actions and State', () => {
|
||||
|
||||
const stoppedInstance: Instance = {
|
||||
name: 'test-instance',
|
||||
running: false,
|
||||
status: 'stopped',
|
||||
options: { model: 'test-model.gguf' }
|
||||
}
|
||||
|
||||
const runningInstance: Instance = {
|
||||
name: 'running-instance',
|
||||
running: true,
|
||||
status: 'running',
|
||||
options: { model: 'running-model.gguf' }
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ afterEach(() => {
|
||||
it('handles instance with minimal data', () => {
|
||||
const minimalInstance: Instance = {
|
||||
name: 'minimal',
|
||||
running: false,
|
||||
status: 'stopped',
|
||||
options: {}
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ afterEach(() => {
|
||||
it('handles instance with undefined options', () => {
|
||||
const instanceWithoutOptions: Instance = {
|
||||
name: 'no-options',
|
||||
running: true,
|
||||
status: 'running',
|
||||
options: undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('InstanceList - State Management and UI Logic', () => {
|
||||
const mockEditInstance = vi.fn()
|
||||
|
||||
const mockInstances: Instance[] = [
|
||||
{ name: 'instance-1', running: false, options: { model: 'model1.gguf' } },
|
||||
{ name: 'instance-2', running: true, options: { model: 'model2.gguf' } },
|
||||
{ name: 'instance-3', running: false, options: { model: 'model3.gguf' } }
|
||||
{ name: 'instance-1', status: 'stopped', options: { model: 'model1.gguf' } },
|
||||
{ name: 'instance-2', status: 'running', options: { model: 'model2.gguf' } },
|
||||
{ name: 'instance-3', status: 'stopped', options: { model: 'model3.gguf' } }
|
||||
]
|
||||
|
||||
const DUMMY_API_KEY = 'test-api-key-123'
|
||||
|
||||
@@ -134,7 +134,7 @@ afterEach(() => {
|
||||
describe('Edit Mode', () => {
|
||||
const mockInstance: Instance = {
|
||||
name: 'existing-instance',
|
||||
running: false,
|
||||
status: 'stopped',
|
||||
options: {
|
||||
model: 'test-model.gguf',
|
||||
gpu_layers: 10,
|
||||
@@ -184,8 +184,8 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
it('shows correct button text for running vs stopped instances', () => {
|
||||
const runningInstance: Instance = { ...mockInstance, running: true }
|
||||
|
||||
const runningInstance: Instance = { ...mockInstance, status: 'running' }
|
||||
|
||||
const { rerender } = render(
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
|
||||
@@ -112,9 +112,9 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
try {
|
||||
setError(null)
|
||||
await instancesApi.start(name)
|
||||
|
||||
// Update only this instance's running status
|
||||
updateInstanceInMap(name, { running: true })
|
||||
|
||||
// Update only this instance's status
|
||||
updateInstanceInMap(name, { status: "running" })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start instance')
|
||||
}
|
||||
@@ -124,9 +124,9 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
try {
|
||||
setError(null)
|
||||
await instancesApi.stop(name)
|
||||
|
||||
// Update only this instance's running status
|
||||
updateInstanceInMap(name, { running: false })
|
||||
|
||||
// Update only this instance's status
|
||||
updateInstanceInMap(name, { status: "stopped" })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to stop instance')
|
||||
}
|
||||
@@ -136,9 +136,9 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
try {
|
||||
setError(null)
|
||||
await instancesApi.restart(name)
|
||||
|
||||
// Update only this instance's running status
|
||||
updateInstanceInMap(name, { running: true })
|
||||
|
||||
// Update only this instance's status
|
||||
updateInstanceInMap(name, { status: "running" })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to restart instance')
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function TestComponent() {
|
||||
<div data-testid="instances-count">{instances.length}</div>
|
||||
{instances.map((instance) => (
|
||||
<div key={instance.name} data-testid={`instance-${instance.name}`}>
|
||||
{instance.name}:{instance.running.toString()}
|
||||
{instance.name}:{instance.status}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -99,8 +99,8 @@ function renderWithProvider(children: ReactNode) {
|
||||
|
||||
describe("InstancesContext", () => {
|
||||
const mockInstances: Instance[] = [
|
||||
{ name: "instance1", running: true, options: { model: "model1.gguf" } },
|
||||
{ name: "instance2", running: false, options: { model: "model2.gguf" } },
|
||||
{ name: "instance1", status: "running", options: { model: "model1.gguf" } },
|
||||
{ name: "instance2", status: "stopped", options: { model: "model2.gguf" } },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -132,10 +132,10 @@ describe("InstancesContext", () => {
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
||||
"instance1:true"
|
||||
"instance1:running"
|
||||
);
|
||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||
"instance2:false"
|
||||
"instance2:stopped"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -158,7 +158,7 @@ describe("InstancesContext", () => {
|
||||
it("creates instance and adds it to state", async () => {
|
||||
const newInstance: Instance = {
|
||||
name: "new-instance",
|
||||
running: false,
|
||||
status: "stopped",
|
||||
options: { model: "test.gguf" },
|
||||
};
|
||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
|
||||
@@ -181,7 +181,7 @@ describe("InstancesContext", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("3");
|
||||
expect(screen.getByTestId("instance-new-instance")).toHaveTextContent(
|
||||
"new-instance:false"
|
||||
"new-instance:stopped"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -214,7 +214,7 @@ describe("InstancesContext", () => {
|
||||
it("updates instance and maintains it in state", async () => {
|
||||
const updatedInstance: Instance = {
|
||||
name: "instance1",
|
||||
running: true,
|
||||
status: "running",
|
||||
options: { model: "updated.gguf" },
|
||||
};
|
||||
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance);
|
||||
@@ -251,7 +251,7 @@ describe("InstancesContext", () => {
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
// instance2 starts as not running
|
||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||
"instance2:false"
|
||||
"instance2:stopped"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -262,7 +262,7 @@ describe("InstancesContext", () => {
|
||||
expect(instancesApi.start).toHaveBeenCalledWith("instance2");
|
||||
// The running state should be updated to true
|
||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||
"instance2:true"
|
||||
"instance2:running"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -276,7 +276,7 @@ describe("InstancesContext", () => {
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
// instance1 starts as running
|
||||
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
||||
"instance1:true"
|
||||
"instance1:running"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -287,7 +287,7 @@ describe("InstancesContext", () => {
|
||||
expect(instancesApi.stop).toHaveBeenCalledWith("instance1");
|
||||
// The running state should be updated to false
|
||||
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
||||
"instance1:false"
|
||||
"instance1:stopped"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -383,7 +383,7 @@ describe("InstancesContext", () => {
|
||||
// Test that operations don't interfere with each other
|
||||
const newInstance: Instance = {
|
||||
name: "new-instance",
|
||||
running: false,
|
||||
status: "stopped",
|
||||
options: {},
|
||||
};
|
||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
|
||||
@@ -411,7 +411,7 @@ describe("InstancesContext", () => {
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("3"); // Still 3
|
||||
// But the running state should change
|
||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||
"instance2:true"
|
||||
"instance2:running"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// ui/src/hooks/useInstanceHealth.ts
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { HealthStatus } from '@/types/instance'
|
||||
import type { HealthStatus, InstanceStatus } from '@/types/instance'
|
||||
import { healthService } from '@/lib/healthService'
|
||||
|
||||
export function useInstanceHealth(instanceName: string, isRunning: boolean): HealthStatus | undefined {
|
||||
export function useInstanceHealth(instanceName: string, instanceStatus: InstanceStatus): HealthStatus | undefined {
|
||||
const [health, setHealth] = useState<HealthStatus | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRunning) {
|
||||
setHealth(undefined)
|
||||
if (instanceStatus == "stopped") {
|
||||
setHealth({ status: "unknown", lastChecked: new Date() })
|
||||
return
|
||||
}
|
||||
|
||||
if (instanceStatus == "failed") {
|
||||
setHealth({ status: instanceStatus, lastChecked: new Date() })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,9 +22,9 @@ export function useInstanceHealth(instanceName: string, isRunning: boolean): Hea
|
||||
setHealth(healthStatus)
|
||||
})
|
||||
|
||||
// Cleanup subscription on unmount or when running changes
|
||||
// Cleanup subscription on unmount or when instanceStatus changes
|
||||
return unsubscribe
|
||||
}, [instanceName, isRunning])
|
||||
}, [instanceName, instanceStatus])
|
||||
|
||||
return health
|
||||
}
|
||||
@@ -2,14 +2,16 @@ import type { CreateInstanceOptions } from '@/schemas/instanceOptions'
|
||||
|
||||
export { type CreateInstanceOptions } from '@/schemas/instanceOptions'
|
||||
|
||||
export type InstanceStatus = 'running' | 'stopped' | 'failed'
|
||||
|
||||
export interface HealthStatus {
|
||||
status: 'ok' | 'loading' | 'error' | 'unknown'
|
||||
status: 'ok' | 'loading' | 'error' | 'unknown' | 'failed'
|
||||
message?: string
|
||||
lastChecked: Date
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
name: string;
|
||||
running: boolean;
|
||||
status: InstanceStatus;
|
||||
options?: CreateInstanceOptions;
|
||||
}
|
||||
Reference in New Issue
Block a user