Ver código fonte

Merge pull request #216 from meienberger/feature/inapp-update

feat(settings): in app update and restart
Nicolas Meienberger 2 anos atrás
pai
commit
b3611a4cb1
39 arquivos alterados com 913 adições e 85 exclusões
  1. 3 0
      docker-compose.dev.yml
  2. 3 0
      docker-compose.rc.yml
  3. 3 0
      docker-compose.yml
  4. 2 2
      packages/dashboard/src/components/AppLogo/AppLogo.tsx
  5. 0 1
      packages/dashboard/src/components/Layout/Layout.tsx
  6. 14 0
      packages/dashboard/src/components/StatusScreens/RestartingScreen.tsx
  7. 50 0
      packages/dashboard/src/components/StatusScreens/StatusWrapper.tsx
  8. 14 0
      packages/dashboard/src/components/StatusScreens/UpdatingScreen.tsx
  9. 2 2
      packages/dashboard/src/core/api.ts
  10. 2 2
      packages/dashboard/src/core/fetcher.ts
  11. 72 2
      packages/dashboard/src/generated/graphql.tsx
  12. 3 0
      packages/dashboard/src/graphql/mutations/restart.graphql
  13. 3 0
      packages/dashboard/src/graphql/mutations/update.graphql
  14. 2 2
      packages/dashboard/src/hooks/useCachedRessources.ts
  15. 3 3
      packages/dashboard/src/modules/Apps/containers/AppDetails.tsx
  16. 6 3
      packages/dashboard/src/pages/_app.tsx
  17. 98 10
      packages/dashboard/src/pages/settings.tsx
  18. 11 1
      packages/dashboard/src/state/systemStore.ts
  19. 2 1
      packages/dashboard/tsconfig.json
  20. 2 0
      packages/system-api/package.json
  21. 6 8
      packages/system-api/src/config/logger/logger.ts
  22. 15 6
      packages/system-api/src/core/config/TipiConfig.ts
  23. 16 0
      packages/system-api/src/core/config/__tests__/TipiConfig.test.ts
  24. 217 0
      packages/system-api/src/modules/system/__tests__/system.resolver.test.ts
  25. 195 0
      packages/system-api/src/modules/system/__tests__/system.service.test.ts
  26. 12 0
      packages/system-api/src/modules/system/system.controller.ts
  27. 13 1
      packages/system-api/src/modules/system/system.resolver.ts
  28. 81 21
      packages/system-api/src/modules/system/system.service.ts
  29. 5 1
      packages/system-api/src/server.ts
  30. 4 0
      packages/system-api/src/test/mutations/index.ts
  31. 3 0
      packages/system-api/src/test/mutations/restart.graphql
  32. 3 0
      packages/system-api/src/test/mutations/update.graphql
  33. 4 0
      packages/system-api/src/test/queries/index.ts
  34. 17 0
      packages/system-api/src/test/queries/systemInfo.graphql
  35. 6 0
      packages/system-api/src/test/queries/version.graphql
  36. 7 4
      pnpm-lock.yaml
  37. 14 14
      scripts/start.sh
  38. 0 0
      scripts/system.sh
  39. 0 1
      templates/env-sample

+ 3 - 0
docker-compose.dev.yml

@@ -88,6 +88,9 @@ services:
       dockerfile: Dockerfile.dev
     command: /bin/sh -c "cd /dashboard && npm run dev"
     container_name: dashboard
+    depends_on:
+      api:
+        condition: service_started
     ports:
       - 3000:3000
     networks:

+ 3 - 0
docker-compose.rc.yml

@@ -89,6 +89,9 @@ services:
     container_name: dashboard
     networks:
       - tipi_main_network
+    depends_on:
+      api:
+        condition: service_started
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       NODE_ENV: production

+ 3 - 0
docker-compose.yml

@@ -90,6 +90,9 @@ services:
     container_name: dashboard
     networks:
       - tipi_main_network
+    depends_on:
+      api:
+        condition: service_started
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       NODE_ENV: production

+ 2 - 2
packages/dashboard/src/components/AppLogo/AppLogo.tsx

@@ -1,8 +1,8 @@
 import React from 'react';
-import { useSytemStore } from '../../state/systemStore';
+import { useSystemStore } from '../../state/systemStore';
 
 const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
