feat: setup trpc and create system router

This commit is contained in:
Nicolas Meienberger 2022-12-26 04:31:44 +01:00 committed by Nicolas Meienberger
parent 34e6ff33e1
commit d4f507ced3
26 changed files with 1399 additions and 21 deletions

View file

@ -86,13 +86,13 @@ services:
labels:
traefik.enable: true
# Web
traefik.http.routers.api.rule: PathPrefix(`/api`)
traefik.http.routers.api.rule: PathPrefix(`/api-legacy`)
traefik.http.routers.api.service: api
traefik.http.routers.api.entrypoints: web
traefik.http.routers.api.middlewares: api-stripprefix
traefik.http.services.api.loadbalancer.server.port: 3001
# Middlewares
traefik.http.middlewares.api-stripprefix.stripprefix.prefixes: /api
traefik.http.middlewares.api-stripprefix.stripprefix.prefixes: /api-legacy
dashboard:
build:
@ -103,12 +103,27 @@ services:
depends_on:
api:
condition: service_started
environment:
INTERNAL_IP: ${INTERNAL_IP}
TIPI_VERSION: ${TIPI_VERSION}
JWT_SECRET: ${JWT_SECRET}
NGINX_PORT: ${NGINX_PORT}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USERNAME: tipi
POSTGRES_DBNAME: tipi
POSTGRES_HOST: tipi-db
APPS_REPO_ID: ${APPS_REPO_ID}
APPS_REPO_URL: ${APPS_REPO_URL}
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
networks:
- tipi_main_network
volumes:
- ${PWD}/packages/dashboard/src:/dashboard/src
# - /dashboard/node_modules
# - /dashboard/.next
- ${PWD}/state:/runtipi/state
- ${PWD}/logs:/app/logs
labels:
traefik.enable: true
# Web
@ -126,4 +141,4 @@ networks:
- subnet: 10.21.21.0/24
volumes:
pgdata:
pgdata:

View file

@ -1,8 +1,8 @@
overwrite: true
schema: "http://localhost:3000/api/graphql"
documents: "src/graphql/**/*.graphql"
schema: "http://localhost:3000/api-legacy/graphql"
documents: "src/client/graphql/**/*.graphql"
generates:
src/generated/graphql.tsx:
src/client/generated/graphql.tsx:
plugins:
- "typescript"
- "typescript-operations"

View file

@ -17,10 +17,17 @@
"@hookform/resolvers": "^2.9.10",
"@tabler/core": "1.0.0-beta16",
"@tabler/icons": "^1.109.0",
"@tanstack/react-query": "^4.20.4",
"@trpc/client": "^10.5.0",
"@trpc/next": "^10.5.0",
"@trpc/react-query": "^10.5.0",
"@trpc/server": "^10.5.0",
"clsx": "^1.1.1",
"fs-extra": "^10.1.0",
"graphql": "^15.8.0",
"graphql-tag": "^2.12.6",
"isomorphic-fetch": "^3.0.0",
"jsonwebtoken": "^9.0.0",
"next": "13.0.3",
"react": "18.2.0",
"react-dom": "18.2.0",
@ -28,15 +35,18 @@
"react-markdown": "^8.0.3",
"react-select": "^5.6.1",
"react-tooltip": "^4.4.3",
"redis": "^4.3.1",
"remark-breaks": "^3.0.2",
"remark-gfm": "^3.0.1",
"remark-mdx": "^2.1.1",
"sass": "^1.55.0",
"semver": "^7.3.7",
"sharp": "0.30.7",
"superjson": "^1.12.0",
"swr": "^1.3.0",
"tslib": "^2.4.0",
"validator": "^13.7.0",
"winston": "^3.7.2",
"zod": "^3.19.1",
"zustand": "^3.7.2"
},
@ -51,8 +61,10 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/fs-extra": "^9.0.13",
"@types/isomorphic-fetch": "^0.0.36",
"@types/jest": "^27.5.0",
"@types/jsonwebtoken": "^8.5.9",
"@types/node": "17.0.31",
"@types/react": "18.0.8",
"@types/react-dom": "18.0.3",

View file

@ -4,11 +4,7 @@ import { getUrl } from '../../core/helpers/url-helpers';
import styles from './AppLogo.module.scss';
export const AppLogo: React.FC<{ id?: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
let logoUrl = id ? `/api/apps/${id}/metadata/logo.jpg` : getUrl('placeholder.png');
if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
logoUrl = getUrl('placeholder.png');
}
const logoUrl = id ? `/api-legacy/apps/${id}/metadata/logo.jpg` : getUrl('placeholder.png');
return (
<div aria-label={alt} className={clsx(styles.dropShadow, className)} style={{ width: size, height: size }}>

View file

@ -1,7 +1,7 @@
import { HttpLink } from '@apollo/client';
const httpLink = new HttpLink({
uri: '/api/graphql',
uri: '/api-legacy/graphql',
});
export default httpLink;

View file

@ -0,0 +1,9 @@
import * as trpcNext from '@trpc/server/adapters/next';
import { createContext } from '../../../server/context';
import { appRouter } from '../../../server/routers/_app';
// export API handler
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext,
});

View file

@ -0,0 +1,11 @@
import fs from 'fs-extra';
export const readJsonFile = (path: string): unknown | null => {
try {
const rawFile = fs.readFileSync(path).toString();
return JSON.parse(rawFile);
} catch (e) {
return null;
}
};

View file

@ -0,0 +1,27 @@
import { type GetServerSidePropsContext } from 'next';
import jwt from 'jsonwebtoken';
import { getConfig } from '../core/TipiConfig';
import TipiCache from '../core/TipiCache';
export const getServerAuthSession = async (ctx: { req: GetServerSidePropsContext['req']; res: GetServerSidePropsContext['res'] }) => {
const { req } = ctx;
const token = req.headers.authorization?.split(' ')[1];
if (token) {
try {
const decodedToken = jwt.verify(token, getConfig().jwtSecret) as { id: number; session: string };
const userId = await TipiCache.get(decodedToken.session);
if (userId === decodedToken.id.toString()) {
return {
userId: decodedToken.id,
id: decodedToken.session,
};
}
} catch (err) {
console.error(err);
}
}
return null;
};

