feat: setup trpc and create system router
This commit is contained in:
parent
34e6ff33e1
commit
d4f507ced3
26 changed files with 1399 additions and 21 deletions
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 }}>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { HttpLink } from '@apollo/client';
|
||||
|
||||
const httpLink = new HttpLink({
|
||||
uri: '/api/graphql',
|
||||
uri: '/api-legacy/graphql',
|
||||
});
|
||||
|
||||
export default httpLink;
|
||||
|
|
9
packages/dashboard/src/pages/api/trpc/[trpc].ts
Normal file
9
packages/dashboard/src/pages/api/trpc/[trpc].ts
Normal 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,
|
||||
});
|
11
packages/dashboard/src/server/common/fs.helpers.ts
Normal file
11
packages/dashboard/src/server/common/fs.helpers.ts
Normal 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;
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
};
|
38
packages/dashboard/src/server/context.ts
Normal file
38
packages/dashboard/src/server/context.ts
Normal 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>;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
|
@ -0,0 +1,2 @@
|
|||
export { EventDispatcherInstance as EventDispatcher } from './EventDispatcher';
|
||||
export { EventTypes } from './EventDispatcher';
|
62
packages/dashboard/src/server/core/Logger/Logger.ts
Normal file
62
packages/dashboard/src/server/core/Logger/Logger.ts
Normal 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;
|
1
packages/dashboard/src/server/core/Logger/index.ts
Normal file
1
packages/dashboard/src/server/core/Logger/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as Logger } from './Logger';
|
61
packages/dashboard/src/server/core/TipiCache/TipiCache.ts
Normal file
61
packages/dashboard/src/server/core/TipiCache/TipiCache.ts
Normal 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();
|
1
packages/dashboard/src/server/core/TipiCache/index.ts
Normal file
1
packages/dashboard/src/server/core/TipiCache/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from './TipiCache';
|
|
@ -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);
|
||||
});
|
||||
});
|
137
packages/dashboard/src/server/core/TipiConfig/TipiConfig.ts
Normal file
137
packages/dashboard/src/server/core/TipiConfig/TipiConfig.ts
Normal 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();
|
1
packages/dashboard/src/server/core/TipiConfig/index.ts
Normal file
1
packages/dashboard/src/server/core/TipiConfig/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { getConfig, setConfig, TipiConfig } from './TipiConfig';
|
8
packages/dashboard/src/server/routers/_app.ts
Normal file
8
packages/dashboard/src/server/routers/_app.ts
Normal 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;
|
|
@ -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),
|
||||
});
|
1
packages/dashboard/src/server/services/system/index.ts
Normal file
1
packages/dashboard/src/server/services/system/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { SystemService } from './system.service';
|
|
@ -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');
|
||||
});
|
||||
});
|
111
packages/dashboard/src/server/services/system/system.service.ts
Normal file
111
packages/dashboard/src/server/services/system/system.service.ts
Normal 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,
|
||||
};
|
37
packages/dashboard/src/server/trpc.ts
Normal file
37
packages/dashboard/src/server/trpc.ts
Normal 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);
|
143
pnpm-lock.yaml
143
pnpm-lock.yaml
|
@ -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'}
|
||||
|
|
Loading…
Reference in a new issue