-  const { baseUrl } = useSytemStore();
+  const { baseUrl } = useSystemStore();
   const logoUrl = `${baseUrl}/apps/${id}/metadata/logo.jpg`;
 
   return (

+ 0 - 1
packages/dashboard/src/components/Layout/Layout.tsx

@@ -6,7 +6,6 @@ import { FiChevronRight } from 'react-icons/fi';
 import Header from './Header';
 import Menu from './SideMenu';
 import MenuDrawer from './MenuDrawer';
-// import UpdateBanner from './UpdateBanner';
 
 interface IProps {
   loading?: boolean;

+ 14 - 0
packages/dashboard/src/components/StatusScreens/RestartingScreen.tsx

@@ -0,0 +1,14 @@
+import { Flex, Spinner, Text } from '@chakra-ui/react';
+import React from 'react';
+
+const RestartingScreen = () => {
+  return (
+    <Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
+      <Text fontSize="2xl">Your system is restarting...</Text>
+      <Text color="gray.500">Please do not refresh this page</Text>
+      <Spinner size="lg" className="mt-5" />
+    </Flex>
+  );
+};
+
+export default RestartingScreen;

+ 50 - 0
packages/dashboard/src/components/StatusScreens/StatusWrapper.tsx

@@ -0,0 +1,50 @@
+import { SlideFade } from '@chakra-ui/react';
+import React, { useEffect, useState } from 'react';
+import useSWR from 'swr';
+import { SystemStatus, useSystemStore } from '../../state/systemStore';
+import RestartingScreen from './RestartingScreen';
+import UpdatingScreen from './UpdatingScreen';
+
+interface IProps {
+  children: React.ReactNode;
+}
+
+const fetcher = (url: string) => fetch(url).then((res) => res.json());
+
+const StatusWrapper: React.FC<IProps> = ({ children }) => {
+  const [s, setS] = useState<SystemStatus>(SystemStatus.RUNNING);
+  const { baseUrl } = useSystemStore();
+  const { data } = useSWR(`${baseUrl}/status`, fetcher, { refreshInterval: 1000 });
+
+  useEffect(() => {
+    if (data?.status === SystemStatus.RUNNING) {
+      setS(SystemStatus.RUNNING);
+    }
+    if (data?.status === SystemStatus.RESTARTING) {
+      setS(SystemStatus.RESTARTING);
+    }
+    if (data?.status === SystemStatus.UPDATING) {
+      setS(SystemStatus.UPDATING);
+    }
+  }, [data?.status]);
+
+  if (s === SystemStatus.RESTARTING) {
+    return (
+      <SlideFade in>
+        <RestartingScreen />
+      </SlideFade>
+    );
+  }
+
+  if (s === SystemStatus.UPDATING) {
+    return (
+      <SlideFade in>
+        <UpdatingScreen />
+      </SlideFade>
+    );
+  }
+
+  return <>{children}</>;
+};
+
+export default StatusWrapper;

+ 14 - 0
packages/dashboard/src/components/StatusScreens/UpdatingScreen.tsx

@@ -0,0 +1,14 @@
+import { Text, Flex, Spinner } from '@chakra-ui/react';
+import React from 'react';
+
+const UpdatingScreen = () => {
+  return (
+    <Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
+      <Text fontSize="2xl">Your system is updating...</Text>
+      <Text color="gray.500">Please do not refresh this page</Text>
+      <Spinner size="lg" className="mt-5" />
+    </Flex>
+  );
+};
+
+export default UpdatingScreen;

+ 2 - 2
packages/dashboard/src/core/api.ts

@@ -1,5 +1,5 @@
 import axios, { Method } from 'axios';
-import { useSytemStore } from '../state/systemStore';
+import { useSystemStore } from '../state/systemStore';
 
 interface IFetchParams {
   endpoint: string;
@@ -11,7 +11,7 @@ interface IFetchParams {
 const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
   const { endpoint, method = 'GET', params, data } = fetchParams;
 
-  const { getState } = useSytemStore;
+  const { getState } = useSystemStore;
   const BASE_URL = getState().baseUrl;
 
   const response = await axios.request<T & { error?: string }>({

+ 2 - 2
packages/dashboard/src/core/fetcher.ts

@@ -1,9 +1,9 @@
 import { BareFetcher } from 'swr';
 import axios from 'axios';
-import { useSytemStore } from '../state/systemStore';
+import { useSystemStore } from '../state/systemStore';
 
 const fetcher: BareFetcher<any> = (url: string) => {
-  const { baseUrl } = useSytemStore.getState();
+  const { baseUrl } = useSystemStore.getState();
 
   return axios.get(url, { baseURL: baseUrl, withCredentials: true }).then((res) => res.data);
 };

+ 72 - 2
packages/dashboard/src/generated/graphql.tsx

@@ -23,7 +23,7 @@ export type App = {
   __typename?: 'App';
   config: Scalars['JSONObject'];
   createdAt: Scalars['DateTime'];
-  domain: Scalars['String'];
+  domain?: Maybe<Scalars['String']>;
   exposed: Scalars['Boolean'];
   id: Scalars['String'];
   info?: Maybe<AppInfo>;
@@ -137,9 +137,11 @@ export type Mutation = {
   login: UserResponse;
   logout: Scalars['Boolean'];
   register: UserResponse;
+  restart: Scalars['Boolean'];
   startApp: App;
   stopApp: App;
   uninstallApp: App;
+  update: Scalars['Boolean'];
   updateApp: App;
   updateAppConfig: App;
 };
@@ -251,6 +253,10 @@ export type RegisterMutationVariables = Exact<{
 
 export type RegisterMutation = { __typename?: 'Mutation'; register: { __typename?: 'UserResponse'; user?: { __typename?: 'User'; id: string } | null } };
 
+export type RestartMutationVariables = Exact<{ [key: string]: never }>;
+
+export type RestartMutation = { __typename?: 'Mutation'; restart: boolean };
+
 export type StartAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
@@ -269,6 +275,10 @@ export type UninstallAppMutationVariables = Exact<{
 
 export type UninstallAppMutation = { __typename?: 'Mutation'; uninstallApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
+export type UpdateMutationVariables = Exact<{ [key: string]: never }>;
+
+export type UpdateMutation = { __typename?: 'Mutation'; update: boolean };
+
 export type UpdateAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
@@ -294,7 +304,7 @@ export type GetAppQuery = {
     config: any;
     version?: number | null;
     exposed: boolean;
-    domain: string;
+    domain?: string | null;
     updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
     info?: {
       __typename?: 'AppInfo';
@@ -523,6 +533,36 @@ export function useRegisterMutation(baseOptions?: Apollo.MutationHookOptions<Reg
 export type RegisterMutationHookResult = ReturnType<typeof useRegisterMutation>;
 export type RegisterMutationResult = Apollo.MutationResult<RegisterMutation>;
 export type RegisterMutationOptions = Apollo.BaseMutationOptions<RegisterMutation, RegisterMutationVariables>;
+export const RestartDocument = gql`
+  mutation Restart {
+    restart
+  }
+`;
+export type RestartMutationFn = Apollo.MutationFunction<RestartMutation, RestartMutationVariables>;
+
+/**
+ * __useRestartMutation__
+ *
+ * To run a mutation, you first call `useRestartMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useRestartMutation` 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 [restartMutation, { data, loading, error }] = useRestartMutation({
+ *   variables: {
+ *   },
+ * });
+ */
+export function useRestartMutation(baseOptions?: Apollo.MutationHookOptions<RestartMutation, RestartMutationVariables>) {
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<RestartMutation, RestartMutationVariables>(RestartDocument, options);
+}
+export type RestartMutationHookResult = ReturnType<typeof useRestartMutation>;
+export type RestartMutationResult = Apollo.MutationResult<RestartMutation>;
+export type RestartMutationOptions = Apollo.BaseMutationOptions<RestartMutation, RestartMutationVariables>;
 export const StartAppDocument = gql`
   mutation StartApp($id: String!) {
     startApp(id: $id) {
@@ -628,6 +668,36 @@ export function useUninstallAppMutation(baseOptions?: Apollo.MutationHookOptions
 export type UninstallAppMutationHookResult = ReturnType<typeof useUninstallAppMutation>;
 export type UninstallAppMutationResult = Apollo.MutationResult<UninstallAppMutation>;
 export type UninstallAppMutationOptions = Apollo.BaseMutationOptions<UninstallAppMutation, UninstallAppMutationVariables>;
+export const UpdateDocument = gql`
+  mutation Update {
+    update
+  }
+`;
+export type UpdateMutationFn = Apollo.MutationFunction<UpdateMutation, UpdateMutationVariables>;
+
+/**
+ * __useUpdateMutation__
+ *
+ * To run a mutation, you first call `useUpdateMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useUpdateMutation` 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 [updateMutation, { data, loading, error }] = useUpdateMutation({
+ *   variables: {
+ *   },
+ * });
+ */
+export function useUpdateMutation(baseOptions?: Apollo.MutationHookOptions<UpdateMutation, UpdateMutationVariables>) {
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<UpdateMutation, UpdateMutationVariables>(UpdateDocument, options);
+}
+export type UpdateMutationHookResult = ReturnType<typeof useUpdateMutation>;
+export type UpdateMutationResult = Apollo.MutationResult<UpdateMutation>;
+export type UpdateMutationOptions = Apollo.BaseMutationOptions<UpdateMutation, UpdateMutationVariables>;
 export const UpdateAppDocument = gql`
   mutation UpdateApp($id: String!) {
     updateApp(id: $id) {

+ 3 - 0
packages/dashboard/src/graphql/mutations/restart.graphql

@@ -0,0 +1,3 @@
+mutation Restart {
+  restart
+}

+ 3 - 0
packages/dashboard/src/graphql/mutations/update.graphql

@@ -0,0 +1,3 @@
+mutation Update {
+  update
+}

+ 2 - 2
packages/dashboard/src/hooks/useCachedRessources.ts

@@ -1,7 +1,7 @@
 import { useEffect, useState } from 'react';
 import { ApolloClient } from '@apollo/client';
 import { createApolloClient } from '../core/apollo/client';
-import { useSytemStore } from '../state/systemStore';
+import { useSystemStore } from '../state/systemStore';
 
 interface IReturnProps {
   client?: ApolloClient<unknown>;
@@ -13,7 +13,7 @@ export default function useCachedResources(): IReturnProps {
   const domain = process.env.NEXT_PUBLIC_DOMAIN;
   const port = process.env.NEXT_PUBLIC_PORT;
 
-  const { baseUrl, setBaseUrl, setInternalIp, setDomain } = useSytemStore();
+  const { baseUrl, setBaseUrl, setInternalIp, setDomain } = useSystemStore();
   const [isLoadingComplete, setLoadingComplete] = useState(false);
   const [client, setClient] = useState<ApolloClient<unknown>>();
 

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

@@ -1,7 +1,7 @@
 import { SlideFade, Flex, Divider, useDisclosure, useToast } from '@chakra-ui/react';
 import React from 'react';
 import { FiExternalLink } from 'react-icons/fi';
-import { useSytemStore } from '../../../state/systemStore';
+import { useSystemStore } from '../../../state/systemStore';
 import AppActions from '../components/AppActions';
 import InstallModal from '../components/InstallModal';
 import StopModal from '../components/StopModal';
@@ -48,7 +48,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
 
   const updateAvailable = Number(app?.updateInfo?.current || 0) < Number(app?.updateInfo?.latest);
 
-  const { internalIp } = useSytemStore();
+  const { internalIp } = useSystemStore();
 
   const handleError = (error: unknown) => {
     if (error instanceof Error) {
@@ -207,7 +207,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
           app={info}
           config={app?.config}
           exposed={app?.exposed}
-          domain={app?.domain}
+          domain={app?.domain || ''}
         />
         <UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={newVersion} />
       </div>

+ 6 - 3
packages/dashboard/src/pages/_app.tsx

@@ -8,6 +8,7 @@ import AuthWrapper from '../modules/Auth/containers/AuthWrapper';
 import { ApolloProvider } from '@apollo/client';
 import useCachedResources from '../hooks/useCachedRessources';
 import Head from 'next/head';
+import StatusWrapper from '../components/StatusScreens/StatusWrapper';
 
 function MyApp({ Component, pageProps }: AppProps) {
   const { client } = useCachedResources();
@@ -22,9 +23,11 @@ function MyApp({ Component, pageProps }: AppProps) {
         <Head>
           <title>Tipi</title>
         </Head>
-        <AuthWrapper>
-          <Component {...pageProps} />
-        </AuthWrapper>
+        <StatusWrapper>
+          <AuthWrapper>
+            <Component {...pageProps} />
+          </AuthWrapper>
+        </StatusWrapper>
       </ChakraProvider>
     </ApolloProvider>
   );

+ 98 - 10
packages/dashboard/src/pages/settings.tsx

@@ -1,13 +1,37 @@
 import type { NextPage } from 'next';
-import { Text } from '@chakra-ui/react';
+import { AlertDialog, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, Button, Text, useDisclosure, useToast } from '@chakra-ui/react';
 import Layout from '../components/Layout';
-import { useVersionQuery } from '../generated/graphql';
+import { useLogoutMutation, useRestartMutation, useUpdateMutation, useVersionQuery } from '../generated/graphql';
+import { useRef, useState } from 'react';
+
+// Necessary to avoid flickering when initiating an update or restart
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
 
 const Settings: NextPage = () => {
-  const { data, loading } = useVersionQuery();
+  const toast = useToast();
+  const restartDisclosure = useDisclosure();
+  const updateDisclosure = useDisclosure();
+  const cancelRef = useRef<any>();
+  const [loading, setLoading] = useState(false);
+  const { data } = useVersionQuery();
 
+  const [restart] = useRestartMutation();
+  const [update] = useUpdateMutation();
+  const [logout] = useLogoutMutation({ refetchQueries: ['Me'] });
   const isLatest = data?.version.latest === data?.version.current;
 
+  const handleError = (error: unknown) => {
+    if (error instanceof Error) {
+      toast({
+        title: 'Error',
+        description: error.message,
+        status: 'error',
+        position: 'top',
+        isClosable: true,
+      });
+    }
+  };
+
   const renderUpdate = () => {
     if (isLatest) {
       return (
@@ -18,22 +42,86 @@ const Settings: NextPage = () => {
     }
 
     return (
-      <Text fontSize="md">
-        You are not using the latest version of Tipi. There is a new version ({data?.version.latest}) available. Visit{' '}
-        <a className="text-blue-600" target="_blank" rel="noreferrer" href={`https://github.com/meienberger/runtipi/releases/v${data?.version.latest}`}>
-          Github
-        </a>{' '}
-        for update instructions.
-      </Text>
+      <>
+        <Text fontSize="md">New version available</Text>
+        <Button onClick={updateDisclosure.onOpen} className="mr-2" colorScheme="green">
+          Update to {data?.version.latest}
+        </Button>
+      </>
     );
   };
 
+  const handleRestart = async () => {
+    setLoading(true);
+    try {
+      restart();
+      await wait(2000);
+      logout();
+    } catch (error) {
+      handleError(error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleUpdate = async () => {
+    setLoading(true);
+    try {
+      update();
+      await wait(2000);
+      logout();
+    } catch (error) {
+      handleError(error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
   return (
     <Layout loading={!data?.version && loading}>
       <Text fontSize="3xl" className="font-bold">
         Settings
       </Text>
       {renderUpdate()}
+      <Button onClick={restartDisclosure.onOpen} colorScheme="gray">
+        Restart
+      </Button>
+      <AlertDialog isOpen={restartDisclosure.isOpen} leastDestructiveRef={cancelRef} onClose={restartDisclosure.onClose}>
+        <AlertDialogOverlay>
+          <AlertDialogContent>
+            <AlertDialogHeader fontSize="lg" fontWeight="bold">
+              Restart Tipi
+            </AlertDialogHeader>
+            <AlertDialogBody>Would you like to restart your Tipi server?</AlertDialogBody>
+            <AlertDialogFooter>
+              <Button colorScheme="gray" ref={cancelRef} onClick={restartDisclosure.onClose}>
+                Cancel
+              </Button>
+              <Button colorScheme="red" isLoading={loading} onClick={handleRestart} ml={3}>
+                Restart
+              </Button>
+            </AlertDialogFooter>
+          </AlertDialogContent>
+        </AlertDialogOverlay>
+      </AlertDialog>
+      <AlertDialog isOpen={updateDisclosure.isOpen} leastDestructiveRef={cancelRef} onClose={updateDisclosure.onClose}>
+        <AlertDialogOverlay>
+          <AlertDialogContent>
+            <AlertDialogHeader fontSize="lg" fontWeight="bold">
+              Update Tipi
+            </AlertDialogHeader>
+            <AlertDialogBody>Would you like to update Tipi to the latest version?</AlertDialogBody>
+            <AlertDialogFooter>
+              <Button colorScheme="gray" ref={cancelRef} onClick={updateDisclosure.onClose}>
+                Cancel
+              </Button>
+              <Button colorScheme="green" isLoading={loading} onClick={handleUpdate} ml={3}>
+                Update
+              </Button>
+            </AlertDialogFooter>
+          </AlertDialogContent>
+        </AlertDialogOverlay>
+      </AlertDialog>
     </Layout>
   );
 };

+ 11 - 1
packages/dashboard/src/state/systemStore.ts

@@ -1,19 +1,29 @@
 import create from 'zustand';
 
+export enum SystemStatus {
+  RUNNING = 'RUNNING',
+  RESTARTING = 'RESTARTING',
+  UPDATING = 'UPDATING',
+}
+
 type Store = {
   baseUrl: string;
   internalIp: string;
   domain: string;
+  status: SystemStatus;
   setDomain: (domain?: string) => void;
   setBaseUrl: (url: string) => void;
   setInternalIp: (ip: string) => void;
+  setStatus: (status: SystemStatus) => void;
 };
 
-export const useSytemStore = create<Store>((set) => ({
+export const useSystemStore = create<Store>((set) => ({
   baseUrl: '',
   internalIp: '',
   domain: '',
+  status: SystemStatus.RUNNING,
   setDomain: (domain?: string) => set((state) => ({ ...state, domain: domain || '' })),
   setBaseUrl: (url: string) => set((state) => ({ ...state, baseUrl: url })),
   setInternalIp: (ip: string) => set((state) => ({ ...state, internalIp: ip })),
+  setStatus: (status: SystemStatus) => set((state) => ({ ...state, status })),
 }));

+ 2 - 1
packages/dashboard/tsconfig.json

@@ -14,7 +14,8 @@
     "isolatedModules": true,
     "jsx": "preserve",
     "incremental": true,
-    "strictNullChecks": true
+    "strictNullChecks": true,
+    "allowSyntheticDefaultImports": true
   },
   "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
   "exclude": ["node_modules"]

+ 2 - 0
packages/system-api/package.json

@@ -50,6 +50,7 @@
     "pg": "^8.7.3",
     "public-ip": "^5.0.0",
     "reflect-metadata": "^0.1.13",
+    "semver": "^7.3.7",
     "session-file-store": "^1.5.0",
     "systeminformation": "^5.11.9",
     "tcp-port-used": "^1.0.2",
@@ -75,6 +76,7 @@
     "@types/node": "17.0.31",
     "@types/node-cron": "^3.0.2",
     "@types/pg": "^8.6.5",
+    "@types/semver": "^7.3.12",
     "@types/session-file-store": "^1.2.2",
     "@types/tcp-port-used": "^1.0.1",
     "@types/validator": "^13.7.2",

+ 6 - 8
packages/system-api/src/config/logger/logger.ts

@@ -3,13 +3,11 @@ import path from 'path';
 import { createLogger, format, transports } from 'winston';
 import { getConfig } from '../../core/config/TipiConfig';
 
-const { logs, NODE_ENV } = getConfig();
-
 const { align, printf, timestamp, combine, colorize } = format;
 
 // Create the logs directory if it does not exist
-if (!fs.existsSync(logs.LOGS_FOLDER)) {
-  fs.mkdirSync(logs.LOGS_FOLDER);
+if (!fs.existsSync(getConfig().logs.LOGS_FOLDER)) {
+  fs.mkdirSync(getConfig().logs.LOGS_FOLDER);
 }
 
 /**
@@ -38,14 +36,14 @@ const Logger = createLogger({
     // - Write all logs error (and below) to `error.log`.
     //
     new transports.File({
-      filename: path.join(logs.LOGS_FOLDER, logs.LOGS_ERROR),
+      filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_ERROR),
       level: 'error',
     }),
     new transports.File({
-      filename: path.join(logs.LOGS_FOLDER, logs.LOGS_APP),
+      filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_APP),
     }),
   ],
-  exceptionHandlers: [new transports.File({ filename: path.join(logs.LOGS_FOLDER, logs.LOGS_ERROR) })],
+  exceptionHandlers: [new transports.File({ filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_ERROR) })],
 });
 
 //
@@ -61,4 +59,4 @@ const LoggerDev = createLogger({
   ],
 });
 
-export default NODE_ENV === 'production' ? Logger : LoggerDev;
+export default process.env.NODE_ENV === 'production' ? Logger : LoggerDev;

+ 15 - 6
packages/system-api/src/core/config/TipiConfig.ts

@@ -24,6 +24,7 @@ const {
 
 const configSchema = z.object({
   NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
+  status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
   logs: z.object({
     LOGS_FOLDER: z.string(),
     LOGS_APP: z.string(),
@@ -62,6 +63,7 @@ class Config {
       appsRepoUrl: APPS_REPO_URL,
       domain: DOMAIN,
       dnsIp: '9.9.9.9',
+      status: 'RUNNING',
     };
 
     const parsed = configSchema.parse({
@@ -83,7 +85,7 @@ class Config {
   }
 
   public applyJsonConfig() {
-    const fileConfig = readJsonFile('/state/settings.json');
+    const fileConfig = readJsonFile('/state/settings.json') || {};
 
     const parsed = configSchema.parse({
       ...this.config,
@@ -93,18 +95,25 @@ class Config {
     this.config = parsed;
   }
 
-  public setConfig(key: keyof typeof configSchema.shape, value: any) {
-    const newConf = { ...this.getConfig() };
+  public setConfig<T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile: boolean = false) {
+    const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
     newConf[key] = value;
 
     this.config = configSchema.parse(newConf);
 
-    fs.writeFileSync(`${this.config.rootFolder}/state/settings.json`, JSON.stringify(newConf));
+    if (writeFile) {
+      const currentJsonConf = readJsonFile('/state/settings.json') || {};
+      currentJsonConf[key] = value;
+      const partialConfig = configSchema.partial();
+      const parsed = partialConfig.parse(currentJsonConf);
+
+      fs.writeFileSync(`${this.config.rootFolder}/state/settings.json`, JSON.stringify(parsed));
+    }
   }
 }
 
-export const setConfig = (key: keyof typeof configSchema.shape, value: any) => {
-  Config.getInstance().setConfig(key, value);
+export const setConfig = <T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile: boolean = false) => {
+  Config.getInstance().setConfig(key, value, writeFile);
 };
 
 export const getConfig = () => Config.getInstance().getConfig();

+ 16 - 0
packages/system-api/src/core/config/__tests__/TipiConfig.test.ts

@@ -1,5 +1,6 @@
 import { faker } from '@faker-js/faker';
 import fs from 'fs-extra';
+import { readJsonFile } from '../../../modules/fs/fs.helpers';
 import { applyJsonConfig, getConfig, setConfig } from '../TipiConfig';
 
 jest.mock('fs-extra');
@@ -35,8 +36,23 @@ describe('Test: setConfig', () => {
   });
 
   it('Should not be able to set invalid NODE_ENV', () => {
+    // @ts-ignore
     expect(() => setConfig('NODE_ENV', 'invalid')).toThrow();
   });
+
+  it('Should write config to json file', () => {
+    const randomWord = faker.random.word();
+    setConfig('appsRepoUrl', randomWord, true);
+    const config = getConfig();
+
+    expect(config).toBeDefined();
+    expect(config.appsRepoUrl).toBe(randomWord);
+
+    const settingsJson = readJsonFile('/state/settings.json');
+
+    expect(settingsJson).toBeDefined();
+    expect(settingsJson.appsRepoUrl).toBe(randomWord);
+  });
 });
 
 describe('Test: applyJsonConfig', () => {

+ 217 - 0
packages/system-api/src/modules/system/__tests__/system.resolver.test.ts

@@ -0,0 +1,217 @@
+import { faker } from '@faker-js/faker';
+import axios from 'axios';
+import fs from 'fs-extra';
+import { DataSource } from 'typeorm';
+import TipiCache from '../../../config/TipiCache';
+import * as TipiConfig from '../../../core/config/TipiConfig';
+import { setConfig } from '../../../core/config/TipiConfig';
+import { setupConnection, teardownConnection } from '../../../test/connection';
+import { gcall } from '../../../test/gcall';
+import { restartMutation, updateMutation } from '../../../test/mutations';
+import { systemInfoQuery, versionQuery } from '../../../test/queries';
+import User from '../../auth/user.entity';
+import { createUser } from '../../auth/__tests__/user.factory';
+import { SystemInfoResponse } from '../system.types';
+
+jest.mock('fs-extra');
+jest.mock('axios');
+
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+  jest.restoreAllMocks();
+});
+
+let db: DataSource | null = null;
+const TEST_SUITE = 'systemresolver';
+beforeAll(async () => {
+  db = await setupConnection(TEST_SUITE);
+});
+
+afterAll(async () => {
+  await db?.destroy();
+  await teardownConnection(TEST_SUITE);
+});
+
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+  jest.restoreAllMocks();
+  await User.clear();
+});
+
+describe('Test: systemInfo', () => {
+  beforeEach(async () => {});
+
+  it('Should return correct system info from file', async () => {
+    const systemInfo = {
+      cpu: { load: 10 },
+      memory: { available: 100, total: 1000, used: 900 },
+      disk: { available: 100, total: 1000, used: 900 },
+    };
+
+    const MockFiles = {
+      '/runtipi/state/system-info.json': JSON.stringify(systemInfo),
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const { data } = await gcall<{ systemInfo: SystemInfoResponse }>({ source: systemInfoQuery });
+
+    expect(data?.systemInfo).toBeDefined();
+    expect(data?.systemInfo.cpu).toBeDefined();
+    expect(data?.systemInfo.cpu.load).toBe(systemInfo.cpu.load);
+    expect(data?.systemInfo.memory).toBeDefined();
+    expect(data?.systemInfo.memory.available).toBe(systemInfo.memory.available);
+    expect(data?.systemInfo.memory.total).toBe(systemInfo.memory.total);
+    expect(data?.systemInfo.memory.used).toBe(systemInfo.memory.used);
+    expect(data?.systemInfo.disk).toBeDefined();
+    expect(data?.systemInfo.disk.available).toBe(systemInfo.disk.available);
+    expect(data?.systemInfo.disk.total).toBe(systemInfo.disk.total);
+    expect(data?.systemInfo.disk.used).toBe(systemInfo.disk.used);
+  });
+
+  it('Should return 0 for missing values', async () => {
+    const systemInfo = {
+      cpu: {},
+      memory: {},
+      disk: {},
+    };
+
+    const MockFiles = {
+      '/runtipi/state/system-info.json': JSON.stringify(systemInfo),
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const { data } = await gcall<{ systemInfo: SystemInfoResponse }>({ source: systemInfoQuery });
+
+    expect(data?.systemInfo).toBeDefined();
+    expect(data?.systemInfo.cpu).toBeDefined();
+    expect(data?.systemInfo.cpu.load).toBe(0);
+    expect(data?.systemInfo.memory).toBeDefined();
+    expect(data?.systemInfo.memory.available).toBe(0);
+    expect(data?.systemInfo.memory.total).toBe(0);
+    expect(data?.systemInfo.memory.used).toBe(0);
+    expect(data?.systemInfo.disk).toBeDefined();
+    expect(data?.systemInfo.disk.available).toBe(0);
+    expect(data?.systemInfo.disk.total).toBe(0);
+    expect(data?.systemInfo.disk.used).toBe(0);
+  });
+});
+
+describe('Test: getVersion', () => {
+  const current = `${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}`;
+  const latest = `${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}`;
+  beforeEach(async () => {
+    jest.spyOn(axios, 'get').mockResolvedValue({
+      data: { name: `v${latest}` },
+    });
+    setConfig('version', current);
+  });
+
+  it('Should return correct version', async () => {
+    const { data } = await gcall<{ version: { current: string; latest?: string } }>({
+      source: versionQuery,
+    });
+
+    expect(data?.version).toBeDefined();
+    expect(data?.version.current).toBeDefined();
+    expect(data?.version.latest).toBeDefined();
+    expect(data?.version.current).toBe(current);
+    expect(data?.version.latest).toBe(latest);
+  });
+});
+
+describe('Test: restart', () => {
+  beforeEach(async () => {
+    setConfig('status', 'RUNNING');
+    setConfig('version', '1.0.0');
+    TipiCache.set('latestVersion', '1.0.1');
+  });
+
+  it('Should return true', async () => {
+    const user = await createUser();
+    const { data } = await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
+
+    expect(data?.restart).toBeDefined();
+    expect(data?.restart).toBe(true);
+  });
+
+  it("Should return an error if user doesn't exist", async () => {
+    const { data, errors } = await gcall<{ restart: boolean }>({
+      source: restartMutation,
+      userId: 1,
+    });
+
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.restart).toBeUndefined();
+  });
+
+  it('Should throw an error if no userId is not provided', async () => {
+    const { data, errors } = await gcall<{ restart: boolean }>({ source: restartMutation });
+
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.restart).toBeUndefined();
+  });
+
+  it('Should set app status to restarting', async () => {
+    const spy = jest.spyOn(TipiConfig, 'setConfig');
+
+    const user = await createUser();
+    await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
+
+    expect(spy).toHaveBeenCalledTimes(2);
+
+    expect(spy).toHaveBeenNthCalledWith(1, 'status', 'RESTARTING');
+    expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
+
+    spy.mockRestore();
+  });
+});
+
+describe('Test: update', () => {
+  beforeEach(async () => {
+    setConfig('status', 'RUNNING');
+    setConfig('version', '1.0.0');
+    TipiCache.set('latestVersion', '1.0.1');
+  });
+
+  it('Should return true', async () => {
+    const user = await createUser();
+    const { data } = await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
+
+    expect(data?.update).toBeDefined();
+    expect(data?.update).toBe(true);
+  });
+
+  it("Should return an error if user doesn't exist", async () => {
+    const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation, userId: 1 });
+
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.update).toBeUndefined();
+  });
+
+  it('Should throw an error if no userId is not provided', async () => {
+    const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation });
+
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.update).toBeUndefined();
+  });
+
+  it('Should set app status to updating', async () => {
+    const spy = jest.spyOn(TipiConfig, 'setConfig');
+
+    const user = await createUser();
+    await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
+
+    expect(spy).toHaveBeenCalledTimes(2);
+
+    expect(spy).toHaveBeenNthCalledWith(1, 'status', 'UPDATING');
+    expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
+
+    spy.mockRestore();
+  });
+});

+ 195 - 0
packages/system-api/src/modules/system/__tests__/system.service.test.ts

@@ -0,0 +1,195 @@
+import fs from 'fs-extra';
+import semver from 'semver';
+import childProcess from 'child_process';
+import axios from 'axios';
+import SystemService from '../system.service';
+import { faker } from '@faker-js/faker';
+import TipiCache from '../../../config/TipiCache';
+import { setConfig } from '../../../core/config/TipiConfig';
+import logger from '../../../config/logger/logger';
+
+jest.mock('fs-extra');
+jest.mock('child_process');
+jest.mock('axios');
+
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+});
+
+describe('Test: systemInfo', () => {
+  it('Should throw if system-info.json does not exist', () => {
+    try {
+      SystemService.systemInfo();
+    } catch (e) {
+      expect(e).toBeDefined();
+      // @ts-ignore
+      expect(e.message).toBe('Error parsing system info');
+    }
+  });
+
+  it('It should return system info', async () => {
+    const info = {
+      cpu: { load: 0.1 },
+      memory: { available: 1000, total: 2000, used: 1000 },
+      disk: { available: 1000, total: 2000, used: 1000 },
+    };
+
+    const MockFiles = {
+      '/runtipi/state/system-info.json': JSON.stringify(info),
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const systemInfo = SystemService.systemInfo();
+
+    expect(systemInfo).toBeDefined();
+    expect(systemInfo.cpu).toBeDefined();
+    expect(systemInfo.memory).toBeDefined();
+  });
+});
+
+describe('Test: getVersion', () => {
+  beforeEach(() => {
+    TipiCache.del('latestVersion');
+  });
+
+  afterAll(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('It should return version', async () => {
+    const spy = jest.spyOn(axios, 'get').mockResolvedValue({
+      data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
+    });
+    const version = await SystemService.getVersion();
+
+    expect(version).toBeDefined();
+    expect(version.current).toBeDefined();
+    expect(semver.valid(version.latest)).toBeTruthy();
+
+    spy.mockRestore();
+  });
+
+  it('Should return undefined for latest if request fails', async () => {
+    jest.spyOn(axios, 'get').mockImplementation(() => {
+      throw new Error('Error');
+    });
+
+    const version = await SystemService.getVersion();
+
+    expect(version).toBeDefined();
+    expect(version.current).toBeDefined();
+    expect(version.latest).toBeUndefined();
+  });
+
+  it('Should return cached version', async () => {
+    const spy = jest.spyOn(axios, 'get').mockResolvedValue({
+      data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
+    });
+    const version = await SystemService.getVersion();
+
+    expect(version).toBeDefined();
+    expect(version.current).toBeDefined();
+    expect(semver.valid(version.latest)).toBeTruthy();
+
+    const version2 = await SystemService.getVersion();
+
+    expect(version2.latest).toBe(version.latest);
+    expect(version2.current).toBeDefined();
+    expect(semver.valid(version2.latest)).toBeTruthy();
+
+    expect(spy).toHaveBeenCalledTimes(1);
+
+    spy.mockRestore();
+  });
+});
+
+describe('Test: restart', () => {
+  it('Should return true', async () => {
+    const restart = await SystemService.restart();
+
+    expect(restart).toBeTruthy();
+  });
+
+  it('Should log error if fails', async () => {
+    // @ts-ignore
+    const spy = jest.spyOn(childProcess, 'execFile').mockImplementation((_path, _args, _, cb) => {
+      // @ts-ignore
+      if (cb) cb('error', null, null);
+    });
+    const log = jest.spyOn(logger, 'error');
+
+    const restart = await SystemService.restart();
+
+    expect(restart).toBeTruthy();
+    expect(log).toHaveBeenCalledWith('Error restarting: error');
+
+    spy.mockRestore();
+  });
+});
+
+describe('Test: update', () => {
+  it('Should return true', async () => {
+    setConfig('version', '0.0.1');
+    TipiCache.set('latestVersion', '0.0.2');
+
+    const update = await SystemService.update();
+
+    expect(update).toBeTruthy();
+  });
+
+  it('Should throw an error if latest version is not set', async () => {
+    TipiCache.del('latestVersion');
+    const spy = jest.spyOn(axios, 'get').mockResolvedValue({
+      data: { name: null },
+    });
+
+    setConfig('version', '0.0.1');
+
+    await expect(SystemService.update()).rejects.toThrow('Could not get latest version');
+
+    spy.mockRestore();
+  });
+
+  it('Should throw if current version is higher than latest', async () => {
+    setConfig('version', '0.0.2');
+    TipiCache.set('latestVersion', '0.0.1');
+
+    await expect(SystemService.update()).rejects.toThrow('Current version is newer than latest version');
+  });
+
+  it('Should throw if current version is equal to latest', async () => {
+    setConfig('version', '0.0.1');
+    TipiCache.set('latestVersion', '0.0.1');
+
+    await expect(SystemService.update()).rejects.toThrow('Current version is already up to date');
+  });
+
+  it('Should throw an error if there is a major version difference', async () => {
+    setConfig('version', '0.0.1');
+    TipiCache.set('latestVersion', '1.0.0');
+
+    await expect(SystemService.update()).rejects.toThrow('The major version has changed. Please update manually');
+  });
+
+  it('Should log error if fails', async () => {
+    // @ts-ignore
+    const spy = jest.spyOn(childProcess, 'execFile').mockImplementation((_path, _args, _, cb) => {
+      // @ts-ignore
+      if (cb) cb('error', null, null);
+    });
+    const log = jest.spyOn(logger, 'error');
+
+    setConfig('version', '0.0.1');
+    TipiCache.set('latestVersion', '0.0.2');
+
+    const update = await SystemService.update();
+
+    expect(update).toBeTruthy();
+    expect(log).toHaveBeenCalledWith('Error updating: error');
+
+    spy.mockRestore();
+  });
+});

+ 12 - 0
packages/system-api/src/modules/system/system.controller.ts

@@ -0,0 +1,12 @@
+import { Request, Response } from 'express';
+import { getConfig } from '../../core/config/TipiConfig';
+
+const status = async (req: Request, res: Response) => {
+  res.status(200).json({
+    status: getConfig().status,
+  });
+};
+
+export default {
+  status,
+};

+ 13 - 1
packages/system-api/src/modules/system/system.resolver.ts

@@ -1,4 +1,4 @@
-import { Query, Resolver } from 'type-graphql';
+import { Authorized, Mutation, Query, Resolver } from 'type-graphql';
 import SystemService from './system.service';
 import { SystemInfoResponse, VersionResponse } from './system.types';
 
@@ -13,4 +13,16 @@ export default class AuthResolver {
   async version(): Promise<VersionResponse> {
     return SystemService.getVersion();
   }
+
+  @Authorized()
+  @Mutation(() => Boolean)
+  async restart(): Promise<boolean> {
+    return SystemService.restart();
+  }
+
+  @Authorized()
+  @Mutation(() => Boolean)
+  async update(): Promise<boolean> {
+    return SystemService.update();
+  }
 }

+ 81 - 21
packages/system-api/src/modules/system/system.service.ts

@@ -1,28 +1,37 @@
 import axios from 'axios';
+import z from 'zod';
+import semver from 'semver';
+import logger from '../../config/logger/logger';
 import TipiCache from '../../config/TipiCache';
-import { getConfig } from '../../core/config/TipiConfig';
-import { readJsonFile } from '../fs/fs.helpers';
-
-type SystemInfo = {
-  cpu: {
-    load: number;
-  };
-  disk: {
-    total: number;
-    used: number;
-    available: number;
-  };
-  memory: {
-    total: number;
-    available: number;
-    used: number;
-  };
-};
+import { getConfig, setConfig } from '../../core/config/TipiConfig';
+import { readJsonFile, runScript } from '../fs/fs.helpers';
+
+const systemInfoSchema = z.object({
+  cpu: z.object({
+    load: z.number().default(0),
+  }),
+  disk: z.object({
+    total: z.number().default(0),
+    used: z.number().default(0),
+    available: z.number().default(0),
+  }),
+  memory: z.object({
+    total: z.number().default(0),
+    available: z.number().default(0),
+    used: z.number().default(0),
+  }),
+});
 
-const systemInfo = (): SystemInfo => {
-  const info: SystemInfo = readJsonFile('/state/system-info.json');
+const systemInfo = (): z.infer<typeof systemInfoSchema> => {
+  const info = systemInfoSchema.safeParse(readJsonFile('/state/system-info.json'));
 
-  return info;
+  if (!info.success) {
+    logger.error('Error parsing system info');
+    logger.error(info.error);
+    throw new Error('Error parsing system info');
+  } else {
+    return info.data;
+  }
 };
 
 const getVersion = async (): Promise<{ current: string; latest?: string }> => {
@@ -40,13 +49,64 @@ const getVersion = async (): Promise<{ current: string; latest?: string }> => {
 
     return { current: getConfig().version, latest: version?.replace('v', '') };
   } catch (e) {
+    logger.error(e);
     return { current: getConfig().version, latest: undefined };
   }
 };
 
+const restart = async (): Promise<boolean> => {
+  setConfig('status', 'RESTARTING');
+
+  runScript('/scripts/system.sh', ['restart'], (err: string) => {
+    setConfig('status', 'RUNNING');
+    if (err) {
+      logger.error(`Error restarting: ${err}`);
+    }
+  });
+
+  setConfig('status', 'RUNNING');
+
+  return true;
+};
+
+const update = async (): Promise<boolean> => {
+  const { current, latest } = await getVersion();
+
+  if (!latest) {
+    throw new Error('Could not get latest version');
+  }
+
+  if (semver.gt(current, latest)) {
+    throw new Error('Current version is newer than latest version');
+  }
+
+  if (semver.eq(current, latest)) {
+    throw new Error('Current version is already up to date');
+  }
+
+  if (semver.major(current) !== semver.major(latest)) {
+    throw new Error('The major version has changed. Please update manually');
+  }
+
+  setConfig('status', 'UPDATING');
+
+  runScript('/scripts/system.sh', ['update'], (err: string) => {
+    setConfig('status', 'RUNNING');
+    if (err) {
+      logger.error(`Error updating: ${err}`);
+    }
+  });
+
+  setConfig('status', 'RUNNING');
+
+  return true;
+};
+
 const SystemService = {
   systemInfo,
   getVersion,
+  restart,
+  update,
 };
 
 export default SystemService;

+ 5 - 1
packages/system-api/src/server.ts

@@ -16,8 +16,9 @@ import { runUpdates } from './core/updates/run';
 import recover from './core/updates/recover-migrations';
 import { cloneRepo, updateRepo } from './helpers/repo-helpers';
 import startJobs from './core/jobs/jobs';
-import { applyJsonConfig, getConfig } from './core/config/TipiConfig';
+import { applyJsonConfig, getConfig, setConfig } from './core/config/TipiConfig';
 import { ZodError } from 'zod';
+import systemController from './modules/system/system.controller';
 
 let corsOptions = {
   credentials: true,
@@ -60,6 +61,7 @@ const main = async () => {
     app.use(express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}`));
     app.use(cors(corsOptions));
     app.use(getSessionMiddleware());
+    app.use('/status', systemController.status);
 
     await datasource.initialize();
 
@@ -94,6 +96,8 @@ const main = async () => {
       await cloneRepo(getConfig().appsRepoUrl);
       await updateRepo(getConfig().appsRepoUrl);
       startJobs();
+      setConfig('status', 'RUNNING');
+
       // Start apps
       appsService.startAllApps();
       logger.info(`Server running on port ${port} 🚀 Production => ${__prod__}`);

+ 4 - 0
packages/system-api/src/test/mutations/index.ts

@@ -10,6 +10,8 @@ import * as updateAppConfig from './updateAppConfig.graphql';
 import * as updateApp from './updateApp.graphql';
 import * as register from './register.graphql';
 import * as login from './login.graphql';
+import * as restart from './restart.graphql';
+import * as update from './update.graphql';
 
 export const installAppMutation = print(installApp);
 export const startAppMutation = print(startApp);
@@ -19,3 +21,5 @@ export const updateAppConfigMutation = print(updateAppConfig);
 export const updateAppMutation = print(updateApp);
 export const registerMutation = print(register);
 export const loginMutation = print(login);
+export const restartMutation = print(restart);
+export const updateMutation = print(update);

+ 3 - 0
packages/system-api/src/test/mutations/restart.graphql

@@ -0,0 +1,3 @@
+mutation {
+  restart
+}

+ 3 - 0
packages/system-api/src/test/mutations/update.graphql

@@ -0,0 +1,3 @@
+mutation {
+  update
+}

+ 4 - 0
packages/system-api/src/test/queries/index.ts

@@ -7,9 +7,13 @@ import * as getApp from './getApp.graphql';
 import * as InstalledApps from './installedApps.graphql';
 import * as Me from './me.graphql';
 import * as isConfigured from './isConfigured.graphql';
+import * as systemInfo from './systemInfo.graphql';
+import * as version from './version.graphql';
 
 export const listAppInfosQuery = print(listAppInfos);
 export const getAppQuery = print(getApp);
 export const InstalledAppsQuery = print(InstalledApps);
 export const MeQuery = print(Me);
 export const isConfiguredQuery = print(isConfigured);
+export const systemInfoQuery = print(systemInfo);
+export const versionQuery = print(version);

+ 17 - 0
packages/system-api/src/test/queries/systemInfo.graphql

@@ -0,0 +1,17 @@
+query {
+  systemInfo {
+    cpu {
+      load
+    }
+    memory {
+      total
+      available
+      used
+    }
+    disk {
+      total
+      available
+      used
+    }
+  }
+}

+ 6 - 0
packages/system-api/src/test/queries/version.graphql

@@ -0,0 +1,6 @@
+query {
+  version {
+    current
+    latest
+  }
+}

+ 7 - 4
pnpm-lock.yaml

@@ -142,6 +142,7 @@ importers:
       '@types/node': 17.0.31
       '@types/node-cron': ^3.0.2
       '@types/pg': ^8.6.5
+      '@types/semver': ^7.3.12
       '@types/session-file-store': ^1.2.2
       '@types/tcp-port-used': ^1.0.1
       '@types/validator': ^13.7.2
@@ -183,6 +184,7 @@ importers:
       public-ip: ^5.0.0
       reflect-metadata: ^0.1.13
       rimraf: ^3.0.2
+      semver: ^7.3.7
       session-file-store: ^1.5.0
       systeminformation: ^5.11.9
       tcp-port-used: ^1.0.2
@@ -220,6 +222,7 @@ importers:
       pg: 8.7.3
       public-ip: 5.0.0
       reflect-metadata: 0.1.13
+      semver: 7.3.7
       session-file-store: 1.5.0
       systeminformation: 5.11.14
       tcp-port-used: 1.0.2
@@ -244,6 +247,7 @@ importers:
       '@types/node': 17.0.31
       '@types/node-cron': 3.0.2
       '@types/pg': 8.6.5
+      '@types/semver': 7.3.12
       '@types/session-file-store': 1.2.2
       '@types/tcp-port-used': 1.0.1
       '@types/validator': 13.7.2
@@ -3957,9 +3961,8 @@ packages:
   /@types/scheduler/0.16.2:
     resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
 
-  /@types/semver/7.3.10:
-    resolution: {integrity: sha512-zsv3fsC7S84NN6nPK06u79oWgrPVd0NvOyqgghV1haPaFcVxIrP4DLomRwGAXk0ui4HZA7mOcSFL98sMVW9viw==}
-    dev: false
+  /@types/semver/7.3.12:
+    resolution: {integrity: sha512-WwA1MW0++RfXmCr12xeYOOC5baSC9mSb0ZqCquFzKhcoF4TvHu5MKOuXsncgZcpVFhB1pXd5hZmM0ryAoCp12A==}
 
   /@types/serve-static/1.13.10:
     resolution: {integrity: sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==}
@@ -12209,7 +12212,7 @@ packages:
     dependencies:
       '@types/glob': 7.2.0
       '@types/node': 17.0.31
-      '@types/semver': 7.3.10
+      '@types/semver': 7.3.12
       class-validator: 0.13.2
       glob: 7.2.0
       graphql: 15.8.0

+ 14 - 14
scripts/start.sh

@@ -7,7 +7,6 @@ set -e # Exit immediately if a command exits with a non-zero status.
 
 NGINX_PORT=80
 NGINX_PORT_SSL=443
-PROXY_PORT=8080
 DOMAIN=tipi.localhost
 
 # Check we are on linux
@@ -51,17 +50,6 @@ while [ -n "$1" ]; do # while loop starts
     fi
     shift
     ;;
-  --proxy-port)
-    proxy_port="$2"
-
-    if [[ "${proxy_port}" =~ ^[0-9]+$ ]]; then
-      PROXY_PORT="${proxy_port}"
-    else
-      echo "--proxy-port must be a number"
-      exit 1
-    fi
-    shift
-    ;;
   --domain)
     domain="$2"
 
@@ -206,6 +194,20 @@ if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
     REPO_ID=$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoId)
   fi
 
+  # If port is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" port)" != "null" ]]; then
+    NGINX_PORT=$(get_json_field "${STATE_FOLDER}/settings.json" port)
+  fi
+
+  # If sslPort is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)" != "null" ]]; then
+    NGINX_PORT_SSL=$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)
+  fi
+
+  # If listenIp is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)" != "null" ]]; then
+    INTERNAL_IP=$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)
+  fi
 fi
 
 echo "Creating .env file with the following values:"
@@ -213,7 +215,6 @@ echo "  DOMAIN=${DOMAIN}"
 echo "  INTERNAL_IP=${INTERNAL_IP}"
 echo "  NGINX_PORT=${NGINX_PORT}"
 echo "  NGINX_PORT_SSL=${NGINX_PORT_SSL}"
-echo "  PROXY_PORT=${PROXY_PORT}"
 echo "  DNS_IP=${DNS_IP}"
 echo "  ARCHITECTURE=${ARCHITECTURE}"
 echo "  TZ=${TZ}"
@@ -235,7 +236,6 @@ for template in ${ENV_FILE}; do
   sed -i "s/<architecture>/${ARCHITECTURE}/g" "${template}"
   sed -i "s/<nginx_port>/${NGINX_PORT}/g" "${template}"
   sed -i "s/<nginx_port_ssl>/${NGINX_PORT_SSL}/g" "${template}"
-  sed -i "s/<proxy_port>/${PROXY_PORT}/g" "${template}"
   sed -i "s/<postgres_password>/${POSTGRES_PASSWORD}/g" "${template}"
   sed -i "s/<apps_repo_id>/${REPO_ID}/g" "${template}"
   sed -i "s/<apps_repo_url>/${APPS_REPOSITORY_ESCAPED}/g" "${template}"

+ 0 - 0
scripts/utils.sh → scripts/system.sh


+ 0 - 1
templates/env-sample

@@ -12,6 +12,5 @@ JWT_SECRET=<jwt_secret>
 ROOT_FOLDER_HOST=<root_folder>
 NGINX_PORT=<nginx_port>
 NGINX_PORT_SSL=<nginx_port_ssl>
-PROXY_PORT=<proxy_port>
 POSTGRES_PASSWORD=<postgres_password>
 DOMAIN=<domain>