View file

@ -0,0 +1,38 @@
import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getServerAuthSession } from './common/get-server-auth-session';
type Session = {
userId?: number;
id?: string;
};
type CreateContextOptions = {
session: Session | null;
};
/** Use this helper for:
* - testing, so we dont have to mock Next.js' req/res
* - trpc's `createSSGHelpers` where we don't have req/res
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
* */
export const createContextInner = async (opts: CreateContextOptions) => ({
session: opts.session,
});
/**
* This is the actual context you'll use in your router
* @link https://trpc.io/docs/context
* */
export const createContext = async (opts: CreateNextContextOptions) => {
const { req, res } = opts;
// Get the session from the server using the unstable_getServerSession wrapper function
const session = await getServerAuthSession({ req, res });
return createContextInner({
session,
});
};
export type Context = inferAsyncReturnType<typeof createContext>;

View file

@ -0,0 +1,200 @@
/**
* @jest-environment node
*/
import fs from 'fs-extra';
import { EventDispatcher, EventTypes } from '.';
const WATCH_FILE = '/runtipi/state/events';
jest.mock('fs-extra');
// eslint-disable-next-line no-promise-executor-return
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
beforeEach(() => {
EventDispatcher.clear();
fs.writeFileSync(WATCH_FILE, '');
fs.writeFileSync('/app/logs/123.log', 'test');
});
describe('EventDispatcher - dispatchEvent', () => {
it('should dispatch an event', () => {
const event = EventDispatcher.dispatchEvent(EventTypes.APP);
expect(event.id).toBeDefined();
});
it('should dispatch an event with args', () => {
const event = EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
expect(event.id).toBeDefined();
});
it('Should put events into queue', async () => {
EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
// @ts-expect-error - private method
const { queue } = EventDispatcher;
expect(queue.length).toBe(2);
});
it('Should put first event into lock after 1 sec', async () => {
EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
EventDispatcher.dispatchEvent(EventTypes.UPDATE, ['--help']);
// @ts-expect-error - private method
const { queue } = EventDispatcher;
await wait(1050);
// @ts-expect-error - private method
const { lock } = EventDispatcher;
expect(queue.length).toBe(2);
expect(lock).toBeDefined();
expect(lock?.type).toBe(EventTypes.APP);
});
it('Should clear event once its status is success', async () => {
// @ts-expect-error - private method
jest.spyOn(EventDispatcher, 'getEventStatus').mockReturnValueOnce('success');
EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
await wait(1050);
// @ts-expect-error - private method
const { queue } = EventDispatcher;
expect(queue.length).toBe(0);
});
it('Should clear event once its status is error', async () => {
// @ts-expect-error - private method
jest.spyOn(EventDispatcher, 'getEventStatus').mockReturnValueOnce('error');
EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
await wait(1050);
// @ts-expect-error - private method
const { queue } = EventDispatcher;
expect(queue.length).toBe(0);
});
});
describe('EventDispatcher - dispatchEventAsync', () => {
it('Should dispatch an event and wait for it to finish', async () => {
// @ts-expect-error - private method
jest.spyOn(EventDispatcher, 'getEventStatus').mockReturnValueOnce('success');
const { success } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['--help']);
expect(success).toBe(true);
});
it('Should dispatch an event and wait for it to finish with error', async () => {
// @ts-expect-error - private method
jest.spyOn(EventDispatcher, 'getEventStatus').mockReturnValueOnce('error');
const { success } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['--help']);
expect(success).toBe(false);
});
});
describe('EventDispatcher - runEvent', () => {
it('Should do nothing if there is a lock', async () => {
// @ts-expect-error - private method
EventDispatcher.lock = { id: '123', type: EventTypes.APP, args: [] };
// @ts-expect-error - private method
await EventDispatcher.runEvent();
const file = fs.readFileSync(WATCH_FILE, 'utf8');
expect(file).toBe('');
});
it('Should do nothing if there is no event in queue', async () => {
// @ts-expect-error - private method
await EventDispatcher.runEvent();
const file = fs.readFileSync(WATCH_FILE, 'utf8');
expect(file).toBe('');
});
});
describe('EventDispatcher - getEventStatus', () => {
it('Should return success if event is not in the queue', async () => {
// @ts-expect-error - private method
EventDispatcher.queue = [];
// @ts-expect-error - private method
const status = EventDispatcher.getEventStatus('123');
expect(status).toBe('success');
});
it('Should return error if event is expired', async () => {
const dateFiveMinutesAgo = new Date(new Date().getTime() - 5 * 60 * 10000);
// @ts-expect-error - private method
EventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: dateFiveMinutesAgo }];
// @ts-expect-error - private method
const status = EventDispatcher.getEventStatus('123');
expect(status).toBe('error');
});
it('Should be waiting if line is not found in the file', async () => {
// @ts-expect-error - private method
EventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: new Date() }];
// @ts-expect-error - private method
const status = EventDispatcher.getEventStatus('123');
expect(status).toBe('waiting');
});
});
describe('EventDispatcher - clearEvent', () => {
it('Should clear event', async () => {
const event = { id: '123', type: EventTypes.APP, args: [], creationDate: new Date() };
// @ts-expect-error - private method
EventDispatcher.queue = [event];
// @ts-expect-error - private method
EventDispatcher.clearEvent(event);
// @ts-expect-error - private method
const { queue } = EventDispatcher;
expect(queue.length).toBe(0);
});
});
describe('EventDispatcher - pollQueue', () => {
it('Should not create a new interval if one already exists', async () => {
// @ts-expect-error - private method
EventDispatcher.interval = 123;
// @ts-expect-error - private method
const id = EventDispatcher.pollQueue();
// @ts-expect-error - private method
const { interval } = EventDispatcher;
expect(interval).toBe(123);
expect(id).toBe(123);
clearInterval(interval);
clearInterval(id);
});
});
describe('EventDispatcher - collectLockStatusAndClean', () => {
it('Should do nothing if there is no lock', async () => {
// @ts-expect-error - private method
EventDispatcher.lock = null;
// @ts-expect-error - private method
EventDispatcher.collectLockStatusAndClean();
// @ts-expect-error - private method
const { lock } = EventDispatcher;
expect(lock).toBeNull();
});
});

