From 5febd84276f4df99d5dc3db1b54ab4bbc51d99ec Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Fri, 13 Feb 2026 06:24:23 +0000 Subject: [PATCH] feat(apps): Implement app update functionality with UI integration --- cli/cmd/app.go | 24 ++++++++++++++++ web/src/components/AppsComponent.tsx | 13 +++++++-- web/src/components/apps/AppDetailPanel.tsx | 33 +++++++++++++++++++++- web/src/hooks/useApps.ts | 25 ++++++++++++++++ web/src/services/api/apps.ts | 4 +++ 5 files changed, 96 insertions(+), 3 deletions(-) diff --git a/cli/cmd/app.go b/cli/cmd/app.go index 29b02ce..c31a99d 100644 --- a/cli/cmd/app.go +++ b/cli/cmd/app.go @@ -122,6 +122,29 @@ var appDeployCmd = &cobra.Command{ }, } +var appUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an app from the Wild Directory", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + inst, err := getInstanceName() + if err != nil { + return err + } + + resp, err := apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/apps/%s/update", inst, args[0]), nil) + if err != nil { + return err + } + + fmt.Printf("App update started: %s\n", args[0]) + if opID := resp.GetString("operation_id"); opID != "" { + fmt.Printf("Operation ID: %s\n", opID) + } + return nil + }, +} + var appDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete an app", @@ -180,6 +203,7 @@ func init() { appCmd.AddCommand(appListDeployedCmd) appCmd.AddCommand(appAddCmd) appCmd.AddCommand(appDeployCmd) + appCmd.AddCommand(appUpdateCmd) appCmd.AddCommand(appDeleteCmd) appCmd.AddCommand(appStatusCmd) } diff --git a/web/src/components/AppsComponent.tsx b/web/src/components/AppsComponent.tsx index eea786a..5b487e1 100644 --- a/web/src/components/AppsComponent.tsx +++ b/web/src/components/AppsComponent.tsx @@ -30,6 +30,7 @@ import { usePageHelp } from '../hooks/usePageHelp'; interface MergedApp extends App { deploymentStatus?: 'added' | 'deployed'; url?: string; + updateAvailable?: boolean; } type TabView = 'available' | 'installed'; @@ -45,6 +46,8 @@ export function AppsComponent() { addApp, isAdding, addingAppNames, + updateApp, + updatingAppNames, deployApp, deployingAppNames, deleteApp, @@ -111,8 +114,10 @@ export function AppsComponent() { ...app, deploymentStatus: deployedApp?.status, url: deployedApp?.url, - // Prefer deployed app icon if it exists (allows custom icons in local manifests) + // Prefer deployed app's version and icon when installed + version: deployedApp?.version || app.version, icon: deployedApp?.icon || app.icon, + updateAvailable: deployedApp?.version ? deployedApp.version !== app.version : false, } as MergedApp; }); @@ -404,7 +409,7 @@ export function AppsComponent() { title={app.name} version={app.version} description={app.description} - statusIndicator={(() => { const color = getStatusColor(liveStatuses[app.name] || app.deploymentStatus); return color ?
: undefined; })()} + statusIndicator={(() => { const color = getStatusColor(liveStatuses[app.name] || app.deploymentStatus); if (color) return
; if (app.updateAvailable) return
; return undefined; })()} onClick={() => setSelectedAppForDetail(app.name)} tint="#fd9631" /> @@ -474,6 +479,7 @@ export function AppsComponent() { open={!!selectedAppForDetail} onClose={() => setSelectedAppForDetail(null)} onDeploy={(appName) => deployApp(appName)} + onUpdate={(appName) => updateApp(appName)} onDelete={(appName) => { if (confirm(`Are you sure you want to delete ${appName}?`)) { deleteApp(appName); @@ -504,7 +510,10 @@ export function AppsComponent() { } }} isDeploying={deployingAppNames.includes(selectedAppForDetail)} + isUpdating={updatingAppNames.includes(selectedAppForDetail)} isDeleting={deletingAppNames.includes(selectedAppForDetail)} + updateAvailable={installedApps.find(a => a.name === selectedAppForDetail)?.updateAvailable || false} + availableVersion={availableAppsMap.get(selectedAppForDetail)?.version} /> )}
diff --git a/web/src/components/apps/AppDetailPanel.tsx b/web/src/components/apps/AppDetailPanel.tsx index 9539ac3..00df39b 100644 --- a/web/src/components/apps/AppDetailPanel.tsx +++ b/web/src/components/apps/AppDetailPanel.tsx @@ -37,12 +37,16 @@ interface AppDetailPanelProps { open: boolean; onClose: () => void; onDeploy: (appName: string) => void; + onUpdate: (appName: string) => void; onDelete: (appName: string) => void; onBackup: (appName: string) => void; onRestore: (appName: string) => void; onConfigure: (appName: string) => void; isDeploying: boolean; + isUpdating: boolean; isDeleting: boolean; + updateAvailable: boolean; + availableVersion?: string; } export function AppDetailPanel({ @@ -51,12 +55,16 @@ export function AppDetailPanel({ open, onClose, onDeploy, + onUpdate, onDelete, onBackup, onRestore, onConfigure, isDeploying, + isUpdating, isDeleting, + updateAvailable, + availableVersion, }: AppDetailPanelProps) { const [showSecrets, setShowSecrets] = useState(false); const [secrets, setSecrets] = useState>({}); @@ -241,6 +249,24 @@ export function AppDetailPanel({ )} + {/* Update available */} + {updateAvailable && (appDetails.status === 'deployed' || appDetails.status === 'running') && ( + + )} + {/* Deployed: backup and restore */} {(appDetails.status === 'deployed' || appDetails.status === 'running') && ( <> @@ -297,7 +323,12 @@ export function AppDetailPanel({

Version

-

{appDetails.version || 'N/A'}

+

+ {appDetails.version || 'N/A'} + {updateAvailable && availableVersion && ( + ({availableVersion} available) + )} +

Namespace

diff --git a/web/src/hooks/useApps.ts b/web/src/hooks/useApps.ts index 0c94942..64658c4 100644 --- a/web/src/hooks/useApps.ts +++ b/web/src/hooks/useApps.ts @@ -78,6 +78,23 @@ export function useDeployedApps(instanceName: string | null | undefined) { }, }); + const updateMutation = useMutation({ + mutationKey: ['updateApp', instanceName], + mutationFn: async (appName: string) => { + toast.loading(`Updating ${appName}...`, { id: `update-${appName}` }); + const response = await appsApi.update(instanceName!, appName); + await pollOperation(instanceName!, response.operation_id); + return response; + }, + onSuccess: (_, appName) => { + toast.success(`${appName} updated successfully. Deploy to apply changes.`, { id: `update-${appName}` }); + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'apps'] }); + }, + onError: (error, appName) => { + toast.error(`Failed to update ${appName}: ${error.message}`, { id: `update-${appName}` }); + }, + }); + const deleteMutation = useMutation({ mutationKey: ['deleteApp', instanceName], mutationFn: async (appName: string) => { @@ -106,6 +123,11 @@ export function useDeployedApps(instanceName: string | null | undefined) { select: (mutation) => mutation.state.variables as string, }); + const pendingUpdates = useMutationState({ + filters: { mutationKey: ['updateApp', instanceName], status: 'pending' }, + select: (mutation) => mutation.state.variables as string, + }); + const pendingAdds = useMutationState({ filters: { mutationKey: ['addApp', instanceName], status: 'pending' }, select: (mutation) => (mutation.state.variables as AppAddRequest)?.name, @@ -120,6 +142,9 @@ export function useDeployedApps(instanceName: string | null | undefined) { isAdding: addMutation.isPending, addingAppNames: pendingAdds, addResult: addMutation.data, + updateApp: updateMutation.mutate, + isUpdating: updateMutation.isPending, + updatingAppNames: pendingUpdates, deployApp: deployMutation.mutate, isDeploying: deployMutation.isPending, deployingAppNames: pendingDeploys, diff --git a/web/src/services/api/apps.ts b/web/src/services/api/apps.ts index dd72a6f..61c95bc 100644 --- a/web/src/services/api/apps.ts +++ b/web/src/services/api/apps.ts @@ -37,6 +37,10 @@ export const appsApi = { return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/deploy`); }, + async update(instanceName: string, appName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/update`); + }, + async delete(instanceName: string, appName: string): Promise { return apiClient.delete(`/api/v1/instances/${instanceName}/apps/${appName}`); },