Merge pull request #216 from meienberger/feature/inapp-update
feat(settings): in app update and restart
This commit is contained in:
commit
b3611a4cb1
39 changed files with 912 additions and 84 deletions
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 }>({
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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
packages/dashboard/src/graphql/mutations/restart.graphql
Normal file
3
packages/dashboard/src/graphql/mutations/restart.graphql
Normal file
|
@ -0,0 +1,3 @@
|
|||
mutation Restart {
|
||||
restart
|
||||
}
|
3
packages/dashboard/src/graphql/mutations/update.graphql
Normal file
3
packages/dashboard/src/graphql/mutations/update.graphql
Normal file
|
@ -0,0 +1,3 @@
|
|||
mutation Update {
|
||||
update
|
||||
}
|
|
@ -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>>();
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 })),
|
||||
}));
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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
packages/system-api/src/modules/system/system.controller.ts
Normal file
12
packages/system-api/src/modules/system/system.controller.ts
Normal file
|
@ -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,
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
import { getConfig, setConfig } from '../../core/config/TipiConfig';
|
||||
import { readJsonFile, runScript } from '../fs/fs.helpers';
|
||||
|
||||
type SystemInfo = {
|
||||
cpu: {
|
||||
load: number;
|
||||
};
|
||||
disk: {
|
||||
total: number;
|
||||
used: number;
|
||||
available: number;
|
||||
};
|
||||
memory: {
|
||||
total: number;
|
||||
available: number;
|
||||
used: number;
|
||||
};
|
||||
};
|
||||
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;
|
||||
|
|
|
@ -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__}`);
|
||||
|
|
|
@ -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
packages/system-api/src/test/mutations/restart.graphql
Normal file
3
packages/system-api/src/test/mutations/restart.graphql
Normal file
|
@ -0,0 +1,3 @@
|
|||
mutation {
|
||||
restart
|
||||
}
|
3
packages/system-api/src/test/mutations/update.graphql
Normal file
3
packages/system-api/src/test/mutations/update.graphql
Normal file
|
@ -0,0 +1,3 @@
|
|||
mutation {
|
||||
update
|
||||
}
|
|
@ -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
packages/system-api/src/test/queries/systemInfo.graphql
Normal file
17
packages/system-api/src/test/queries/systemInfo.graphql
Normal file
|
@ -0,0 +1,17 @@
|
|||
query {
|
||||
systemInfo {
|
||||
cpu {
|
||||
load
|
||||
}
|
||||
memory {
|
||||
total
|
||||
available
|
||||
used
|
||||
}
|
||||
disk {
|
||||
total
|
||||
available
|
||||
used
|
||||
}
|
||||
}
|
||||
}
|
6
packages/system-api/src/test/queries/version.graphql
Normal file
6
packages/system-api/src/test/queries/version.graphql
Normal file
|
@ -0,0 +1,6 @@
|
|||
query {
|
||||
version {
|
||||
current
|
||||
latest
|
||||
}
|
||||
}
|
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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>
|
Loading…
Add table
Reference in a new issue