View file

@ -0,0 +1,225 @@
import fs from 'fs-extra';
import { Logger } from '../Logger';
export enum EventTypes {
// System events
RESTART = 'restart',
UPDATE = 'update',
CLONE_REPO = 'clone_repo',
UPDATE_REPO = 'update_repo',
APP = 'app',
SYSTEM_INFO = 'system_info',
}
type SystemEvent = {
id: string;
type: EventTypes;
args: string[];
creationDate: Date;
};
type EventStatusTypes = 'running' | 'success' | 'error' | 'waiting';
const WATCH_FILE = '/runtipi/state/events';
// File state example:
// restart 1631231231231 running "arg1 arg2"
class EventDispatcher {
private static instance: EventDispatcher | null;
private queue: SystemEvent[] = [];
private lock: SystemEvent | null = null;
private interval: NodeJS.Timer;
private intervals: NodeJS.Timer[] = [];
constructor() {
const timer = this.pollQueue();
this.interval = timer;
}
public static getInstance(): EventDispatcher {
if (!EventDispatcher.instance) {
EventDispatcher.instance = new EventDispatcher();
}
return EventDispatcher.instance;
}
/**
* Generate a random task id
* @returns - Random id
*/
static generateId() {
return Math.random().toString(36).substring(2, 9);
}
/**
* Collect lock status and clean queue if event is done
*/
private collectLockStatusAndClean() {
if (!this.lock) {
return;
}
const status = this.getEventStatus(this.lock.id);
if (status === 'running' || status === 'waiting') {
return;
}
this.clearEvent(this.lock, status);
this.lock = null;
}
/**
* Poll queue and run events
*/
private pollQueue() {
Logger.info('EventDispatcher: Polling queue...');
if (!this.interval) {
const id = setInterval(() => {
this.runEvent();
this.collectLockStatusAndClean();
}, 1000);
this.intervals.push(id);
return id;
}
return this.interval;
}
/**
* Run event from the queue if there is no lock
*/
private async runEvent() {
if (this.lock) {
return;
}
const event = this.queue[0];
if (!event) {
return;
}
this.lock = event;
// Write event to state file
const args = event.args.join(' ');
const line = `${event.type} ${event.id} waiting ${args}`;
fs.writeFileSync(WATCH_FILE, `${line}`);
}
/**
* Check event status
* @param id - Event id
* @returns - Event status
*/
private getEventStatus(id: string): EventStatusTypes {
const event = this.queue.find((e) => e.id === id);
if (!event) {
return 'success';
}
// if event was created more than 3 minutes ago, it's an error
if (new Date().getTime() - event.creationDate.getTime() > 5 * 60 * 1000) {
return 'error';
}
const file = fs.readFileSync(WATCH_FILE, 'utf8');
const lines = file?.split('\n') || [];
const line = lines.find((l) => l.startsWith(`${event.type} ${event.id}`));
if (!line) {
return 'waiting';
}
const status = line.split(' ')[2] as EventStatusTypes;
return status;
}
/**
* Dispatch an event to the queue
* @param type - Event type
* @param args - Event arguments
* @returns - Event object
*/
public dispatchEvent(type: EventTypes, args?: string[]): SystemEvent {
const event: SystemEvent = {
id: EventDispatcher.generateId(),
type,
args: args || [],
creationDate: new Date(),
};
this.queue.push(event);
return event;
}
/**
* Clear event from queue
* @param id - Event id
*/
private clearEvent(event: SystemEvent, status: EventStatusTypes = 'success') {
this.queue = this.queue.filter((e) => e.id !== event.id);
if (fs.existsSync(`/app/logs/${event.id}.log`)) {
const log = fs.readFileSync(`/app/logs/${event.id}.log`, 'utf8');
if (log && status === 'error') {
Logger.error(`EventDispatcher: ${event.type} ${event.id} failed with error: ${log}`);
} else if (log) {
Logger.info(`EventDispatcher: ${event.type} ${event.id} finished with message: ${log}`);
}
fs.unlinkSync(`/app/logs/${event.id}.log`);
}
fs.writeFileSync(WATCH_FILE, '');
}
/**
* Dispatch an event to the queue and wait for it to finish
* @param type - Event type
* @param args - Event arguments
* @returns - Promise that resolves when the event is done
*/
public async dispatchEventAsync(type: EventTypes, args?: string[]): Promise<{ success: boolean; stdout?: string }> {
const event = this.dispatchEvent(type, args);
return new Promise((resolve) => {
const interval = setInterval(() => {
this.intervals.push(interval);
const status = this.getEventStatus(event.id);
let log = '';
if (fs.existsSync(`/app/logs/${event.id}.log`)) {
log = fs.readFileSync(`/app/logs/${event.id}.log`, 'utf8');
}
if (status === 'success') {
clearInterval(interval);
resolve({ success: true, stdout: log });
} else if (status === 'error') {
clearInterval(interval);
resolve({ success: false, stdout: log });
}
}, 100);
});
}
public clearInterval() {
clearInterval(this.interval);
this.intervals.forEach((i) => clearInterval(i));
}
public clear() {
this.queue = [];
this.lock = null;
EventDispatcher.instance = null;
fs.writeFileSync(WATCH_FILE, '');
}
}
export const EventDispatcherInstance = EventDispatcher.getInstance();

