feat(apps): Implement app update functionality with UI integration

This commit is contained in:
2026-02-13 06:24:23 +00:00
parent d0f03dd644
commit 5febd84276
5 changed files with 96 additions and 3 deletions

View File

@@ -122,6 +122,29 @@ var appDeployCmd = &cobra.Command{
},
}
var appUpdateCmd = &cobra.Command{
Use: "update <app>",
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 <app>",
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)
}

View File

@@ -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 ? <div className={`h-3 w-3 rounded-full ${color}`} /> : undefined; })()}
statusIndicator={(() => { const color = getStatusColor(liveStatuses[app.name] || app.deploymentStatus); if (color) return <div className={`h-3 w-3 rounded-full ${color}`} />; if (app.updateAvailable) return <div className="h-3 w-3 rounded-full bg-amber-500" />; 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}
/>
)}
</div>

View File

@@ -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<Record<string, string>>({});
@@ -241,6 +249,24 @@ export function AppDetailPanel({
</>
)}
{/* Update available */}
{updateAvailable && (appDetails.status === 'deployed' || appDetails.status === 'running') && (
<Button
size="sm"
variant="outline"
className="border-amber-500 text-amber-700 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/20"
onClick={() => onUpdate(appName)}
disabled={isUpdating}
>
{isUpdating ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Update to {availableVersion}
</Button>
)}
{/* Deployed: backup and restore */}
{(appDetails.status === 'deployed' || appDetails.status === 'running') && (
<>
@@ -297,7 +323,12 @@ export function AppDetailPanel({
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Version</p>
<p className="text-sm">{appDetails.version || 'N/A'}</p>
<p className="text-sm">
{appDetails.version || 'N/A'}
{updateAvailable && availableVersion && (
<span className="text-amber-600 dark:text-amber-400 ml-2">({availableVersion} available)</span>
)}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Namespace</p>

View File

@@ -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,

View File

@@ -37,6 +37,10 @@ export const appsApi = {
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/deploy`);
},
async update(instanceName: string, appName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/update`);
},
async delete(instanceName: string, appName: string): Promise<OperationResponse> {
return apiClient.delete(`/api/v1/instances/${instanceName}/apps/${appName}`);
},