diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 44a155b..8704e6b 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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); } }; diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx index 04c1e34..57f00a0 100644 --- a/webui/src/__tests__/App.test.tsx +++ b/webui/src/__tests__/App.test.tsx @@ -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) diff --git a/webui/src/components/HealthBadge.tsx b/webui/src/components/HealthBadge.tsx index 4bc0cfb..45c1960 100644 --- a/webui/src/components/HealthBadge.tsx +++ b/webui/src/components/HealthBadge.tsx @@ -27,6 +27,8 @@ const HealthBadge: React.FC = ({ health }) => { return ; case "unknown": return ; + case "failed": + return ; } }; @@ -40,6 +42,8 @@ const HealthBadge: React.FC = ({ health }) => { return "destructive"; case "unknown": return "secondary"; + case "failed": + return "destructive"; } }; @@ -53,6 +57,8 @@ const HealthBadge: React.FC = ({ health }) => { return "Error"; case "unknown": return "Unknown"; + case "failed": + return "Failed"; } }; diff --git a/webui/src/components/InstanceCard.tsx b/webui/src/components/InstanceCard.tsx index 5ecfcb2..b17714c 100644 --- a/webui/src/components/InstanceCard.tsx +++ b/webui/src/components/InstanceCard.tsx @@ -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 ( <>
{instance.name} - {instance.running && } + {running && }
@@ -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} /> ); diff --git a/webui/src/components/InstanceDialog.tsx b/webui/src/components/InstanceDialog.tsx index 9542a8c..56792e6 100644 --- a/webui/src/components/InstanceDialog.tsx +++ b/webui/src/components/InstanceDialog.tsx @@ -29,7 +29,6 @@ const InstanceDialog: React.FC = ({ instance, }) => { const isEditing = !!instance; - const isRunning = instance?.running || true; // Assume running if instance exists const [instanceName, setInstanceName] = useState(""); const [formData, setFormData] = useState({}); @@ -114,6 +113,16 @@ const InstanceDialog: React.FC = ({ // 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 ( @@ -264,11 +273,7 @@ const InstanceDialog: React.FC = ({ disabled={!instanceName.trim() || !!nameError} data-testid="dialog-save-button" > - {isEditing - ? isRunning - ? "Update & Restart Instance" - : "Update Instance" - : "Create Instance"} + {saveButtonLabel} diff --git a/webui/src/components/__tests__/InstanceCard.test.tsx b/webui/src/components/__tests__/InstanceCard.test.tsx index 4429e4f..5daebe4 100644 --- a/webui/src/components/__tests__/InstanceCard.test.tsx +++ b/webui/src/components/__tests__/InstanceCard.test.tsx @@ -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 } diff --git a/webui/src/components/__tests__/InstanceList.test.tsx b/webui/src/components/__tests__/InstanceList.test.tsx index b237987..a38f873 100644 --- a/webui/src/components/__tests__/InstanceList.test.tsx +++ b/webui/src/components/__tests__/InstanceList.test.tsx @@ -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' diff --git a/webui/src/components/__tests__/InstanceModal.test.tsx b/webui/src/components/__tests__/InstanceModal.test.tsx index 926d214..8468379 100644 --- a/webui/src/components/__tests__/InstanceModal.test.tsx +++ b/webui/src/components/__tests__/InstanceModal.test.tsx @@ -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( { 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') } diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx index aa4e8e3..c271730 100644 --- a/webui/src/contexts/__tests__/InstancesContext.test.tsx +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -41,7 +41,7 @@ function TestComponent() {
{instances.length}
{instances.map((instance) => (
- {instance.name}:{instance.running.toString()} + {instance.name}:{instance.status}
))} @@ -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" ); }); }); diff --git a/webui/src/hooks/useInstanceHealth.ts b/webui/src/hooks/useInstanceHealth.ts index ceb5271..2741ea4 100644 --- a/webui/src/hooks/useInstanceHealth.ts +++ b/webui/src/hooks/useInstanceHealth.ts @@ -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() 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 } \ No newline at end of file diff --git a/webui/src/types/instance.ts b/webui/src/types/instance.ts index 0ebbf5f..f530f80 100644 --- a/webui/src/types/instance.ts +++ b/webui/src/types/instance.ts @@ -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; } \ No newline at end of file