View file

@ -0,0 +1,2 @@
export { EventDispatcherInstance as EventDispatcher } from './EventDispatcher';
export { EventTypes } from './EventDispatcher';

View file

@ -0,0 +1,62 @@
import fs from 'fs-extra';
import path from 'path';
import { createLogger, format, transports } from 'winston';
import { getConfig } from '../TipiConfig';
const { align, printf, timestamp, combine, colorize } = format;
/**
* Production logger format
*/
const combinedLogFormat = combine(
timestamp(),
printf((info) => `${info.timestamp} > ${info.message}`),
);
/**
* Development logger format
*/
const combinedLogFormatDev = combine(
colorize(),
align(),
printf((info) => `${info.level}: ${info.message}`),
);
const productionLogger = () => {
if (!fs.existsSync(getConfig().logs.LOGS_FOLDER)) {
fs.mkdirSync(getConfig().logs.LOGS_FOLDER);
}
return createLogger({
level: 'info',
format: combinedLogFormat,
transports: [
//
// - Write to all logs with level `info` and below to `app.log`
// - Write all logs error (and below) to `error.log`.
//
new transports.File({
filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_ERROR),
level: 'error',
}),
new transports.File({
filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_APP),
}),
],
exceptionHandlers: [new transports.File({ filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_ERROR) })],
});
};
//
// If we're not in production then log to the `console
//
const LoggerDev = createLogger({
level: 'debug',
format: combinedLogFormatDev,
transports: [
new transports.Console({
level: 'debug',
}),
],
});
export default process.env.NODE_ENV === 'production' ? productionLogger() : LoggerDev;

View file

@ -0,0 +1 @@
export { default as Logger } from './Logger';

View file

@ -0,0 +1,61 @@
import { createClient, RedisClientType } from 'redis';
import { getConfig } from '../TipiConfig';
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
class TipiCache {
private static instance: TipiCache;
private client: RedisClientType;
constructor() {
const client = createClient({
url: `redis://${getConfig().REDIS_HOST}:6379`,
});
this.client = client as RedisClientType;
}
public static getInstance(): TipiCache {
if (!TipiCache.instance) {
TipiCache.instance = new TipiCache();
}
return TipiCache.instance;
}
private async getClient(): Promise<RedisClientType> {
if (!this.client.isOpen) {
await this.client.connect();
}
return this.client;
}
public async set(key: string, value: string, expiration = ONE_DAY_IN_SECONDS) {
const client = await this.getClient();
return client.set(key, value, {
EX: expiration,
});
}
public async get(key: string) {
const client = await this.getClient();
return client.get(key);
}
public async del(key: string) {
const client = await this.getClient();
return client.del(key);
}
public async close() {
return this.client.quit();
}
public async ttl(key: string) {
const client = await this.getClient();
return client.ttl(key);
}
}
export default TipiCache.getInstance();

View file

@ -0,0 +1 @@
export { default } from './TipiCache';

View file

@ -0,0 +1,98 @@
import { faker } from '@faker-js/faker';
import fs from 'fs-extra';
import { getConfig, setConfig, TipiConfig } from '.';
import { readJsonFile } from '../../common/fs.helpers';
jest.mock('fs-extra');
beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
// @ts-expect-error - We are mocking fs
fs.__resetAllMocks();
});
describe('Test: getConfig', () => {
it('It should return config from .env', () => {
const config = getConfig();
expect(config).toBeDefined();
expect(config.NODE_ENV).toBe('test');
expect(config.logs.LOGS_FOLDER).toBe('/app/logs');
expect(config.logs.LOGS_APP).toBe('app.log');
expect(config.logs.LOGS_ERROR).toBe('error.log');
expect(config.dnsIp).toBe('9.9.9.9');
expect(config.rootFolder).toBe('/runtipi');
expect(config.internalIp).toBe('localhost');
});
it('It should overrides config from settings.json file', () => {
const settingsJson = {
appsRepoUrl: faker.random.word(),
appsRepoId: faker.random.word(),
domain: faker.random.word(),
};
const MockFiles = {
'/runtipi/state/settings.json': JSON.stringify(settingsJson),
};
// @ts-expect-error - We are mocking fs
fs.__createMockFiles(MockFiles);
const config = new TipiConfig().getConfig();
expect(config).toBeDefined();
expect(config.appsRepoUrl).toBe(settingsJson.appsRepoUrl);
expect(config.appsRepoId).toBe(settingsJson.appsRepoId);
expect(config.domain).toBe(settingsJson.domain);
});
it('Should not be able to apply an invalid value from json config', () => {
const settingsJson = {
appsRepoUrl: faker.random.word(),
appsRepoId: faker.random.word(),
domain: 10,
};
const MockFiles = {
'/runtipi/state/settings.json': JSON.stringify(settingsJson),
};
// @ts-expect-error - We are mocking fs
fs.__createMockFiles(MockFiles);
expect(() => new TipiConfig().getConfig()).toThrow();
});
});
describe('Test: setConfig', () => {
it('It should be able set config', () => {
const randomWord = faker.random.word();
setConfig('appsRepoUrl', randomWord);
const config = getConfig();
expect(config).toBeDefined();
expect(config.appsRepoUrl).toBe(randomWord);
});
it('Should not be able to set invalid NODE_ENV', () => {
// @ts-expect-error - We are testing invalid NODE_ENV
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('/runtipi/state/settings.json') as { [key: string]: string };
expect(settingsJson).toBeDefined();
expect(settingsJson.appsRepoUrl).toBe(randomWord);
});
});

View file

