feat(apps): Implement app update functionality with UI integration
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user