diff --git a/app/(main)/instance/_lib/instance.ts b/app/(main)/instance/_lib/instance.ts index 2f17e66..b28eb13 100644 --- a/app/(main)/instance/_lib/instance.ts +++ b/app/(main)/instance/_lib/instance.ts @@ -1,5 +1,8 @@ import { Instance } from '../../instances/_lib/instances.d'; +const OPERATION_POLL_MAX_ATTEMPTS = 20; +const OPERATION_POLL_DELAY_MS = 500; + export type InstanceAction = 'start' | 'stop' | 'restart' | 'freeze'; export async function performInstanceAction({ @@ -34,3 +37,184 @@ export async function performInstanceAction({ ); } } + +export async function updateInstance({ + name, + description, + project, +}: { + name: string; + description: string; + project?: string; +}) { + const projectSuffix = project + ? `?project=${encodeURIComponent(project)}` + : ''; + const res = await fetch( + `/1.0/instances/${encodeURIComponent(name)}${projectSuffix}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + description, + }), + }, + ); + if (!res.ok) { + const payload = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(payload?.error || `Unable to update instance ${name}`); + } +} + +export async function renameInstance({ + name, + newName, + project, +}: { + name: string; + newName: string; + project?: string; +}) { + const projectSuffix = project + ? `?project=${encodeURIComponent(project)}` + : ''; + const res = await fetch( + `/1.0/instances/${encodeURIComponent(name)}${projectSuffix}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: newName, + }), + }, + ); + if (!res.ok) { + const payload = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(payload?.error || `Unable to rename instance ${name}`); + } + + const data = await res.json(); + if (data.type === 'async' && data.operation) { + await waitForOperation(data.operation); + } +} + +async function waitForOperation(operationUrl: string) { + // Poll the operation URL until it's done + for (let i = 0; i < OPERATION_POLL_MAX_ATTEMPTS; i++) { + const res = await fetch(operationUrl); + if (!res.ok) { + // If we can't check the operation, assume it failed or network issue + throw new Error('Failed to check operation status'); + } + const data = await res.json(); + // Operation status: Running, Pending, Success, Failure, Cancelled + if (data.metadata?.status === 'Success') { + return; + } + if (data.metadata?.status === 'Failure') { + throw new Error(data.metadata.err || 'Operation failed'); + } + if (data.metadata?.status === 'Cancelled') { + throw new Error('Operation cancelled'); + } + + // Wait before next poll + await new Promise((resolve) => setTimeout(resolve, OPERATION_POLL_DELAY_MS)); + } + + const operationId = operationUrl.split('/').pop() ?? operationUrl; + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const socketUrl = `${protocol}://${window.location.host}/1.0/events?type=operation&operation=${encodeURIComponent( + operationId, + )}`; + + await new Promise((resolve, reject) => { + let settled = false; + const socket = new WebSocket(socketUrl); + const timeoutId = window.setTimeout(() => { + if (settled) return; + settled = true; + socket.close(); + reject(new Error('Operation timed out')); + }, OPERATION_EVENT_TIMEOUT_MS); + + const cleanup = () => { + window.clearTimeout(timeoutId); + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + }; + + socket.onerror = () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error('Failed to check operation status')); + }; + + socket.onclose = () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error('Operation socket closed before completion')); + }; + + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as OperationEvent; + if (data.type !== 'operation') { + return; + } + + const metadata = data.metadata; + if (!metadata?.status) { + return; + } + + if (metadata.id && metadata.id !== operationId) { + return; + } + + if (metadata.status === 'Success') { + if (settled) return; + settled = true; + cleanup(); + socket.close(); + resolve(); + } + + if (metadata.status === 'Failure') { + if (settled) return; + settled = true; + cleanup(); + socket.close(); + reject(new Error(metadata.err || 'Operation failed')); + } + + if (metadata.status === 'Cancelled') { + if (settled) return; + settled = true; + cleanup(); + socket.close(); + reject(new Error('Operation cancelled')); + } + } catch (error) { + if (settled) return; + settled = true; + cleanup(); + socket.close(); + reject( + error instanceof Error + ? error + : new Error('Failed to parse operation status'), + ); + } + }; + }); +} diff --git a/app/(main)/instance/layout.tsx b/app/(main)/instance/layout.tsx index 39068a5..bfe3b55 100644 --- a/app/(main)/instance/layout.tsx +++ b/app/(main)/instance/layout.tsx @@ -12,15 +12,30 @@ import { SquareIcon, RotateCcwIcon, SnowflakeIcon, + PencilIcon, + CheckIcon, + XIcon, } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger } from 'ui-web/components/tabs'; import { OSLogo } from '@/app/_components/OSLogo'; import { getBaseImage } from './_lib/utils'; -import { performInstanceAction, type InstanceAction } from './_lib/instance'; +import { + performInstanceAction, + type InstanceAction, + updateInstance, + renameInstance, +} from './_lib/instance'; +import { Input } from 'ui-web/components/input'; +import { toast } from 'sonner'; + +const INPUT_WIDTH_BUFFER_CH = 4; +const MIN_VISIBLE_CHARACTERS = 1; function InstanceLayoutContent({ children }: { children: React.ReactNode }) { const { name, instance, isLoading, isError, mutate } = useInstanceContext(); + const [isRenaming, setIsRenaming] = React.useState(false); + if (!name) { return ( @@ -40,7 +55,7 @@ function InstanceLayoutContent({ children }: { children: React.ReactNode }) { ); } - if (isError || !instance) { + if ((isError || !instance) && !isRenaming) { return ( Error @@ -53,7 +68,12 @@ function InstanceLayoutContent({ children }: { children: React.ReactNode }) { return (
- + setIsRenaming(true)} + onRenameEnd={() => setIsRenaming(false)} + />
@@ -90,13 +110,27 @@ const instanceActionDetails: Record< function InstanceHeader({ instance, onMutate, + onRenameStart, + onRenameEnd, }: { instance: Instance; onMutate: () => Promise; + onRenameStart: () => void; + onRenameEnd: () => void; }) { const [actionInFlight, setActionInFlight] = React.useState(null); const [actionError, setActionError] = React.useState(null); + const router = useRouter(); + + // Editing state + const [isEditingName, setIsEditingName] = React.useState(false); + const [newName, setNewName] = React.useState(instance.name); + const [isEditingDescription, setIsEditingDescription] = React.useState(false); + const [newDescription, setNewDescription] = React.useState( + instance.description || '', + ); + const [isSaving, setIsSaving] = React.useState(false); const handleAction = async (action: InstanceAction) => { try { @@ -113,6 +147,56 @@ function InstanceHeader({ } }; + const handleSaveName = async () => { + if (newName === instance.name) { + setIsEditingName(false); + return; + } + try { + setIsSaving(true); + onRenameStart(); + await renameInstance({ + name: instance.name, + newName, + project: instance.project, + }); + setIsEditingName(false); + // Redirect to new URL + router.push(`/instance?name=${encodeURIComponent(newName)}`); + } catch (err) { + toast.error( + err instanceof Error ? err.message : 'Failed to rename instance', + ); + onRenameEnd(); + } finally { + setIsSaving(false); + } + }; + + const handleSaveDescription = async () => { + if (newDescription === instance.description) { + setIsEditingDescription(false); + return; + } + try { + setIsSaving(true); + await updateInstance({ + name: instance.name, + description: newDescription, + project: instance.project, + }); + await onMutate(); + toast.success('Description updated successfully'); + setIsEditingDescription(false); + } catch (err) { + toast.error( + err instanceof Error ? err.message : 'Failed to update description', + ); + } finally { + setIsSaving(false); + } + }; + const status = instance.status?.toLowerCase(); const isRunning = status === 'running'; const isStopped = status === 'stopped'; @@ -152,10 +236,115 @@ function InstanceHeader({ )}
-
-

{instance.name}

- {instance.description && ( -

{instance.description}

+
+ {isEditingName ? ( +
+ setNewName(e.target.value)} + className="h-8 w-auto font-bold min-w-[4ch]" + style={{ + width: `${Math.max( + newName.length, + MIN_VISIBLE_CHARACTERS, + ) + INPUT_WIDTH_BUFFER_CH}ch`, + }} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveName(); + if (e.key === 'Escape') { + setIsEditingName(false); + setNewName(instance.name); + } + }} + /> + + +
+ ) : ( +
setIsEditingName(true)} + > +

{instance.name}

+ +
+ )} + + {isEditingDescription ? ( +
+ setNewDescription(e.target.value)} + className="h-8 w-auto min-w-[10ch]" + style={{ + width: `${Math.max( + newDescription.length, + MIN_VISIBLE_CHARACTERS, + ) + INPUT_WIDTH_BUFFER_CH}ch`, + }} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveDescription(); + if (e.key === 'Escape') { + setIsEditingDescription(false); + setNewDescription(instance.description || ''); + } + }} + /> +
+ + +
+
+ ) : ( +
setIsEditingDescription(true)} + > +

+ {instance.description || 'Add a description...'} +

+ +
)}
diff --git a/deps/ui-web b/deps/ui-web index 06b9f31..0ec782d 160000 --- a/deps/ui-web +++ b/deps/ui-web @@ -1 +1 @@ -Subproject commit 06b9f31b6504767e445c7d7242b5070de90959ad +Subproject commit 0ec782d55d02b8911ff248e97972267aa530bb27