@ -0,0 +1,137 @@
import { z } from 'zod';
import fs from 'fs-extra';
import nextConfig from 'next/config';
import { readJsonFile } from '../../common/fs.helpers';
import { Logger } from '../Logger';
enum AppSupportedArchitecturesEnum {
ARM = 'arm',
ARM64 = 'arm64',
AMD64 = 'amd64',
}
const { serverRuntimeConfig } = nextConfig();
const {
LOGS_FOLDER = '/app/logs',
LOGS_APP = 'app.log',
LOGS_ERROR = 'error.log',
NODE_ENV,
JWT_SECRET,
INTERNAL_IP,
TIPI_VERSION,
APPS_REPO_ID,
APPS_REPO_URL,
DOMAIN,
REDIS_HOST,
STORAGE_PATH = '/runtipi',
ARCHITECTURE = 'amd64',
} = serverRuntimeConfig;
const configSchema = z.object({
NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
REDIS_HOST: z.string(),
status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
architecture: z.nativeEnum(AppSupportedArchitecturesEnum),
logs: z.object({
LOGS_FOLDER: z.string(),
LOGS_APP: z.string(),
LOGS_ERROR: z.string(),
}),
dnsIp: z.string(),
rootFolder: z.string(),
internalIp: z.string(),
version: z.string(),
jwtSecret: z.string(),
appsRepoId: z.string(),
appsRepoUrl: z.string(),
domain: z.string(),
storagePath: z.string(),
});
export const formatErrors = (errors: z.ZodFormattedError<Map<string, string>, string>) =>
Object.entries(errors)
.map(([name, value]) => {
if (value && '_errors' in value) return `${name}: ${value._errors.join(', ')}\n`;
return null;
})
.filter(Boolean)
.join('\n');
export class TipiConfig {
private static instance: TipiConfig;
private config: z.infer<typeof configSchema>;
constructor() {
const envConfig: z.infer<typeof configSchema> = {
logs: {
LOGS_FOLDER,
LOGS_APP,
LOGS_ERROR,
},
REDIS_HOST,
NODE_ENV,
architecture: ARCHITECTURE as z.infer<typeof configSchema>['architecture'],
rootFolder: '/runtipi',
internalIp: INTERNAL_IP,
version: TIPI_VERSION,
jwtSecret: JWT_SECRET,
appsRepoId: APPS_REPO_ID,
appsRepoUrl: APPS_REPO_URL,
domain: DOMAIN,
dnsIp: '9.9.9.9',
status: 'RUNNING',
storagePath: STORAGE_PATH,
};
const fileConfig = readJsonFile('/runtipi/state/settings.json') || {};
const parsedFileConfig = configSchema.partial().safeParse(fileConfig);
if (parsedFileConfig.success) {
const parsedConfig = configSchema.safeParse({ ...envConfig, ...parsedFileConfig.data });
if (parsedConfig.success) {
this.config = parsedConfig.data;
} else {
Logger.error(`❌ Invalid env config\n${formatErrors(parsedConfig.error.format())}`);
throw new Error('Invalid env config');
}
} else {
Logger.error(`❌ Invalid settings.json file:\n${formatErrors(parsedFileConfig.error.format())}`);
throw new Error('Invalid settings.json file');
}
}
public static getInstance(): TipiConfig {
if (!TipiConfig.instance) {
TipiConfig.instance = new TipiConfig();
}
return TipiConfig.instance;
}
public getConfig() {
return this.config;
}
public setConfig<T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) {
const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
newConf[key] = value;
this.config = configSchema.parse(newConf);
if (writeFile) {
const currentJsonConf = readJsonFile('/runtipi/state/settings.json') || {};
const parsedConf = configSchema.partial().parse(currentJsonConf);
parsedConf[key] = value;
const parsed = configSchema.partial().parse(parsedConf);
fs.writeFileSync('/runtipi/state/settings.json', JSON.stringify(parsed));
}
}
}
export const setConfig = <T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) => {
TipiConfig.getInstance().setConfig(key, value, writeFile);
};
export const getConfig = () => TipiConfig.getInstance().getConfig();

View file

@ -0,0 +1 @@
export { getConfig, setConfig, TipiConfig } from './TipiConfig';

View file

@ -0,0 +1,8 @@
import { router } from '../trpc';
import { systemRouter } from './system/system.router';
export const appRouter = router({
system: systemRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,13 @@
import { inferRouterOutputs } from '@trpc/server';
import { router, protectedProcedure, publicProcedure } from '../../trpc';
import { SystemService } from '../../services/system/system.service';
export type SystemRouterOutput = inferRouterOutputs<typeof systemRouter>;
export const systemRouter = router({
status: publicProcedure.query(SystemService.status),
systemInfo: protectedProcedure.query(SystemService.systemInfo),
getVersion: protectedProcedure.query(SystemService.getVersion),
restart: protectedProcedure.mutation(SystemService.restart),
update: protectedProcedure.mutation(SystemService.update),
});

View file

@ -0,0 +1 @@
export { SystemService } from './system.service';

View file

@ -0,0 +1,187 @@
import fs from 'fs-extra';
import semver from 'semver';
import axios from 'axios';
import { faker } from '@faker-js/faker';
import { SystemService } from '.';
import { EventDispatcher } from '../../core/EventDispatcher';
import { setConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
jest.mock('fs-extra');
jest.mock('axios');
jest.mock('redis');
beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
});
describe('Test: systemInfo', () => {
it('Should throw if system-info.json does not exist', () => {
try {
SystemService.systemInfo();
} catch (e: unknown) {
if (e instanceof Error) {
expect(e).toBeDefined();
expect(e.message).toBe('Error parsing system info');
} else {
expect(true).toBe(false);
}
}
});
it('It should return system info', async () => {
// Arrange
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-expect-error Mocking fs
fs.__createMockFiles(MockFiles);
// Act
const systemInfo = SystemService.systemInfo();
// Assert
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 () => {
// Arrange
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
});
// Act
const version = await SystemService.getVersion();
// Assert
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 () => {
// Arrange
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
});
// Act
const version = await SystemService.getVersion();
const version2 = await SystemService.getVersion();
// Assert
expect(version).toBeDefined();
expect(version.current).toBeDefined();
expect(semver.valid(version.latest)).toBeTruthy();
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 () => {
// Arrange
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
// Act
const restart = await SystemService.restart();
// Assert
expect(restart).toBeTruthy();
});
});
describe('Test: update', () => {
it('Should return true', async () => {
// Arrange
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
setConfig('version', '0.0.1');
TipiCache.set('latestVersion', '0.0.2');
// Act
const update = await SystemService.update();
// Assert
expect(update).toBeTruthy();
});
it('Should throw an error if latest version is not set', async () => {
// Arrange
TipiCache.del('latestVersion');
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
data: { name: null },
});
setConfig('version', '0.0.1');
// Act & Assert
await expect(SystemService.update()).rejects.toThrow('Could not get latest version');
spy.mockRestore();
});
it('Should throw if current version is higher than latest', async () => {
// Arrange
setConfig('version', '0.0.2');
TipiCache.set('latestVersion', '0.0.1');
// Act & Assert
await expect(SystemService.update()).rejects.toThrow('Current version is newer than latest version');
});
it('Should throw if current version is equal to latest', async () => {
// Arrange
setConfig('version', '0.0.1');
TipiCache.set('latestVersion', '0.0.1');
// Act & Assert
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 () => {
// Arrange
setConfig('version', '0.0.1');
TipiCache.set('latestVersion', '1.0.0');
// Act & Assert
await expect(SystemService.update()).rejects.toThrow('The major version has changed. Please update manually');
});
});

