feat(settings): in app update and restart

This commit is contained in:
Nicolas Meienberger 2022-09-25 00:06:13 +02:00
parent 3a472d7097
commit aa518c660f
32 changed files with 872 additions and 416 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }>({

View file

@ -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);
};

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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>>();

View file

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

View file

@ -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>
);

View file

@ -1,13 +1,34 @@
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';
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 +39,90 @@ 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 {
setTimeout(() => {
logout();
}, 2000);
await restart();
} catch (error) {
handleError(error);
} finally {
setLoading(false);
}
};
const handleUpdate = async () => {
setLoading(true);
try {
setTimeout(() => {
logout();
}, 2000);
await update();
} 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>
);
};

View file

@ -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 })),
}));

View file

@ -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"]

View file

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

View file

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

View file

@ -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();

View file

@ -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', () => {

View file

@ -0,0 +1,124 @@
import fs from 'fs-extra';
import semver from 'semver';
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';
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();
});
});
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();
});
});

View 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,
};

View file

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

View file

@ -1,28 +1,39 @@
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(),
}),
disk: z.object({
total: z.number(),
used: z.number(),
available: z.number(),
}),
memory: z.object({
total: z.number(),
available: z.number(),
used: z.number(),
}),
});
const systemInfo = (): SystemInfo => {
const info: SystemInfo = readJsonFile('/state/system-info.json');
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
return info;
const systemInfo = (): z.infer<typeof systemInfoSchema> => {
const info = systemInfoSchema.safeParse(readJsonFile('/state/system-info.json'));
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 +51,65 @@ 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');
await wait(2000);
runScript('/scripts/system.sh', ['restart'], (err: string) => {
setConfig('status', 'RUNNING');
if (err) {
logger.error(`Error restarting: ${err}`);
}
});
return true;
};
const update = async (): Promise<boolean> => {
const { current, latest } = await getVersion();
console.log(current, latest);
await wait(2000);
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}`);
}
});
return true;
};
const SystemService = {
systemInfo,
getVersion,
restart,
update,
};
export default SystemService;

View file

@ -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__}`);

11
pnpm-lock.yaml generated
View file

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

View file

@ -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}"

View file

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