Quellcode durchsuchen

feat(update app): front end and queries

Nicolas Meienberger vor 2 Jahren
Ursprung
Commit
f484793a47

+ 13 - 3
packages/dashboard/src/components/AppTile/index.tsx

@@ -1,7 +1,8 @@
-import { Box, SlideFade, useColorModeValue } from '@chakra-ui/react';
+import { Box, SlideFade, Tag, useColorModeValue, Tooltip } from '@chakra-ui/react';
 import Link from 'next/link';
 import React from 'react';
 import { FiChevronRight } from 'react-icons/fi';
+import { MdSystemUpdateAlt } from 'react-icons/md';
 import AppStatus from './AppStatus';
 import AppLogo from '../AppLogo/AppLogo';
 import { limitText } from '../../modules/AppStore/helpers/table.helpers';
@@ -9,7 +10,7 @@ import { AppInfo, AppStatusEnum } from '../../generated/graphql';
 
 type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
 
-const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum }> = ({ app, status }) => {
+const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
   const bg = useColorModeValue('white', '#1a202c');
 
   return (
@@ -18,7 +19,16 @@ const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum }> = ({ app, s
         <Box bg={bg} className="flex flex-1 border-2 drop-shadow-sm rounded-lg p-3 items-center cursor-pointer group hover:drop-shadow-md transition-all">
           <AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={100} />
           <div className="mr-3 flex-1">
-            <h3 className="font-bold text-xl">{app.name}</h3>
+            <div className="flex">
+              <h3 className="font-bold text-xl mr-2">{app.name}</h3>
+              {updateAvailable && (
+                <Tooltip label="Update available">
+                  <Tag colorScheme="gray">
+                    <MdSystemUpdateAlt size={15} />
+                  </Tag>
+                </Tooltip>
+              )}
+            </div>
             <span>{limitText(app.short_desc, 50)}</span>
             <div className="flex mt-1">
               <AppStatus status={status} />

+ 363 - 253
packages/dashboard/src/generated/graphql.tsx

@@ -24,12 +24,13 @@ export type App = {
   config: Scalars['JSONObject'];
   createdAt: Scalars['DateTime'];
   id: Scalars['String'];
-  info: AppInfo;
+  info?: Maybe<AppInfo>;
   lastOpened: Scalars['DateTime'];
   numOpened: Scalars['Float'];
   status: AppStatusEnum;
+  updateInfo?: Maybe<UpdateInfo>;
   updatedAt: Scalars['DateTime'];
-  version: Scalars['Float'];
+  version?: Maybe<Scalars['Float']>;
 };
 
 export enum AppCategoriesEnum {
@@ -45,7 +46,7 @@ export enum AppCategoriesEnum {
   Photography = 'PHOTOGRAPHY',
   Security = 'SECURITY',
   Social = 'SOCIAL',
-  Utilities = 'UTILITIES'
+  Utilities = 'UTILITIES',
 }
 
 export type AppInfo = {
@@ -78,7 +79,8 @@ export enum AppStatusEnum {
   Starting = 'STARTING',
   Stopped = 'STOPPED',
   Stopping = 'STOPPING',
-  Uninstalling = 'UNINSTALLING'
+  Uninstalling = 'UNINSTALLING',
+  Updating = 'UPDATING',
 }
 
 export type Cpu = {
@@ -102,7 +104,7 @@ export enum FieldTypesEnum {
   Password = 'password',
   Random = 'random',
   Text = 'text',
-  Url = 'url'
+  Url = 'url',
 }
 
 export type FormField = {
@@ -131,39 +133,37 @@ export type Mutation = {
   startApp: App;
   stopApp: App;
   uninstallApp: App;
+  updateApp: App;
   updateAppConfig: App;
 };
 
-
 export type MutationInstallAppArgs = {
   input: AppInputType;
 };
 
-
 export type MutationLoginArgs = {
   input: UsernamePasswordInput;
 };
 
-
 export type MutationRegisterArgs = {
   input: UsernamePasswordInput;
 };
 
-
 export type MutationStartAppArgs = {
   id: Scalars['String'];
 };
 
-
 export type MutationStopAppArgs = {
   id: Scalars['String'];
 };
 
-
 export type MutationUninstallAppArgs = {
   id: Scalars['String'];
 };
 
+export type MutationUpdateAppArgs = {
+  id: Scalars['String'];
+};
 
 export type MutationUpdateAppConfigArgs = {
   input: AppInputType;
@@ -180,7 +180,6 @@ export type Query = {
   version: VersionResponse;
 };
 
-
 export type QueryGetAppArgs = {
   id: Scalars['String'];
 };
@@ -192,6 +191,12 @@ export type SystemInfoResponse = {
   memory: DiskMemory;
 };
 
+export type UpdateInfo = {
+  __typename?: 'UpdateInfo';
+  current: Scalars['Float'];
+  latest: Scalars['Float'];
+};
+
 export type User = {
   __typename?: 'User';
   createdAt: Scalars['DateTime'];
@@ -220,103 +225,165 @@ export type InstallAppMutationVariables = Exact<{
   input: AppInputType;
 }>;
 
-
-export type InstallAppMutation = { __typename?: 'Mutation', installApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type InstallAppMutation = { __typename?: 'Mutation'; installApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type LoginMutationVariables = Exact<{
   input: UsernamePasswordInput;
 }>;
 
+export type LoginMutation = { __typename?: 'Mutation'; login: { __typename?: 'UserResponse'; user?: { __typename?: 'User'; id: string } | null } };
 
-export type LoginMutation = { __typename?: 'Mutation', login: { __typename?: 'UserResponse', user?: { __typename?: 'User', id: string } | null } };
-
-export type LogoutMutationVariables = Exact<{ [key: string]: never; }>;
+export type LogoutMutationVariables = Exact<{ [key: string]: never }>;
 
-
-export type LogoutMutation = { __typename?: 'Mutation', logout: boolean };
+export type LogoutMutation = { __typename?: 'Mutation'; logout: boolean };
 
 export type RegisterMutationVariables = Exact<{
   input: UsernamePasswordInput;
 }>;
 
-
-export type RegisterMutation = { __typename?: 'Mutation', register: { __typename?: 'UserResponse', user?: { __typename?: 'User', id: string } | null } };
+export type RegisterMutation = { __typename?: 'Mutation'; register: { __typename?: 'UserResponse'; user?: { __typename?: 'User'; id: string } | null } };
 
 export type StartAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
-
-export type StartAppMutation = { __typename?: 'Mutation', startApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type StartAppMutation = { __typename?: 'Mutation'; startApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type StopAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
-
-export type StopAppMutation = { __typename?: 'Mutation', stopApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type StopAppMutation = { __typename?: 'Mutation'; stopApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type UninstallAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
+export type UninstallAppMutation = { __typename?: 'Mutation'; uninstallApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
+
+export type UpdateAppMutationVariables = Exact<{
+  id: Scalars['String'];
+}>;
 
-export type UninstallAppMutation = { __typename?: 'Mutation', uninstallApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type UpdateAppMutation = { __typename?: 'Mutation'; updateApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type UpdateAppConfigMutationVariables = Exact<{
   input: AppInputType;
 }>;
 
-
-export type UpdateAppConfigMutation = { __typename?: 'Mutation', updateAppConfig: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type UpdateAppConfigMutation = { __typename?: 'Mutation'; updateAppConfig: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type GetAppQueryVariables = Exact<{
   appId: Scalars['String'];
 }>;
 
+export type GetAppQuery = {
+  __typename?: 'Query';
+  getApp: {
+    __typename?: 'App';
+    id: string;
+    status: AppStatusEnum;
+    config: any;
+    version?: number | null;
+    updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number } | null;
+    info?: {
+      __typename?: 'AppInfo';
+      id: string;
+      port: number;
+      name: string;
+      description: string;
+      available: boolean;
+      version?: string | null;
+      tipi_version: number;
+      short_desc: string;
+      author: string;
+      source: string;
+      categories: Array<AppCategoriesEnum>;
+      url_suffix?: string | null;
+      form_fields: Array<{
+        __typename?: 'FormField';
+        type: FieldTypesEnum;
+        label: string;
+        max?: number | null;
+        min?: number | null;
+        hint?: string | null;
+        required?: boolean | null;
+        env_variable: string;
+      }>;
+    } | null;
+  };
+};
 
-export type GetAppQuery = { __typename?: 'Query', getApp: { __typename?: 'App', id: string, status: AppStatusEnum, config: any, version: number, info: { __typename?: 'AppInfo', id: string, port: number, name: string, description: string, available: boolean, version?: string | null, tipi_version: number, short_desc: string, author: string, source: string, categories: Array<AppCategoriesEnum>, url_suffix?: string | null, form_fields: Array<{ __typename?: 'FormField', type: FieldTypesEnum, label: string, max?: number | null, min?: number | null, hint?: string | null, required?: boolean | null, env_variable: string }> } } };
-
-export type InstalledAppsQueryVariables = Exact<{ [key: string]: never; }>;
-
-
-export type InstalledAppsQuery = { __typename?: 'Query', installedApps: Array<{ __typename?: 'App', id: string, status: AppStatusEnum, config: any, version: number, info: { __typename?: 'AppInfo', id: string, name: string, description: string, tipi_version: number, short_desc: string } }> };
-
-export type ConfiguredQueryVariables = Exact<{ [key: string]: never; }>;
-
-
-export type ConfiguredQuery = { __typename?: 'Query', isConfigured: boolean };
-
-export type ListAppsQueryVariables = Exact<{ [key: string]: never; }>;
-
+export type InstalledAppsQueryVariables = Exact<{ [key: string]: never }>;
 
-export type ListAppsQuery = { __typename?: 'Query', listAppsInfo: { __typename?: 'ListAppsResonse', total: number, apps: Array<{ __typename?: 'AppInfo', id: string, available: boolean, tipi_version: number, port: number, name: string, version?: string | null, short_desc: string, author: string, categories: Array<AppCategoriesEnum> }> } };
+export type InstalledAppsQuery = {
+  __typename?: 'Query';
+  installedApps: Array<{
+    __typename?: 'App';
+    id: string;
+    status: AppStatusEnum;
+    config: any;
+    version?: number | null;
+    updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number } | null;
+    info?: { __typename?: 'AppInfo'; id: string; name: string; description: string; tipi_version: number; short_desc: string } | null;
+  }>;
+};
 
-export type MeQueryVariables = Exact<{ [key: string]: never; }>;
+export type ConfiguredQueryVariables = Exact<{ [key: string]: never }>;
 
+export type ConfiguredQuery = { __typename?: 'Query'; isConfigured: boolean };
 
-export type MeQuery = { __typename?: 'Query', me?: { __typename?: 'User', id: string } | null };
+export type ListAppsQueryVariables = Exact<{ [key: string]: never }>;
 
-export type SystemInfoQueryVariables = Exact<{ [key: string]: never; }>;
+export type ListAppsQuery = {
+  __typename?: 'Query';
+  listAppsInfo: {
+    __typename?: 'ListAppsResonse';
+    total: number;
+    apps: Array<{
+      __typename?: 'AppInfo';
+      id: string;
+      available: boolean;
+      tipi_version: number;
+      port: number;
+      name: string;
+      version?: string | null;
+      short_desc: string;
+      author: string;
+      categories: Array<AppCategoriesEnum>;
+    }>;
+  };
+};
 
+export type MeQueryVariables = Exact<{ [key: string]: never }>;
 
-export type SystemInfoQuery = { __typename?: 'Query', systemInfo?: { __typename?: 'SystemInfoResponse', cpu: { __typename?: 'Cpu', load: number }, disk: { __typename?: 'DiskMemory', available: number, used: number, total: number }, memory: { __typename?: 'DiskMemory', available: number, used: number, total: number } } | null };
+export type MeQuery = { __typename?: 'Query'; me?: { __typename?: 'User'; id: string } | null };
 
-export type VersionQueryVariables = Exact<{ [key: string]: never; }>;
+export type SystemInfoQueryVariables = Exact<{ [key: string]: never }>;
 
+export type SystemInfoQuery = {
+  __typename?: 'Query';
+  systemInfo?: {
+    __typename?: 'SystemInfoResponse';
+    cpu: { __typename?: 'Cpu'; load: number };
+    disk: { __typename?: 'DiskMemory'; available: number; used: number; total: number };
+    memory: { __typename?: 'DiskMemory'; available: number; used: number; total: number };
+  } | null;
+};
 
-export type VersionQuery = { __typename?: 'Query', version: { __typename?: 'VersionResponse', current: string, latest?: string | null } };
+export type VersionQueryVariables = Exact<{ [key: string]: never }>;
 
+export type VersionQuery = { __typename?: 'Query'; version: { __typename?: 'VersionResponse'; current: string; latest?: string | null } };
 
 export const InstallAppDocument = gql`
-    mutation InstallApp($input: AppInputType!) {
-  installApp(input: $input) {
-    id
-    status
-    __typename
+  mutation InstallApp($input: AppInputType!) {
+    installApp(input: $input) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type InstallAppMutationFn = Apollo.MutationFunction<InstallAppMutation, InstallAppMutationVariables>;
 
 /**
@@ -337,21 +404,21 @@ export type InstallAppMutationFn = Apollo.MutationFunction<InstallAppMutation, I
  * });
  */
 export function useInstallAppMutation(baseOptions?: Apollo.MutationHookOptions<InstallAppMutation, InstallAppMutationVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useMutation<InstallAppMutation, InstallAppMutationVariables>(InstallAppDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<InstallAppMutation, InstallAppMutationVariables>(InstallAppDocument, options);
+}
 export type InstallAppMutationHookResult = ReturnType<typeof useInstallAppMutation>;
 export type InstallAppMutationResult = Apollo.MutationResult<InstallAppMutation>;
 export type InstallAppMutationOptions = Apollo.BaseMutationOptions<InstallAppMutation, InstallAppMutationVariables>;
 export const LoginDocument = gql`
-    mutation Login($input: UsernamePasswordInput!) {
-  login(input: $input) {
-    user {
-      id
+  mutation Login($input: UsernamePasswordInput!) {
+    login(input: $input) {
+      user {
+        id
+      }
     }
   }
-}
-    `;
+`;
 export type LoginMutationFn = Apollo.MutationFunction<LoginMutation, LoginMutationVariables>;
 
 /**
@@ -372,17 +439,17 @@ export type LoginMutationFn = Apollo.MutationFunction<LoginMutation, LoginMutati
  * });
  */
 export function useLoginMutation(baseOptions?: Apollo.MutationHookOptions<LoginMutation, LoginMutationVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useMutation<LoginMutation, LoginMutationVariables>(LoginDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<LoginMutation, LoginMutationVariables>(LoginDocument, options);
+}
 export type LoginMutationHookResult = ReturnType<typeof useLoginMutation>;
 export type LoginMutationResult = Apollo.MutationResult<LoginMutation>;
 export type LoginMutationOptions = Apollo.BaseMutationOptions<LoginMutation, LoginMutationVariables>;
 export const LogoutDocument = gql`
-    mutation Logout {
-  logout
-}
-    `;
+  mutation Logout {
+    logout
+  }
+`;
 export type LogoutMutationFn = Apollo.MutationFunction<LogoutMutation, LogoutMutationVariables>;
 
 /**
@@ -402,21 +469,21 @@ export type LogoutMutationFn = Apollo.MutationFunction<LogoutMutation, LogoutMut
  * });
  */
 export function useLogoutMutation(baseOptions?: Apollo.MutationHookOptions<LogoutMutation, LogoutMutationVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useMutation<LogoutMutation, LogoutMutationVariables>(LogoutDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<LogoutMutation, LogoutMutationVariables>(LogoutDocument, options);
+}
 export type LogoutMutationHookResult = ReturnType<typeof useLogoutMutation>;
 export type LogoutMutationResult = Apollo.MutationResult<LogoutMutation>;
 export type LogoutMutationOptions = Apollo.BaseMutationOptions<LogoutMutation, LogoutMutationVariables>;
 export const RegisterDocument = gql`
-    mutation Register($input: UsernamePasswordInput!) {
-  register(input: $input) {
-    user {
-      id
+  mutation Register($input: UsernamePasswordInput!) {
+    register(input: $input) {
+      user {
+        id
+      }
     }
   }
-}
-    `;
+`;
 export type RegisterMutationFn = Apollo.MutationFunction<RegisterMutation, RegisterMutationVariables>;
 
 /**
@@ -437,21 +504,21 @@ export type RegisterMutationFn = Apollo.MutationFunction<RegisterMutation, Regis
  * });
  */
 export function useRegisterMutation(baseOptions?: Apollo.MutationHookOptions<RegisterMutation, RegisterMutationVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useMutation<RegisterMutation, RegisterMutationVariables>(RegisterDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<RegisterMutation, RegisterMutationVariables>(RegisterDocument, options);
+}
 export type RegisterMutationHookResult = ReturnType<typeof useRegisterMutation>;
 export type RegisterMutationResult = Apollo.MutationResult<RegisterMutation>;
 export type RegisterMutationOptions = Apollo.BaseMutationOptions<RegisterMutation, RegisterMutationVariables>;
 export const StartAppDocument = gql`
-    mutation StartApp($id: String!) {
-  startApp(id: $id) {
-    id
-    status
-    __typename
+  mutation StartApp($id: String!) {
+    startApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type StartAppMutationFn = Apollo.MutationFunction<StartAppMutation, StartAppMutationVariables>;
 
 /**
@@ -472,21 +539,21 @@ export type StartAppMutationFn = Apollo.MutationFunction<StartAppMutation, Start
  * });
  */
 export function useStartAppMutation(baseOptions?: Apollo.MutationHookOptions<StartAppMutation, StartAppMutationVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useMutation<StartAppMutation, StartAppMutationVariables>(StartAppDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<StartAppMutation, StartAppMutationVariables>(StartAppDocument, options);
+}
 export type StartAppMutationHookResult = ReturnType<typeof useStartAppMutation>;
 export type StartAppMutationResult = Apollo.MutationResult<StartAppMutation>;
 export type StartAppMutationOptions = Apollo.BaseMutationOptions<StartAppMutation, StartAppMutationVariables>;
 export const StopAppDocument = gql`
-    mutation StopApp($id: String!) {
-  stopApp(id: $id) {
-    id
-    status
-    __typename
+  mutation StopApp($id: String!) {
+    stopApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type StopAppMutationFn = Apollo.MutationFunction<StopAppMutation, StopAppMutationVariables>;
 
 /**
@@ -507,21 +574,21 @@ export type StopAppMutationFn = Apollo.MutationFunction<StopAppMutation, StopApp
  * });
  */
 export function useStopAppMutation(baseOptions?: Apollo.MutationHookOptions<StopAppMutation, StopAppMutationVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useMutation<StopAppMutation, StopAppMutationVariables>(StopAppDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<StopAppMutation, StopAppMutationVariables>(StopAppDocument, options);
+}
 export type StopAppMutationHookResult = ReturnType<typeof useStopAppMutation>;
 export type StopAppMutationResult = Apollo.MutationResult<StopAppMutation>;
 export type StopAppMutationOptions = Apollo.BaseMutationOptions<StopAppMutation, StopAppMutationVariables>;
 export const UninstallAppDocument = gql`
-    mutation UninstallApp($id: String!) {
-  uninstallApp(id: $id) {
-    id
-    status
-    __typename
+  mutation UninstallApp($id: String!) {
+    uninstallApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type UninstallAppMutationFn = Apollo.MutationFunction<UninstallAppMutation, UninstallAppMutationVariables>;
 
 /**
@@ -542,21 +609,56 @@ export type UninstallAppMutationFn = Apollo.MutationFunction<UninstallAppMutatio
  * });
  */
 export function useUninstallAppMutation(baseOptions?: Apollo.MutationHookOptions<UninstallAppMutation, UninstallAppMutationVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useMutation<UninstallAppMutation, UninstallAppMutationVariables>(UninstallAppDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<UninstallAppMutation, UninstallAppMutationVariables>(UninstallAppDocument, options);
+}
 export type UninstallAppMutationHookResult = ReturnType<typeof useUninstallAppMutation>;
 export type UninstallAppMutationResult = Apollo.MutationResult<UninstallAppMutation>;
 export type UninstallAppMutationOptions = Apollo.BaseMutationOptions<UninstallAppMutation, UninstallAppMutationVariables>;
-export const UpdateAppConfigDocument = gql`
-    mutation UpdateAppConfig($input: AppInputType!) {
-  updateAppConfig(input: $input) {
-    id
-    status
-    __typename
+export const UpdateAppDocument = gql`
+  mutation UpdateApp($id: String!) {
+    updateApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
+`;
+export type UpdateAppMutationFn = Apollo.MutationFunction<UpdateAppMutation, UpdateAppMutationVariables>;
+
+/**
+ * __useUpdateAppMutation__
+ *
+ * To run a mutation, you first call `useUpdateAppMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useUpdateAppMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [updateAppMutation, { data, loading, error }] = useUpdateAppMutation({
+ *   variables: {
+ *      id: // value for 'id'
+ *   },
+ * });
+ */
+export function useUpdateAppMutation(baseOptions?: Apollo.MutationHookOptions<UpdateAppMutation, UpdateAppMutationVariables>) {
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<UpdateAppMutation, UpdateAppMutationVariables>(UpdateAppDocument, options);
 }
-    `;
+export type UpdateAppMutationHookResult = ReturnType<typeof useUpdateAppMutation>;
+export type UpdateAppMutationResult = Apollo.MutationResult<UpdateAppMutation>;
+export type UpdateAppMutationOptions = Apollo.BaseMutationOptions<UpdateAppMutation, UpdateAppMutationVariables>;
+export const UpdateAppConfigDocument = gql`
+  mutation UpdateAppConfig($input: AppInputType!) {
+    updateAppConfig(input: $input) {
+      id
+      status
+      __typename
+    }
+  }
+`;
 export type UpdateAppConfigMutationFn = Apollo.MutationFunction<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>;
 
 /**
@@ -577,45 +679,49 @@ export type UpdateAppConfigMutationFn = Apollo.MutationFunction<UpdateAppConfigM
  * });
  */
 export function useUpdateAppConfigMutation(baseOptions?: Apollo.MutationHookOptions<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useMutation<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>(UpdateAppConfigDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>(UpdateAppConfigDocument, options);
+}
 export type UpdateAppConfigMutationHookResult = ReturnType<typeof useUpdateAppConfigMutation>;
 export type UpdateAppConfigMutationResult = Apollo.MutationResult<UpdateAppConfigMutation>;
 export type UpdateAppConfigMutationOptions = Apollo.BaseMutationOptions<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>;
 export const GetAppDocument = gql`
-    query GetApp($appId: String!) {
-  getApp(id: $appId) {
-    id
-    status
-    config
-    version
-    info {
+  query GetApp($appId: String!) {
+    getApp(id: $appId) {
       id
-      port
-      name
-      description
-      available
+      status
+      config
       version
-      tipi_version
-      short_desc
-      author
-      source
-      categories
-      url_suffix
-      form_fields {
-        type
-        label
-        max
-        min
-        hint
-        required
-        env_variable
+      updateInfo {
+        current
+        latest
+      }
+      info {
+        id
+        port
+        name
+        description
+        available
+        version
+        tipi_version
+        short_desc
+        author
+        source
+        categories
+        url_suffix
+        form_fields {
+          type
+          label
+          max
+          min
+          hint
+          required
+          env_variable
+        }
       }
     }
   }
-}
-    `;
+`;
 
 /**
  * __useGetAppQuery__
@@ -634,33 +740,37 @@ export const GetAppDocument = gql`
  * });
  */
 export function useGetAppQuery(baseOptions: Apollo.QueryHookOptions<GetAppQuery, GetAppQueryVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useQuery<GetAppQuery, GetAppQueryVariables>(GetAppDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useQuery<GetAppQuery, GetAppQueryVariables>(GetAppDocument, options);
+}
 export function useGetAppLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetAppQuery, GetAppQueryVariables>) {
-          const options = {...defaultOptions, ...baseOptions}
-          return Apollo.useLazyQuery<GetAppQuery, GetAppQueryVariables>(GetAppDocument, options);
-        }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useLazyQuery<GetAppQuery, GetAppQueryVariables>(GetAppDocument, options);
+}
 export type GetAppQueryHookResult = ReturnType<typeof useGetAppQuery>;
 export type GetAppLazyQueryHookResult = ReturnType<typeof useGetAppLazyQuery>;
 export type GetAppQueryResult = Apollo.QueryResult<GetAppQuery, GetAppQueryVariables>;
 export const InstalledAppsDocument = gql`
-    query InstalledApps {
-  installedApps {
-    id
-    status
-    config
-    version
-    info {
+  query InstalledApps {
+    installedApps {
       id
-      name
-      description
-      tipi_version
-      short_desc
+      status
+      config
+      version
+      updateInfo {
+        current
+        latest
+      }
+      info {
+        id
+        name
+        description
+        tipi_version
+        short_desc
+      }
     }
   }
-}
-    `;
+`;
 
 /**
  * __useInstalledAppsQuery__
@@ -678,21 +788,21 @@ export const InstalledAppsDocument = gql`
  * });
  */
 export function useInstalledAppsQuery(baseOptions?: Apollo.QueryHookOptions<InstalledAppsQuery, InstalledAppsQueryVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useQuery<InstalledAppsQuery, InstalledAppsQueryVariables>(InstalledAppsDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useQuery<InstalledAppsQuery, InstalledAppsQueryVariables>(InstalledAppsDocument, options);
+}
 export function useInstalledAppsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<InstalledAppsQuery, InstalledAppsQueryVariables>) {
-          const options = {...defaultOptions, ...baseOptions}
-          return Apollo.useLazyQuery<InstalledAppsQuery, InstalledAppsQueryVariables>(InstalledAppsDocument, options);
-        }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useLazyQuery<InstalledAppsQuery, InstalledAppsQueryVariables>(InstalledAppsDocument, options);
+}
 export type InstalledAppsQueryHookResult = ReturnType<typeof useInstalledAppsQuery>;
 export type InstalledAppsLazyQueryHookResult = ReturnType<typeof useInstalledAppsLazyQuery>;
 export type InstalledAppsQueryResult = Apollo.QueryResult<InstalledAppsQuery, InstalledAppsQueryVariables>;
 export const ConfiguredDocument = gql`
-    query Configured {
-  isConfigured
-}
-    `;
+  query Configured {
+    isConfigured
+  }
+`;
 
 /**
  * __useConfiguredQuery__
@@ -710,34 +820,34 @@ export const ConfiguredDocument = gql`
  * });
  */
 export function useConfiguredQuery(baseOptions?: Apollo.QueryHookOptions<ConfiguredQuery, ConfiguredQueryVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useQuery<ConfiguredQuery, ConfiguredQueryVariables>(ConfiguredDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useQuery<ConfiguredQuery, ConfiguredQueryVariables>(ConfiguredDocument, options);
+}
 export function useConfiguredLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ConfiguredQuery, ConfiguredQueryVariables>) {
-          const options = {...defaultOptions, ...baseOptions}
-          return Apollo.useLazyQuery<ConfiguredQuery, ConfiguredQueryVariables>(ConfiguredDocument, options);
-        }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useLazyQuery<ConfiguredQuery, ConfiguredQueryVariables>(ConfiguredDocument, options);
+}
 export type ConfiguredQueryHookResult = ReturnType<typeof useConfiguredQuery>;
 export type ConfiguredLazyQueryHookResult = ReturnType<typeof useConfiguredLazyQuery>;
 export type ConfiguredQueryResult = Apollo.QueryResult<ConfiguredQuery, ConfiguredQueryVariables>;
 export const ListAppsDocument = gql`
-    query ListApps {
-  listAppsInfo {
-    apps {
-      id
-      available
-      tipi_version
-      port
-      name
-      version
-      short_desc
-      author
-      categories
+  query ListApps {
+    listAppsInfo {
+      apps {
+        id
+        available
+        tipi_version
+        port
+        name
+        version
+        short_desc
+        author
+        categories
+      }
+      total
     }
-    total
   }
-}
-    `;
+`;
 
 /**
  * __useListAppsQuery__
@@ -755,23 +865,23 @@ export const ListAppsDocument = gql`
  * });
  */
 export function useListAppsQuery(baseOptions?: Apollo.QueryHookOptions<ListAppsQuery, ListAppsQueryVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useQuery<ListAppsQuery, ListAppsQueryVariables>(ListAppsDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useQuery<ListAppsQuery, ListAppsQueryVariables>(ListAppsDocument, options);
+}
 export function useListAppsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ListAppsQuery, ListAppsQueryVariables>) {
-          const options = {...defaultOptions, ...baseOptions}
-          return Apollo.useLazyQuery<ListAppsQuery, ListAppsQueryVariables>(ListAppsDocument, options);
-        }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useLazyQuery<ListAppsQuery, ListAppsQueryVariables>(ListAppsDocument, options);
+}
 export type ListAppsQueryHookResult = ReturnType<typeof useListAppsQuery>;
 export type ListAppsLazyQueryHookResult = ReturnType<typeof useListAppsLazyQuery>;
 export type ListAppsQueryResult = Apollo.QueryResult<ListAppsQuery, ListAppsQueryVariables>;
 export const MeDocument = gql`
-    query Me {
-  me {
-    id
+  query Me {
+    me {
+      id
+    }
   }
-}
-    `;
+`;
 
 /**
  * __useMeQuery__
@@ -789,35 +899,35 @@ export const MeDocument = gql`
  * });
  */
 export function useMeQuery(baseOptions?: Apollo.QueryHookOptions<MeQuery, MeQueryVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useQuery<MeQuery, MeQueryVariables>(MeDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useQuery<MeQuery, MeQueryVariables>(MeDocument, options);
+}
 export function useMeLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<MeQuery, MeQueryVariables>) {
-          const options = {...defaultOptions, ...baseOptions}
-          return Apollo.useLazyQuery<MeQuery, MeQueryVariables>(MeDocument, options);
-        }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useLazyQuery<MeQuery, MeQueryVariables>(MeDocument, options);
+}
 export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
 export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
 export type MeQueryResult = Apollo.QueryResult<MeQuery, MeQueryVariables>;
 export const SystemInfoDocument = gql`
-    query SystemInfo {
-  systemInfo {
-    cpu {
-      load
-    }
-    disk {
-      available
-      used
-      total
-    }
-    memory {
-      available
-      used
-      total
+  query SystemInfo {
+    systemInfo {
+      cpu {
+        load
+      }
+      disk {
+        available
+        used
+        total
+      }
+      memory {
+        available
+        used
+        total
+      }
     }
   }
-}
-    `;
+`;
 
 /**
  * __useSystemInfoQuery__
@@ -835,24 +945,24 @@ export const SystemInfoDocument = gql`
  * });
  */
 export function useSystemInfoQuery(baseOptions?: Apollo.QueryHookOptions<SystemInfoQuery, SystemInfoQueryVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useQuery<SystemInfoQuery, SystemInfoQueryVariables>(SystemInfoDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useQuery<SystemInfoQuery, SystemInfoQueryVariables>(SystemInfoDocument, options);
+}
 export function useSystemInfoLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SystemInfoQuery, SystemInfoQueryVariables>) {
-          const options = {...defaultOptions, ...baseOptions}
-          return Apollo.useLazyQuery<SystemInfoQuery, SystemInfoQueryVariables>(SystemInfoDocument, options);
-        }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useLazyQuery<SystemInfoQuery, SystemInfoQueryVariables>(SystemInfoDocument, options);
+}
 export type SystemInfoQueryHookResult = ReturnType<typeof useSystemInfoQuery>;
 export type SystemInfoLazyQueryHookResult = ReturnType<typeof useSystemInfoLazyQuery>;
 export type SystemInfoQueryResult = Apollo.QueryResult<SystemInfoQuery, SystemInfoQueryVariables>;
 export const VersionDocument = gql`
-    query Version {
-  version {
-    current
-    latest
+  query Version {
+    version {
+      current
+      latest
+    }
   }
-}
-    `;
+`;
 
 /**
  * __useVersionQuery__
@@ -870,13 +980,13 @@ export const VersionDocument = gql`
  * });
  */
 export function useVersionQuery(baseOptions?: Apollo.QueryHookOptions<VersionQuery, VersionQueryVariables>) {
-        const options = {...defaultOptions, ...baseOptions}
-        return Apollo.useQuery<VersionQuery, VersionQueryVariables>(VersionDocument, options);
-      }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useQuery<VersionQuery, VersionQueryVariables>(VersionDocument, options);
+}
 export function useVersionLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<VersionQuery, VersionQueryVariables>) {
-          const options = {...defaultOptions, ...baseOptions}
-          return Apollo.useLazyQuery<VersionQuery, VersionQueryVariables>(VersionDocument, options);
-        }
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useLazyQuery<VersionQuery, VersionQueryVariables>(VersionDocument, options);
+}
 export type VersionQueryHookResult = ReturnType<typeof useVersionQuery>;
 export type VersionLazyQueryHookResult = ReturnType<typeof useVersionLazyQuery>;
-export type VersionQueryResult = Apollo.QueryResult<VersionQuery, VersionQueryVariables>;
+export type VersionQueryResult = Apollo.QueryResult<VersionQuery, VersionQueryVariables>;

+ 7 - 0
packages/dashboard/src/graphql/mutations/updateApp.graphql

@@ -0,0 +1,7 @@
+mutation UpdateApp($id: String!) {
+  updateApp(id: $id) {
+    id
+    status
+    __typename
+  }
+}

+ 4 - 0
packages/dashboard/src/graphql/queries/getApp.graphql

@@ -4,6 +4,10 @@ query GetApp($appId: String!) {
     status
     config
     version
+    updateInfo {
+      current
+      latest
+    }
     info {
       id
       port

+ 4 - 0
packages/dashboard/src/graphql/queries/installedApps.graphql

@@ -4,6 +4,10 @@ query InstalledApps {
     status
     config
     version
+    updateInfo {
+      current
+      latest
+    }
     info {
       id
       name

+ 103 - 40
packages/dashboard/src/modules/Apps/components/AppActions.tsx

@@ -1,78 +1,141 @@
-import { Button } from '@chakra-ui/react';
+import { Button, Tooltip } from '@chakra-ui/react';
 import React from 'react';
 import { FiExternalLink, FiPause, FiPlay, FiSettings, FiTrash2 } from 'react-icons/fi';
+import { MdSystemUpdateAlt } from 'react-icons/md';
 import { TiCancel } from 'react-icons/ti';
 import { AppInfo, AppStatusEnum } from '../../../generated/graphql';
 
 interface IProps {
   app: AppInfo;
   status?: AppStatusEnum;
+  updateAvailable: boolean;
   onInstall: () => void;
   onUninstall: () => void;
   onStart: () => void;
   onStop: () => void;
   onOpen: () => void;
   onUpdate: () => void;
+  onUpdateSettings: () => void;
   onCancel: () => void;
 }
 
-const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel }) => {
+const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel, updateAvailable, onUpdateSettings }) => {
   const hasSettings = Object.keys(app.form_fields).length > 0;
 
-  if (status === AppStatusEnum.Stopped) {
-    return (
-      <div className="flex flex-wrap justify-center">
+  const buttons: JSX.Element[] = [];
+
+  const renderStatus = () => {
+    if (status === AppStatusEnum.Installing || status === AppStatusEnum.Uninstalling || status === AppStatusEnum.Starting || status === AppStatusEnum.Stopping || status === AppStatusEnum.Updating) {
+      return <span className="text-gray-500 text-sm ml-2 mt-3 self-center text-center sm:text-left">{`App is ${status.toLowerCase()} please wait...`}</span>;
+    }
+  };
+
+  switch (status) {
+    case AppStatusEnum.Stopped:
+      buttons.push(
         <Button onClick={onStart} width={150} colorScheme="green" className="mt-3 mr-2">
           Start
           <FiPlay className="ml-1" />
-        </Button>
+        </Button>,
         <Button onClick={onUninstall} width={150} colorScheme="gray" className="mt-3 mr-2">
           Remove
           <FiTrash2 className="ml-1" />
-        </Button>
-        {hasSettings && (
-          <Button onClick={onUpdate} width={150} colorScheme="gray" className="mt-3 mr-2">
-            Settings
-            <FiSettings className="ml-1" />
-          </Button>
-        )}
-      </div>
-    );
-  } else if (status === AppStatusEnum.Running) {
-    return (
-      <div>
+        </Button>,
+      );
+      if (hasSettings) {
+        buttons.push(
+          <Tooltip label="Update settings">
+            <Button onClick={onUpdateSettings} colorScheme="gray" className="mt-3 mr-2">
+              <FiSettings className="ml-1" />
+            </Button>
+          </Tooltip>,
+        );
+      }
+      if (updateAvailable) {
+        buttons.push(
+          <Tooltip label="Download update">
+            <Button onClick={onUpdate} colorScheme="gray" className="mt-3 mr-2">
+              <MdSystemUpdateAlt className="ml-1" />
+            </Button>
+          </Tooltip>,
+        );
+      }
+      break;
+    case AppStatusEnum.Running:
+      buttons.push(
+        <Button onClick={onStop} width={150} colorScheme="red" className="mt-3 mr-2">
+          Stop
+          <FiPause className="ml-1" />
+        </Button>,
         <Button onClick={onOpen} width={150} colorScheme="gray" className="mt-3 mr-2">
           Open
           <FiExternalLink className="ml-1" />
-        </Button>
-        <Button onClick={onStop} width={150} colorScheme="red" className="mt-3">
-          Stop
-          <FiPause className="ml-2" />
-        </Button>
-      </div>
-    );
-  } else if (status === AppStatusEnum.Installing || status === AppStatusEnum.Uninstalling || status === AppStatusEnum.Starting || status === AppStatusEnum.Stopping) {
-    return (
-      <div className="flex items-center sm:items-start flex-col md:flex-row">
+        </Button>,
+      );
+      if (hasSettings) {
+        buttons.push(
+          <Tooltip label="Update settings">
+            <Button onClick={onUpdateSettings} colorScheme="gray" className="mt-3 mr-2">
+              <FiSettings className="ml-1" />
+            </Button>
+          </Tooltip>,
+        );
+      }
+      if (updateAvailable) {
+        buttons.push(
+          <Tooltip label="Download update">
+            <Button onClick={onUpdate} colorScheme="gray" className="mt-3 mr-2">
+              <MdSystemUpdateAlt className="ml-1" />
+            </Button>
+          </Tooltip>,
+        );
+      }
+      break;
+    case AppStatusEnum.Installing:
+    case AppStatusEnum.Uninstalling:
+    case AppStatusEnum.Starting:
+    case AppStatusEnum.Stopping:
+      buttons.push(
         <Button isLoading onClick={() => null} width={160} colorScheme="green" className="mt-3">
           Install
           <FiPlay className="ml-1" />
-        </Button>
+        </Button>,
+        <Button onClick={onCancel} colorScheme="gray" className="mt-3 mr-2 ml-2">
+          <TiCancel />
+        </Button>,
+      );
+      break;
+
+    case AppStatusEnum.Updating:
+      buttons.push(
+        <Button isLoading onClick={() => null} width={160} colorScheme="green" className="mt-3">
+          Updating
+          <FiPlay className="ml-1" />
+        </Button>,
         <Button onClick={onCancel} colorScheme="gray" className="mt-3 mr-2 ml-2">
           <TiCancel />
-        </Button>
-        <span className="text-gray-500 text-sm ml-2 mt-3 self-center text-center sm:text-left">{`App is ${status.toLowerCase()} please wait and don't refresh page...`}</span>
-      </div>
-    );
-  } else if (status === AppStatusEnum.Missing) {
-    return (
-      <Button onClick={onInstall} width={160} colorScheme="green" className="mt-3">
-        Install
-      </Button>
-    );
+        </Button>,
+      );
+      break;
+    case AppStatusEnum.Missing:
+      buttons.push(
+        <Button onClick={onInstall} width={160} colorScheme="green" className="mt-3">
+          Install
+        </Button>,
+      );
+      break;
+    default:
+      break;
   }
 
-  return null;
+  return (
+    <div className="flex items-center sm:items-start flex-col md:flex-row">
+      {buttons.map((button) => {
+        return button;
+      })}
+      {renderStatus()}
+    </div>
+  );
 };
 
 export default AppActions;

+ 12 - 8
packages/dashboard/src/modules/Apps/components/UpdateModal.tsx

@@ -1,26 +1,30 @@
-import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
+import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from '@chakra-ui/react';
 import React from 'react';
-import InstallForm from './InstallForm';
-import { App, AppInfo } from '../../../generated/graphql';
+import { AppInfo } from '../../../generated/graphql';
 
 interface IProps {
+  newVersion: string;
   app: AppInfo;
-  config: App['config'];
   isOpen: boolean;
   onClose: () => void;
-  onSubmit: (values: Record<string, any>) => void;
+  onConfirm: () => void;
 }
 
-const UpdateModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit }) => {
+const UpdateModal: React.FC<IProps> = ({ app, newVersion, isOpen, onClose, onConfirm }) => {
   return (
     <Modal isOpen={isOpen} onClose={onClose}>
       <ModalOverlay />
       <ModalContent>
-        <ModalHeader>Update {app.name} config</ModalHeader>
+        <ModalHeader>Update {app.name} ?</ModalHeader>
         <ModalCloseButton />
         <ModalBody>
-          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} initalValues={config} />
+          Update app to latest verion : <b>{newVersion}</b> ?
         </ModalBody>
+        <ModalFooter>
+          <Button onClick={onConfirm} colorScheme="green">
+            Update
+          </Button>
+        </ModalFooter>
       </ModalContent>
     </Modal>
   );

+ 29 - 0
packages/dashboard/src/modules/Apps/components/UpdateSettingsModal.tsx

@@ -0,0 +1,29 @@
+import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
+import React from 'react';
+import InstallForm from './InstallForm';
+import { App, AppInfo } from '../../../generated/graphql';
+
+interface IProps {
+  app: AppInfo;
+  config: App['config'];
+  isOpen: boolean;
+  onClose: () => void;
+  onSubmit: (values: Record<string, any>) => void;
+}
+
+const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit }) => {
+  return (
+    <Modal isOpen={isOpen} onClose={onClose}>
+      <ModalOverlay />
+      <ModalContent>
+        <ModalHeader>Update {app.name} config</ModalHeader>
+        <ModalCloseButton />
+        <ModalBody>
+          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} initalValues={config} />
+        </ModalBody>
+      </ModalContent>
+    </Modal>
+  );
+};
+
+export default UpdateSettingsModal;

+ 34 - 7
packages/dashboard/src/modules/Apps/containers/AppDetails.tsx

@@ -6,7 +6,7 @@ import AppActions from '../components/AppActions';
 import InstallModal from '../components/InstallModal';
 import StopModal from '../components/StopModal';
 import UninstallModal from '../components/UninstallModal';
-import UpdateModal from '../components/UpdateModal';
+import UpdateSettingsModal from '../components/UpdateSettingsModal';
 import AppLogo from '../../../components/AppLogo/AppLogo';
 import Markdown from '../../../components/Markdown/Markdown';
 import {
@@ -20,10 +20,12 @@ import {
   useStopAppMutation,
   useUninstallAppMutation,
   useUpdateAppConfigMutation,
+  useUpdateAppMutation,
 } from '../../../generated/graphql';
+import UpdateModal from '../components/UpdateModal';
 
 interface IProps {
-  app?: Pick<App, 'status' | 'config'>;
+  app?: Pick<App, 'status' | 'config' | 'version' | 'updateInfo'>;
   info: AppInfo;
 }
 
@@ -33,13 +35,17 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
   const uninstallDisclosure = useDisclosure();
   const stopDisclosure = useDisclosure();
   const updateDisclosure = useDisclosure();
+  const updateSettingsDisclosure = useDisclosure();
 
   // Mutations
+  const [update] = useUpdateAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
   const [install] = useInstallAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }, { query: InstalledAppsDocument }] });
   const [uninstall] = useUninstallAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }, { query: InstalledAppsDocument }] });
   const [stop] = useStopAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
   const [start] = useStartAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
-  const [update] = useUpdateAppConfigMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
+  const [updateConfig] = useUpdateAppConfigMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
+
+  const updateAvailable = Number(app?.updateInfo?.current || 0) < Number(app?.updateInfo?.latest);
 
   const { internalIp } = useSytemStore();
 
@@ -93,16 +99,31 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
     }
   };
 
-  const handleUpdateSubmit = async (values: Record<string, any>) => {
+  const handleUpdateSettingsSubmit = async (values: Record<string, any>) => {
     try {
-      await update({ variables: { input: { form: values, id: info.id } } });
+      await updateConfig({ variables: { input: { form: values, id: info.id } } });
       toast({
         title: 'Success',
         description: 'App config updated successfully',
         position: 'top',
         status: 'success',
       });
-      updateDisclosure.onClose();
+      updateSettingsDisclosure.onClose();
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  const handleUpdateSubmit = async () => {
+    updateDisclosure.onClose();
+    try {
+      await update({ variables: { id: info.id }, optimisticResponse: { updateApp: { id: info.id, status: AppStatusEnum.Updating, __typename: 'App' } } });
+      toast({
+        title: 'Success',
+        description: 'App updated successfully',
+        position: 'top',
+        status: 'success',
+      });
     } catch (error) {
       handleError(error);
     }
@@ -121,6 +142,9 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
             <div className="mt-3 items-center self-center flex flex-col sm:items-start sm:self-start md:mt-0">
               <h1 className="font-bold text-2xl">{info.name}</h1>
               <h2 className="text-center md:text-left">{info.short_desc}</h2>
+              <h3 className="text-center md:text-left text-sm">
+                version: <b>{info.version}</b> ({app?.version})
+              </h3>
               {info.source && (
                 <a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={info.source}>
                   <Flex className="mt-2 items-center">
@@ -133,7 +157,9 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
             </div>
             <div className="flex justify-center xs:absolute md:static top-0 right-5 self-center sm:self-auto">
               <AppActions
+                updateAvailable={updateAvailable}
                 onUpdate={updateDisclosure.onOpen}
+                onUpdateSettings={updateSettingsDisclosure.onOpen}
                 onOpen={handleOpen}
                 onStart={handleStartSubmit}
                 onStop={stopDisclosure.onOpen}
@@ -151,7 +177,8 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
         <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={info} />
         <UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.onClose} app={info} />
         <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={info} />
-        <UpdateModal onSubmit={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} config={app?.config} />
+        <UpdateSettingsModal onSubmit={handleUpdateSettingsSubmit} isOpen={updateSettingsDisclosure.isOpen} onClose={updateSettingsDisclosure.onClose} app={info} config={app?.config} />
+        <UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={`${info.version} (${info.tipi_version})`} />
       </div>
     </SlideFade>
   );

+ 1 - 1
packages/dashboard/src/pages/apps/[id].tsx

@@ -12,7 +12,7 @@ const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
 
   const breadcrumb = [
     { name: 'Apps', href: '/apps' },
-    { name: data?.getApp.info.name || '', href: `/apps/${appId}`, current: true },
+    { name: data?.getApp.info?.name || '', href: `/apps/${appId}`, current: true },
   ];
 
   return (

+ 8 - 4
packages/dashboard/src/pages/apps/index.tsx

@@ -3,13 +3,19 @@ import { Flex, SimpleGrid } from '@chakra-ui/react';
 import type { NextPage } from 'next';
 import Layout from '../../components/Layout';
 import AppTile from '../../components/AppTile';
-import { useInstalledAppsQuery } from '../../generated/graphql';
+import { InstalledAppsQuery, useInstalledAppsQuery } from '../../generated/graphql';
 
 const Apps: NextPage = () => {
   const { data, loading } = useInstalledAppsQuery();
 
   const installedCount: number = data?.installedApps.length || 0;
 
+  const renderApp = (app: InstalledAppsQuery['installedApps'][0]) => {
+    const updateAvailable = Number(app.updateInfo?.current) < Number(app.updateInfo?.latest);
+
+    if (app.info) return <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />;
+  };
+
   return (
     <Layout loading={loading || !data?.installedApps}>
       <Flex className="flex-col">
@@ -21,9 +27,7 @@ const Apps: NextPage = () => {
           </div>
         )}
         <SimpleGrid minChildWidth="340px" spacing="20px">
-          {data?.installedApps.map((app) => (
-            <AppTile key={app.id} app={app.info} status={app.status} />
-          ))}
+          {data?.installedApps.map((a) => renderApp(a))}
         </SimpleGrid>
       </Flex>
     </Layout>

+ 5 - 1
packages/dashboard/src/utils/typescript.ts

@@ -1,3 +1,7 @@
 const objectKeys = <T>(obj: T): (keyof T)[] => Object.keys(obj) as (keyof T)[];
 
-export { objectKeys };
+function nonNullable<T>(value: T): value is NonNullable<T> {
+  return value !== null && value !== undefined;
+}
+
+export { objectKeys, nonNullable };

+ 11 - 0
packages/system-api/src/config/migrations/1660071627328-AppStatusUpdating.ts

@@ -0,0 +1,11 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AppStatusUpdating1660071627328 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('ALTER TYPE "public"."app_status_enum" ADD VALUE \'updating\'');
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('ALTER TYPE "public"."app_status_enum" DROP VALUE \'updating\'');
+  }
+}

+ 17 - 3
packages/system-api/src/modules/apps/app.entity.ts

@@ -1,13 +1,22 @@
 import { GraphQLJSONObject } from 'graphql-type-json';
 import { Field, ObjectType, registerEnumType } from 'type-graphql';
 import { BaseEntity, Column, CreateDateColumn, Entity, UpdateDateColumn } from 'typeorm';
-import { getAppInfo } from './apps.helpers';
+import { getAppInfo, getUpdateInfo } from './apps.helpers';
 import { AppInfo, AppStatusEnum } from './apps.types';
 
 registerEnumType(AppStatusEnum, {
   name: 'AppStatusEnum',
 });
 
+@ObjectType()
+class UpdateInfo {
+  @Field(() => Number)
+  current!: number;
+
+  @Field(() => Number)
+  latest!: number;
+}
+
 @ObjectType()
 @Entity()
 class App extends BaseEntity {
@@ -43,10 +52,15 @@ class App extends BaseEntity {
   @UpdateDateColumn()
   updatedAt!: Date;
 
-  @Field(() => AppInfo)
-  info(): AppInfo {
+  @Field(() => AppInfo, { nullable: true })
+  info(): AppInfo | null {
     return getAppInfo(this.id);
   }
+
+  @Field(() => UpdateInfo, { nullable: true })
+  updateInfo(): Promise<UpdateInfo | null> {
+    return getUpdateInfo(this.id);
+  }
 }
 
 export default App;

+ 23 - 0
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -5,6 +5,7 @@ import crypto from 'crypto';
 import config from '../../config';
 import { AppInfo } from './apps.types';
 import logger from '../../config/logger/logger';
+import App from './app.entity';
 
 export const checkAppRequirements = async (appName: string) => {
   let valid = true;
@@ -136,6 +137,13 @@ export const getAvailableApps = async (): Promise<string[]> => {
 export const getAppInfo = (id: string): AppInfo => {
   try {
     const repoId = config.APPS_REPO_ID;
+
+    if (fileExists(`/apps/${id}/config.json`)) {
+      const configFile: AppInfo = readJsonFile(`/apps/${id}/config.json`);
+      configFile.description = readFile(`/apps/${id}/metadata/description.md`).toString();
+      return configFile;
+    }
+
     if (fileExists(`/repos/${repoId}`)) {
       const configFile: AppInfo = readJsonFile(`/repos/${repoId}/apps/${id}/config.json`);
       configFile.description = readFile(`/repos/${repoId}/apps/${id}/metadata/description.md`);
@@ -150,3 +158,18 @@ export const getAppInfo = (id: string): AppInfo => {
     throw new Error(`Error loading app ${id}`);
   }
 };
+
+export const getUpdateInfo = async (id: string) => {
+  const app = await App.findOne({ where: { id } });
+
+  if (!app) {
+    return null;
+  }
+
+  const repoConfig: AppInfo = readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${id}/config.json`);
+
+  return {
+    current: app.version,
+    latest: repoConfig.tipi_version,
+  };
+};

+ 6 - 0
packages/system-api/src/modules/apps/apps.resolver.ts

@@ -54,4 +54,10 @@ export default class AppsResolver {
 
     return AppsService.updateAppConfig(id, form);
   }
+
+  @Authorized()
+  @Mutation(() => App)
+  async updateApp(@Arg('id', () => String) id: string): Promise<App> {
+    return AppsService.updateApp(id);
+  }
 }

+ 27 - 1
packages/system-api/src/modules/apps/apps.service.ts

@@ -185,4 +185,30 @@ const getApp = async (id: string): Promise<App> => {
   return app;
 };
 
-export default { installApp, startApp, listApps, getApp, updateAppConfig, stopApp, uninstallApp, startAllApps };
+const updateApp = async (id: string) => {
+  let app = await App.findOne({ where: { id } });
+
+  if (!app) {
+    throw new Error(`App ${id} not found`);
+  }
+
+  await App.update({ id }, { status: AppStatusEnum.UPDATING });
+
+  // Run script
+  try {
+    await runAppScript(['update', id]);
+    const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
+    await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
+  } catch (e) {
+    logger.error(e);
+    throw e;
+  } finally {
+    await App.update({ id }, { status: AppStatusEnum.STOPPED });
+  }
+
+  app = (await App.findOne({ where: { id } })) as App;
+
+  return app;
+};
+
+export default { installApp, startApp, updateApp, listApps, getApp, updateAppConfig, stopApp, uninstallApp, startAllApps };

+ 1 - 0
packages/system-api/src/modules/apps/apps.types.ts

@@ -37,6 +37,7 @@ export enum AppStatusEnum {
   STOPPING = 'stopping',
   STARTING = 'starting',
   MISSING = 'missing',
+  UPDATING = 'updating',
 }
 
 registerEnumType(AppCategoriesEnum, {

+ 0 - 1
scripts/app.sh

@@ -189,7 +189,6 @@ if [[ "$command" = "update" ]]; then
   cp -r "${ROOT_FOLDER}/repos/${repo_id}/apps/${app}" "${app_dir}"
 
   compose "${app}" pull
-  compose "${app}" up --detach
   exit
 fi