View file

@ -0,0 +1,111 @@
import axios from 'axios';
import semver from 'semver';
import { z } from 'zod';
import { readJsonFile } from '../../common/fs.helpers';
import { EventDispatcher, EventTypes } from '../../core/EventDispatcher';
import { Logger } from '../../core/Logger';
import TipiCache from '../../core/TipiCache';
import { getConfig, setConfig } from '../../core/TipiConfig';
import { env } from '../../../env/server.mjs';
import { SystemStatus } from '../../../client/state/systemStore';
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 status = async (): Promise<{ status: SystemStatus }> => ({
status: getConfig().status as SystemStatus,
});
/**
* Get the current and latest version of Tipi
* @returns {Promise<{ current: string; latest: string }>}
*/
const getVersion = async (): Promise<{ current: string; latest?: string }> => {
try {
let version = await TipiCache.get('latestVersion');
if (!version) {
const { data } = await axios.get('https://api.github.com/repos/meienberger/runtipi/releases/latest');
version = data.name.replace('v', '');
await TipiCache.set('latestVersion', version?.replace('v', '') || '', 60 * 60);
}
return { current: getConfig().version, latest: version?.replace('v', '') };
} catch (e) {
Logger.error(e);
return { current: getConfig().version, latest: undefined };
}
};
const systemInfo = (): z.infer<typeof systemInfoSchema> => {
const info = systemInfoSchema.safeParse(readJsonFile('/runtipi/state/system-info.json'));
if (!info.success) {
throw new Error('Error parsing system info');
} else {
return info.data;
}
};
const restart = async (): Promise<boolean> => {
if (env.NODE_ENV === 'development') {
throw new Error('Cannot restart in development mode');
}
setConfig('status', 'RESTARTING');
EventDispatcher.dispatchEventAsync(EventTypes.RESTART);
return true;
};
const update = async (): Promise<boolean> => {
const { current, latest } = await getVersion();
if (env.NODE_ENV === 'development') {
throw new Error('Cannot update in development mode');
}
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');
EventDispatcher.dispatchEventAsync(EventTypes.UPDATE);
return true;
};
export const SystemService = {
getVersion,
systemInfo,
restart,
update,
status,
};

View file

@ -0,0 +1,37 @@
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { type Context } from './context';
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape }) {
return shape;
},
});
// Base router and procedure helpers
export const { router } = t;
/**
* Unprotected procedure
* */
export const publicProcedure = t.procedure;
/**
* Reusable middleware to ensure
* users are logged in
*/
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session || !ctx.session.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
}
return next({
ctx: {
session: { ...ctx.session, user: ctx.session.userId },
},
});
});
/**
* Protected procedure
* */
export const protectedProcedure = t.procedure.use(isAuthed);

View file

@ -28,12 +28,19 @@ importers:
'@hookform/resolvers': ^2.9.10
'@tabler/core': 1.0.0-beta16
'@tabler/icons': ^1.109.0
'@tanstack/react-query': ^4.20.4
'@testing-library/dom': ^8.19.0
'@testing-library/jest-dom': ^5.16.5
'@testing-library/react': ^13.4.0
'@testing-library/user-event': ^14.4.3
'@trpc/client': ^10.5.0
'@trpc/next': ^10.5.0
'@trpc/react-query': ^10.5.0
'@trpc/server': ^10.5.0
'@types/fs-extra': ^9.0.13
'@types/isomorphic-fetch': ^0.0.36
'@types/jest': ^27.5.0
'@types/jsonwebtoken': ^8.5.9
'@types/node': 17.0.31
'@types/react': 18.0.8
'@types/react-dom': 18.0.3
@ -53,11 +60,13 @@ importers:
eslint-plugin-jsx-a11y: ^6.6.1
eslint-plugin-react: ^7.31.10
eslint-plugin-react-hooks: ^4.6.0
fs-extra: ^10.1.0
graphql: ^15.8.0
graphql-tag: ^2.12.6
isomorphic-fetch: ^3.0.0
jest: ^28.1.0
jest-environment-jsdom: ^29.3.1
jsonwebtoken: ^9.0.0
msw: ^0.49.1
next: 13.0.3
next-router-mock: ^0.8.0
@ -67,18 +76,21 @@ importers:
react-markdown: ^8.0.3
react-select: ^5.6.1
react-tooltip: ^4.4.3
redis: ^4.3.1
remark-breaks: ^3.0.2
remark-gfm: ^3.0.1
remark-mdx: ^2.1.1
sass: ^1.55.0
semver: ^7.3.7
sharp: 0.30.7
superjson: ^1.12.0
swr: ^1.3.0
ts-jest: ^28.0.2
tslib: ^2.4.0
typescript: 4.6.4
validator: ^13.7.0
whatwg-fetch: ^3.6.2
winston: ^3.7.2
zod: ^3.19.1
zustand: ^3.7.2
dependencies:
@ -86,10 +98,17 @@ importers:
'@hookform/resolvers': 2.9.10_react-hook-form@7.40.0
'@tabler/core': 1.0.0-beta16_biqbaboplfbrettd7655fr4n2y
'@tabler/icons': 1.116.1_biqbaboplfbrettd7655fr4n2y
'@tanstack/react-query': 4.20.4_biqbaboplfbrettd7655fr4n2y
'@trpc/client': 10.5.0_@trpc+server@10.5.0
'@trpc/next': 10.5.0_vblc46ctnp72tcdsq7xyv3ad7a
'@trpc/react-query': 10.5.0_hkttq5uvq7kltri3tppuogqqou
'@trpc/server': 10.5.0
clsx: 1.1.1
fs-extra: 10.1.0
graphql: 15.8.0
graphql-tag: 2.12.6_graphql@15.8.0
isomorphic-fetch: 3.0.0
jsonwebtoken: 9.0.0
next: 13.0.3_7nrowiyds4jpk2wpzkb7237oey
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
@ -97,15 +116,18 @@ importers:
react-markdown: 8.0.3_gvifxuufrqkj4gcqfnnwrb44ya
react-select: 5.7.0_5ubfrz6g4pwsjvo47rxfnxhiaa
react-tooltip: 4.5.1_biqbaboplfbrettd7655fr4n2y
redis: 4.4.0
remark-breaks: 3.0.2
remark-gfm: 3.0.1
remark-mdx: 2.1.1
sass: 1.56.1
semver: 7.3.7
sharp: 0.30.7
superjson: 1.12.0
swr: 1.3.0_react@18.2.0
tslib: 2.4.0
validator: 13.7.0
winston: 3.7.2
zod: 3.19.1
zustand: 3.7.2_react@18.2.0
devDependencies:
@ -119,8 +141,10 @@ importers:
'@testing-library/jest-dom': 5.16.5
'@testing-library/react': 13.4.0_biqbaboplfbrettd7655fr4n2y
'@testing-library/user-event': 14.4.3_aaq3sbffpfe3jnxzm2zngsddei
'@types/fs-extra': 9.0.13
'@types/isomorphic-fetch': 0.0.36
'@types/jest': 27.5.0
'@types/jsonwebtoken': 8.5.9
'@types/node': 17.0.31
'@types/react': 18.0.8
'@types/react-dom': 18.0.3
@ -2099,7 +2123,7 @@ packages:
'@graphql-tools/utils': 8.6.13_graphql@15.8.0
'@types/js-yaml': 4.0.5
'@types/json-stable-stringify': 1.0.34
'@types/jsonwebtoken': 8.5.8
'@types/jsonwebtoken': 8.5.9
chalk: 4.1.2
debug: 4.3.4
dotenv: 16.0.0
@ -3036,6 +3060,28 @@ packages:
react-dom: 18.2.0_react@18.2.0
dev: false
/@tanstack/query-core/4.20.4:
resolution: {integrity: sha512-lhLtGVNJDsJ/DyZXrLzekDEywQqRVykgBqTmkv0La32a/RleILXy6JMLBb7UmS3QCatg/F/0N9/5b0i5j6IKcA==}
dev: false
/@tanstack/react-query/4.20.4_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-SJRxx13k/csb9lXAJfycgVA1N/yU/h3bvRNWP0+aHMfMjmbyX82FdoAcckDBbOdEyAupvb0byelNHNeypCFSyA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
react-native: '*'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
dependencies:
'@tanstack/query-core': 4.20.4
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
use-sync-external-store: 1.2.0_react@18.2.0
dev: false
/@testing-library/dom/8.19.0:
resolution: {integrity: sha512-6YWYPPpxG3e/xOo6HIWwB/58HukkwIVTOaZ0VwdMVjhRUX/01E4FtQbck9GazOOj7MXHc5RBzMrU86iBJHbI+A==}
engines: {node: '>=12'}
@ -3093,6 +3139,55 @@ packages:
engines: {node: '>= 10'}
dev: true
/@trpc/client/10.5.0_@trpc+server@10.5.0:
resolution: {integrity: sha512-ULRL6YUi/4sMzZnqS3VCe/VduPZgY24wdC4canpwWZfHj+O0kHz3KR260DzEw0QrpLrOwmkIWOlQKzVBn2lLgQ==}
peerDependencies:
'@trpc/server': 10.5.0
dependencies:
'@trpc/server': 10.5.0
dev: false
/@trpc/next/10.5.0_vblc46ctnp72tcdsq7xyv3ad7a:
resolution: {integrity: sha512-beWvrdHZTV7kx4XeLlHKB29EC6VWkkPykWN1eoPkSTeAYMqEIpl46Xgdj6UfiLmuq8yjAi888BahGNdmTMqFZQ==}
peerDependencies:
'@tanstack/react-query': ^4.3.8
'@trpc/client': 10.5.0
'@trpc/react-query': ^10.0.0-proxy-beta.21
'@trpc/server': 10.5.0
next: '*'
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@tanstack/react-query': 4.20.4_biqbaboplfbrettd7655fr4n2y
'@trpc/client': 10.5.0_@trpc+server@10.5.0
'@trpc/react-query': 10.5.0_hkttq5uvq7kltri3tppuogqqou
'@trpc/server': 10.5.0
next: 13.0.3_7nrowiyds4jpk2wpzkb7237oey
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
react-ssr-prepass: 1.5.0_react@18.2.0
dev: false
/@trpc/react-query/10.5.0_hkttq5uvq7kltri3tppuogqqou:
resolution: {integrity: sha512-MBjgssZBy1ZRZVRE4uvnVu7AGcdOhL47Y1NIPGd5WG5ZisNrV6imf7yZ62uNDemnekwTuJT/Lad9r14swvmvzQ==}
peerDependencies:
'@tanstack/react-query': ^4.3.8
'@trpc/client': 10.5.0
'@trpc/server': 10.5.0
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@tanstack/react-query': 4.20.4_biqbaboplfbrettd7655fr4n2y
'@trpc/client': 10.5.0_@trpc+server@10.5.0
'@trpc/server': 10.5.0
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
dev: false
/@trpc/server/10.5.0:
resolution: {integrity: sha512-AJ4ckDpnN8xuqWBox68KDTFpG12ZxKkW7fi9XJ+TLtyyNyqOMVUvKH9070CdxhqBZjebTASryE+/6lntkDFQxA==}
dev: false
/@tsconfig/node10/1.0.9:
resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}
dev: true
@ -3290,12 +3385,6 @@ packages:
resolution: {integrity: sha1-7ihweulOEdK4J7y+UnC86n8+ce4=}
dev: true
/@types/jsonwebtoken/8.5.8:
resolution: {integrity: sha512-zm6xBQpFDIDM6o9r6HSgDeIcLy82TKWctCXEPbJJcXb5AKmi5BNNdLXneixK4lplX3PqIVcwLBCGE/kAGnlD4A==}
dependencies:
'@types/node': 17.0.31
dev: true
/@types/jsonwebtoken/8.5.9:
resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==}
dependencies:
@ -4272,7 +4361,7 @@ packages:
/babel-plugin-dynamic-import-node/2.3.3:
resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==}
dependencies:
object.assign: 4.1.2
object.assign: 4.1.4
dev: true
/babel-plugin-istanbul/6.1.1:
@ -5054,6 +5143,13 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/copy-anything/3.0.3:
resolution: {integrity: sha512-fpW2W/BqEzqPp29QS+MwwfisHCQZtiduTe/m8idFo0xbti9fIZ2WVhAsCv4ggFVH3AgCkVdpoOCtQC6gBrdhjw==}
engines: {node: '>=12.13'}
dependencies:
is-what: 4.1.8
dev: false
/core-js-pure/3.22.4:
resolution: {integrity: sha512-4iF+QZkpzIz0prAFuepmxwJ2h5t4agvE8WPYqs2mjLJMNNwJOnpch76w2Q7bUfCPEv/V7wpvOfog0w273M+ZSw==}
deprecated: core-js-pure@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js-pure.
@ -7685,6 +7781,11 @@ packages:
get-intrinsic: 1.1.3
dev: true
/is-what/4.1.8:
resolution: {integrity: sha512-yq8gMao5upkPoGEU9LsB2P+K3Kt8Q3fQFCGyNCWOAnJAMzEXVV9drYb0TXr42TTliLLhKIBvulgAXgtLLnwzGA==}
engines: {node: '>=12.13'}
dev: false
/is-windows/1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
@ -8490,6 +8591,16 @@ packages:
ms: 2.1.3
semver: 5.7.1
/jsonwebtoken/9.0.0:
resolution: {integrity: sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==}
engines: {node: '>=12', npm: '>=6'}
dependencies:
jws: 3.2.2
lodash: 4.17.21
ms: 2.1.3
semver: 7.3.8
dev: false
/jsx-ast-utils/3.3.0:
resolution: {integrity: sha512-XzO9luP6L0xkxwhIJMTJQpZo/eeN60K08jHdexfD569AGxeNug6UketeHXEhROoM8aR7EcUoOQmIhcJQjcuq8Q==}
engines: {node: '>=4.0'}
@ -8703,7 +8814,6 @@ packages:
/lodash/4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: true
/log-symbols/1.0.2:
resolution: {integrity: sha512-mmPrW0Fh2fxOzdBbFv4g1m6pR72haFLPJ2G5SJEELf1y+iaQrDG6cWCPjy54RHYbZAt7X+ls690Kw62AdWXBzQ==}
@ -10540,6 +10650,14 @@ packages:
- '@types/react'
dev: false
/react-ssr-prepass/1.5.0_react@18.2.0:
resolution: {integrity: sha512-yFNHrlVEReVYKsLI5lF05tZoHveA5pGzjFbFJY/3pOqqjGOmMmqx83N4hIjN2n6E1AOa+eQEUxs3CgRnPmT0RQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/react-tooltip/4.5.1_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-Zo+CSFUGXar1uV+bgXFFDe7VeS2iByeIp5rTgTcc2HqtuOS5D76QapejNNfx320MCY91TlhTQat36KGFTqgcvw==}
engines: {npm: '>=6.13'}
@ -11366,6 +11484,13 @@ packages:
resolution: {integrity: sha512-GP6WDNWf+o403jrEp9c5jibKavrtLW+/qYGhFxFrG8maXhwTBI7gLLhiBb0o7uFccWN+EOS9aMO6cGHWAO07OA==}
dev: false
/superjson/1.12.0:
resolution: {integrity: sha512-B4tefmFqj8KDShHi2br2rz0kBlUJuQHtxMCydEuHvooL+6EscROTNWRfOLMDxW1dS/daK2zZr3C3N9DU+jXATQ==}
engines: {node: '>=10'}
dependencies:
copy-anything: 3.0.3
dev: false
/supports-color/2.0.0:
resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==}
engines: {node: '>=0.8.0'}