Release/1.0.0 (#316)

* fix: create default media folder structure on install

* feat: add link to open exposed app to domain

* [ImgBot] Optimize images

*Total -- 2,048.42kb -> 1,263.43kb (38.32%)

/screenshots/darkmode.png -- 998.43kb -> 609.77kb (38.93%)
/screenshots/appstore.png -- 1,006.73kb -> 620.12kb (38.4%)
/packages/dashboard/public/error.png -- 42.38kb -> 32.70kb (22.84%)
/packages/dashboard/public/empty.svg -- 0.87kb -> 0.85kb (2.35%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>

* chore: bump version 0.8.1

* refactor: move all dashboard's files into a client folder

* feat: setup trpc and create system router

* test: split jest config for client and server

* refactor: replace grapqhl queries with trpc in the frontend

* refactor: remove now un-used system queries/mutations/resolvers from both client and server

* chore: bump dependencies

* feat: setup prisma and configure it for tests and development

* feat: create trpc router for auth service

* refactor: migrate client auth queries to trpc procedures

* refactor: cleanup now un-used graphql resolvers and services

* feat: create sql migrations by replicating typeorm ones in an idempotent manner

* feat: create server-preload script to run migrations upon server start

* chore: remove legacy migrations steps

* feat: add redis_host as an env variable

* refactor: remove prisma from context and use client directly in service

* feat: create trpc router & service for apps

* refactor: migrate client app queries/mutations to trpc

* refactor: removal and replace usage of old graphql generated types

* refactor: move from node --require to custom next server

* test: fix tests and bump various dependencies

* chore: cleanup system-api from now un-used files

* refactor(dashboard): remove code related to apollo

* refactor: serve static files through next's server instead of system-api

* refactor(server): move auth and system services to class

* refactor(client): remove layoutv2 abstraction

* fix: return correct update info

* chore: remove legacy system-api folder

* refactor: remove system-api from docker files

* feat: create scheduler to run cron jobs and setup periodic repo update

* fix: failing build caused by remark-mdx

* refactor: move migrations to server folder

* feat: compile server using esbuild

* refactor: ts issue mis-used file from client in server

* ci: make pipeline pass by cd into dashboard before each step (temp)

* chore: drop armv7 support

* refactor: move dashboard files in root folder

* feat(db): create migration to add operator field on user

* feat(user): create routes and services for password reset

* feat(auth): add reset password page, container & form

* refactor(dashboard): change layout and page of auth to be url based instead of state based

* feat(script): add reset-password script

* fix(dashboard): only check status if restart or update has been requested

* test: increase coverage for get-server-auth-session

* fix(start.sh): prompt for network interface only if there is not an internal ip set

* feat(script): support user docker-compose.yml and app.env

* chore: bump version

* fix: add missing postgres variables to start script

* fix: check for 32 bits before installing/starting

* fix: create default media folder structure on install

* Updated demo instance link

Changed demo.runtipi.com to https://demo.runtipi.com

* feat: adding config for codespaces

* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: Freddie Sackur <github@dustyfox.uk>
Co-authored-by: Kieran Klukas <92754843+kcoderhtml@users.noreply.github.com>
Co-authored-by: alwerner <alexander.werner@bonprix.net>
Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
This commit is contained in:
Nicolas Meienberger 2023-03-02 20:19:20 +01:00 committed by GitHub
parent 91c3162e3e
commit 3925cfa7bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
441 changed files with 7919 additions and 15209 deletions

View File

@ -10,3 +10,18 @@ dist/
docker-compose*.yml
Dockerfile*
.dockerignore
# Tipi folder
logs/
tests/
state/
templates/
scripts/
screenshots/
repos/
media/
data/
apps/
app-data/
.github/
__mocks__/

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
# Only edit this file if you know what you are doing!
# It will be overwritten on update.
APPS_REPO_ID=7a92c8307e0a8074763c80be1fcfa4f87da6641daea9211aea6743b0116aba3b
APPS_REPO_URL=https://github.com/meienberger/runtipi-appstore
TZ=UTC
INTERNAL_IP=localhost
DNS_IP=9.9.9.9
ARCHITECTURE=arm64
TIPI_VERSION=0.8.0
JWT_SECRET=secret
ROOT_FOLDER_HOST=/Users/nicolas/Projects/runtipi
NGINX_PORT=3000
NGINX_PORT_SSL=443
POSTGRES_PASSWORD=postgres
DOMAIN=tipi.localhost
STORAGE_PATH=/Users/nicolas/Projects/runtipi
REDIS_HOST=tipi-redis

6
.env.test Normal file
View File

@ -0,0 +1,6 @@
POSTGRES_HOST=localhost
POSTGRES_DBNAME=postgres
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5433
APPS_REPO_ID=repo-id

View File

@ -1,5 +1,5 @@
module.exports = {
plugins: ['@typescript-eslint', 'import', 'react', 'jest'],
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc'],
extends: [
'plugin:@typescript-eslint/recommended',
'next/core-web-vitals',
@ -10,6 +10,7 @@ module.exports = {
'plugin:import/typescript',
'prettier',
'plugin:react/recommended',
'plugin:jsdoc/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
@ -28,10 +29,13 @@ module.exports = {
'react/jsx-props-no-spreading': 0,
'react/no-unused-prop-types': 0,
'react/button-has-type': 0,
'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', '**/mocks/**', 'tests/**'] }],
'import/no-extraneous-dependencies': ['error', { devDependencies: ['esbuild.js', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', '**/*.factory.{ts,tsx}', '**/mocks/**', 'tests/**'] }],
'no-underscore-dangle': 0,
'arrow-body-style': 0,
},
globals: {
JSX: true,
NodeJS: true,
},
env: {
'jest/globals': true,

View File

@ -7,8 +7,12 @@ env:
JWT_SECRET: "secret"
ROOT_FOLDER_HOST: /tipi
APPS_REPO_ID: repo-id
INTERNAL_IP: 192.168.1.10
INTERNAL_IP: localhost
REDIS_HOST: redis
APPS_REPO_URL: https://repo.github.com/
DOMAIN: localhost
TIPI_VERSION: 0.0.1
jobs:
ci:
runs-on: ubuntu-latest
@ -57,15 +61,15 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Build packages
run: pnpm -r build
- name: Build client
run: pnpm build:next
- name: Run linter
run: pnpm -r lint
run: pnpm lint
- name: Run tests
run: pnpm -r test
run: pnpm test
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
token: ${{ secrets.CODECOV_TOKEN }}

View File

@ -15,29 +15,29 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get tag from VERSION file
id: meta
run: |
VERSION=$(npm run version --silent)
TAG=${VERSION}
echo "::set-output name=tag::${TAG}"
- name: Build and push images
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true
tags: meienberger/runtipi:rc-${{ steps.meta.outputs.TAG }}
cache-from: type=registry,ref=meienberger/runtipi:buildcache
cache-to: type=registry,ref=meienberger/runtipi:buildcache,mode=max
cache-to: type=registry,ref=meienberger/runtipi:buildcache,mode=max

View File

@ -1,10 +1,9 @@
name: Publish release
on:
push:
branches:
branches:
- master
jobs:
release:
if: github.repository == 'meienberger/runtipi'
@ -18,25 +17,25 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get tag from VERSION file
id: meta
run: |
VERSION=$(npm run version --silent)
TAG=${VERSION}
echo "::set-output name=tag::${TAG}"
- name: Build and push images
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true
tags: meienberger/runtipi:latest,meienberger/runtipi:${{ steps.meta.outputs.TAG }}
cache-from: type=registry,ref=meienberger/runtipi:buildcache
@ -48,7 +47,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
package-path: ./package.json
- name: Create Release
id: create_release
uses: actions/create-release@latest

70
.gitignore vendored
View File

@ -4,38 +4,58 @@
.DS_Store
.vscode
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
/dist
server-preload.js
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
logs
.pnpm-debug.log
.env*
!.env.example
!.env.test
github.secrets
node_modules/
app-data/*
data/postgres
data/redis
!app-data/.gitkeep
repos/*
!repos/.gitkeep
apps/*
!apps/.gitkeep
/app-data/
/data/
/repos/
/apps/
traefik/shared
# media folder
media
!media/.gitkeep
!media/data/.gitkeep
!media/data/books/.gitkeep
!media/data/books/ebooks/.gitkeep
!media/data/books/spoken/.gitkeep
!media/data/movies/.gitkeep
!media/data/music/.gitkeep
!media/data/podcasts/.gitkeep
!media/data/tv/.gitkeep
!media/data/images/.gitkeep
!media/torrents/.gitkeep
!media/torrents/complete/.gitkeep
!media/torrents/incomplete/.gitkeep
!media/torrents/watch/.gitkeep
# state folder
state/*
!state/.gitkeep
/state/

View File

@ -1,43 +1,49 @@
FROM node:18-alpine3.16 AS builder
ARG NODE_VERSION="18.12.1"
ARG ALPINE_VERSION="3.16"
# Required for argon2
RUN apk --no-cache add g++
RUN apk --no-cache add make
RUN apk --no-cache add python3
FROM node:${NODE_VERSION}-buster-slim AS node_base
RUN apt update
RUN apt install -y openssl
FROM node_base AS builder_base
# Required for sharp
RUN apk --no-cache add vips-dev=8.12.2-r5
RUN npm install node-gyp -g
RUN npm install pnpm -g
WORKDIR /api
COPY ./packages/system-api/package.json /api/package.json
RUN npm i
# ---
WORKDIR /dashboard
COPY ./packages/dashboard/package.json /dashboard/package.json
RUN npm i
# BUILDER
FROM builder_base AS builder
WORKDIR /app
COPY ./pnpm-lock.yaml ./
RUN pnpm fetch
COPY ./package*.json ./
COPY ./prisma/schema.prisma ./prisma/
RUN pnpm install -r --prefer-offline
COPY ./src ./src
COPY ./esbuild.js ./esbuild.js
COPY ./tsconfig.json ./tsconfig.json
COPY ./next.config.mjs ./next.config.mjs
COPY ./public ./public
WORKDIR /api
COPY ./packages/system-api /api
RUN npm run build
# ---
WORKDIR /dashboard
COPY ./packages/dashboard /dashboard
RUN npm run build
FROM node:18-alpine3.16 as app
# APP
FROM node_base AS app
WORKDIR /
# USER node
WORKDIR /api
COPY ./packages/system-api/package.json /api/
COPY --from=builder /api/dist /api/dist
WORKDIR /app
WORKDIR /dashboard
COPY --from=builder /dashboard/next.config.js ./
COPY --from=builder /dashboard/public ./public
COPY --from=builder /dashboard/package.json ./package.json
COPY --from=builder --chown=node:node /dashboard/.next/standalone ./
COPY --from=builder --chown=node:node /dashboard/.next/static ./.next/static
COPY --from=builder /app/dist ./
COPY --from=builder /app/next.config.mjs ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
WORKDIR /
EXPOSE 3000
CMD ["npm", "run", "start"]

View File

@ -1,19 +1,28 @@
FROM node:18-alpine3.16
ARG NODE_VERSION="18.12.1"
ARG ALPINE_VERSION="3.16"
WORKDIR /
FROM node:${NODE_VERSION}-buster-slim
RUN apk --no-cache add g++ make
RUN apt update
RUN apt install -y openssl
RUN npm install pnpm -g
RUN npm install node-gyp -g
WORKDIR /api
COPY ./packages/system-api/package*.json /api/
RUN npm install
WORKDIR /app
WORKDIR /dashboard
COPY ./packages/dashboard/package*.json /dashboard/
RUN npm install
COPY ./pnpm-lock.yaml ./
RUN pnpm fetch
COPY ./packages/system-api /api
COPY ./packages/dashboard /dashboard
COPY ./package*.json ./
COPY ./prisma/schema.prisma ./prisma/
WORKDIR /
RUN pnpm install -r --prefer-offline
COPY ./src ./src
COPY ./esbuild.js ./esbuild.js
COPY ./tsconfig.json ./tsconfig.json
COPY ./next.config.mjs ./next.config.mjs
COPY ./public ./public
CMD ["npm", "run", "dev"]

118
__mocks__/fs-extra.ts Normal file
View File

@ -0,0 +1,118 @@
import path from 'path';
class FsMock {
private static instance: FsMock;
private mockFiles = Object.create(null);
// private constructor() {}
static getInstance(): FsMock {
if (!FsMock.instance) {
FsMock.instance = new FsMock();
}
return FsMock.instance;
}
__createMockFiles = (newMockFiles: Record<string, string>) => {
this.mockFiles = Object.create(null);
// Create folder tree
Object.keys(newMockFiles).forEach((file) => {
const dir = path.dirname(file);
if (!this.mockFiles[dir]) {
this.mockFiles[dir] = [];
}
this.mockFiles[dir].push(path.basename(file));
this.mockFiles[file] = newMockFiles[file];
});
};
__resetAllMocks = () => {
this.mockFiles = Object.create(null);
};
readFileSync = (p: string) => this.mockFiles[p];
existsSync = (p: string) => this.mockFiles[p] !== undefined;
writeFileSync = (p: string, data: string | string[]) => {
this.mockFiles[p] = data;
};
mkdirSync = (p: string) => {
this.mockFiles[p] = Object.create(null);
};
rmSync = (p: string) => {
if (this.mockFiles[p] instanceof Array) {
this.mockFiles[p].forEach((file: string) => {
delete this.mockFiles[path.join(p, file)];
});
}
delete this.mockFiles[p];
};
readdirSync = (p: string) => {
const files: string[] = [];
const depth = p.split('/').length;
Object.keys(this.mockFiles).forEach((file) => {
if (file.startsWith(p)) {
const fileDepth = file.split('/').length;
if (fileDepth === depth + 1) {
files.push(file.split('/').pop() || '');
}
}
});
return files;
};
copyFileSync = (source: string, destination: string) => {
this.mockFiles[destination] = this.mockFiles[source];
};
copySync = (source: string, destination: string) => {
this.mockFiles[destination] = this.mockFiles[source];
if (this.mockFiles[source] instanceof Array) {
this.mockFiles[source].forEach((file: string) => {
this.mockFiles[`${destination}/${file}`] = this.mockFiles[`${source}/${file}`];
});
}
};
createFileSync = (p: string) => {
this.mockFiles[p] = '';
};
unlinkSync = (p: string) => {
if (this.mockFiles[p] instanceof Array) {
this.mockFiles[p].forEach((file: string) => {
delete this.mockFiles[path.join(p, file)];
});
}
delete this.mockFiles[p];
};
getMockFiles = () => this.mockFiles;
promises = {
unlink: (p: string) => {
if (this.mockFiles[p] instanceof Array) {
this.mockFiles[p].forEach((file: string) => {
delete this.mockFiles[path.join(p, file)];
});
}
delete this.mockFiles[p];
},
};
}
export default FsMock.getInstance();

16
__mocks__/redis.ts Normal file
View File

@ -0,0 +1,16 @@
export const createClient = jest.fn(() => {
const values = new Map();
const expirations = new Map();
return {
isOpen: true,
connect: jest.fn(),
set: (key: string, value: string, exp: number) => {
values.set(key, value);
expirations.set(key, exp);
},
get: (key: string) => values.get(key),
quit: jest.fn(),
del: (key: string) => values.delete(key),
ttl: (key: string) => expirations.get(key),
};
});

View File

View File

View File

@ -1,3 +0,0 @@
module.exports = {
extends: ["@commitlint/config-conventional"],
};

View File

@ -50,65 +50,40 @@ services:
networks:
- tipi_main_network
api:
dashboard:
build:
context: .
dockerfile: Dockerfile.dev
command: /bin/sh -c "cd /api && npm run build && npm run dev"
container_name: dashboard
depends_on:
tipi-db:
condition: service_healthy
container_name: api
volumes:
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/state:/runtipi/state
- ${PWD}/packages/system-api/src:/api/src
- ${STORAGE_PATH}:/app/storage
- ${PWD}/logs:/app/logs
- ${PWD}/.env.dev:/runtipi/.env
# - /api/node_modules
environment:
NODE_ENV: development
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
POSTGRES_USERNAME: ${POSTGRES_USERNAME}
POSTGRES_DBNAME: ${POSTGRES_DBNAME}
POSTGRES_HOST: ${POSTGRES_HOST}
APPS_REPO_ID: ${APPS_REPO_ID}
APPS_REPO_URL: ${APPS_REPO_URL}
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
networks:
- tipi_main_network
labels:
traefik.enable: true
# Web
traefik.http.routers.api.rule: PathPrefix(`/api`)
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
dashboard:
build:
context: .
dockerfile: Dockerfile.dev
command: /bin/sh -c "cd /dashboard && npm run dev"
container_name: dashboard
depends_on:
api:
condition: service_started
REDIS_HOST: ${REDIS_HOST}
networks:
- tipi_main_network
volumes:
- ${PWD}/packages/dashboard/src:/dashboard/src
- ${PWD}/src:/app/src
# - /dashboard/node_modules
# - /dashboard/.next
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
labels:
traefik.enable: true
# Web
@ -126,4 +101,4 @@ networks:
- subnet: 10.21.21.0/24
volumes:
pgdata:
pgdata:

View File

@ -45,65 +45,35 @@ services:
networks:
- tipi_main_network
api:
dashboard:
image: meienberger/runtipi:rc-${TIPI_VERSION}
command: /bin/sh -c "cd /api && npm run start"
container_name: api
container_name: dashboard
networks:
- tipi_main_network
depends_on:
tipi-db:
condition: service_healthy
volumes:
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/state:/runtipi/state
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
- ${PWD}/.env:/runtipi/.env:ro
environment:
NODE_ENV: production
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
NODE_ENV: production
POSTGRES_USERNAME: ${POSTGRES_USERNAME}
POSTGRES_DBNAME: ${POSTGRES_DBNAME}
POSTGRES_HOST: ${POSTGRES_HOST}
APPS_REPO_ID: ${APPS_REPO_ID}
APPS_REPO_URL: ${APPS_REPO_URL}
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
networks:
- tipi_main_network
labels:
traefik.enable: true
# Web
traefik.http.routers.api.rule: PathPrefix(`/api`)
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
# Websecure
traefik.http.routers.api-secure.rule: (Host(`${DOMAIN}`) && PathPrefix(`/api`))
traefik.http.routers.api-secure.entrypoints: websecure
traefik.http.routers.api-secure.service: api-secure
traefik.http.routers.api-secure.tls.certresolver: myresolver
traefik.http.routers.api-secure.middlewares: api-stripprefix
traefik.http.services.api-secure.loadbalancer.server.port: 3001
# Middlewares
traefik.http.middlewares.api-stripprefix.stripprefix.prefixes: /api
dashboard:
image: meienberger/runtipi:rc-${TIPI_VERSION}
command: /bin/sh -c "cd /dashboard && node server.js"
container_name: dashboard
networks:
- tipi_main_network
depends_on:
api:
condition: service_started
environment:
NODE_ENV: production
REDIS_HOST: ${REDIS_HOST}
volumes:
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
labels:
traefik.enable: true
# Web

View File

@ -44,100 +44,51 @@ services:
networks:
- tipi_main_network
api:
build:
context: .
dockerfile: Dockerfile
command: /bin/sh -c "cd /api && npm run start"
restart: unless-stopped
container_name: api
depends_on:
tipi-db:
condition: service_healthy
volumes:
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/state:/runtipi/state
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
- ${PWD}/.env:/runtipi/.env:ro
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
NODE_ENV: production
APPS_REPO_ID: ${APPS_REPO_ID}
APPS_REPO_URL: ${APPS_REPO_URL}
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
networks:
- tipi_main_network
labels:
traefik.enable: true
# Web
traefik.http.routers.api.rule: PathPrefix(`/api`)
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
# Websecure
traefik.http.routers.api-secure.rule: (Host(`${DOMAIN}`) && PathPrefix(`/api`))
traefik.http.routers.api-secure.entrypoints: websecure
traefik.http.routers.api-secure.service: api-secure
traefik.http.routers.api-secure.tls.certresolver: myresolver
traefik.http.routers.api-secure.middlewares: api-stripprefix
traefik.http.services.api-secure.loadbalancer.server.port: 3001
# Middlewares
traefik.http.middlewares.api-stripprefix.stripprefix.prefixes: /api
dashboard:
build:
context: .
dockerfile: Dockerfile
command: /bin/sh -c "cd /dashboard && node server.js"
restart: unless-stopped
container_name: dashboard
networks:
- tipi_main_network
depends_on:
api:
condition: service_started
tipi-db:
condition: service_healthy
environment:
NODE_ENV: production
INTERNAL_IP: ${INTERNAL_IP}
TIPI_VERSION: ${TIPI_VERSION}
JWT_SECRET: ${JWT_SECRET}
NGINX_PORT: ${NGINX_PORT}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USERNAME: ${POSTGRES_USERNAME}
POSTGRES_DBNAME: ${POSTGRES_DBNAME}
POSTGRES_HOST: ${POSTGRES_HOST}
APPS_REPO_ID: ${APPS_REPO_ID}
APPS_REPO_URL: ${APPS_REPO_URL}
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
REDIS_HOST: ${REDIS_HOST}
volumes:
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
labels:
traefik.enable: true
traefik.http.routers.dashboard-redirect.rule: PathPrefix("/")
traefik.http.routers.dashboard-redirect.entrypoints: web
traefik.http.routers.dashboard-redirect.middlewares: redirect-middleware
traefik.http.routers.dashboard-redirect.service: dashboard
traefik.http.services.dashboard-redirect.loadbalancer.server.port: 3000
traefik.http.routers.dashboard-redirect-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
traefik.http.routers.dashboard-redirect-secure.entrypoints: websecure
traefik.http.routers.dashboard-redirect-secure.middlewares: redirect-middleware
traefik.http.routers.dashboard-redirect-secure.service: dashboard
traefik.http.routers.dashboard-redirect-secure.tls.certresolver: myresolver
traefik.http.services.dashboard-redirect-secure.loadbalancer.server.port: 3000
# Web
traefik.http.routers.dashboard.rule: PathPrefix("/dashboard")
traefik.http.routers.dashboard.rule: PathPrefix("/")
traefik.http.routers.dashboard.service: dashboard
traefik.http.routers.dashboard.entrypoints: web
traefik.http.services.dashboard.loadbalancer.server.port: 3000
# Websecure
traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/dashboard`)
traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
traefik.http.routers.dashboard-secure.service: dashboard-secure
traefik.http.routers.dashboard-secure.entrypoints: websecure
traefik.http.routers.dashboard-secure.tls.certresolver: myresolver
traefik.http.services.dashboard-secure.loadbalancer.server.port: 3000
# Middlewares
traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
networks:
tipi_main_network:
@ -148,4 +99,4 @@ networks:
- subnet: 10.21.21.0/24
volumes:
pgdata:
pgdata:

View File

@ -44,67 +44,36 @@ services:
networks:
- tipi_main_network
api:
image: meienberger/runtipi:${TIPI_VERSION}
command: /bin/sh -c "cd /api && npm run start"
restart: unless-stopped
container_name: api
depends_on:
tipi-db:
condition: service_healthy
volumes:
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/state:/runtipi/state
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
- ${PWD}/.env:/runtipi/.env:ro
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
NODE_ENV: production
APPS_REPO_ID: ${APPS_REPO_ID}
APPS_REPO_URL: ${APPS_REPO_URL}
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
networks:
- tipi_main_network
labels:
traefik.enable: true
# Web
traefik.http.routers.api.rule: PathPrefix(`/api`)
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
# Websecure
traefik.http.routers.api-secure.rule: (Host(`${DOMAIN}`) && PathPrefix(`/api`))
traefik.http.routers.api-secure.entrypoints: websecure
traefik.http.routers.api-secure.service: api-secure
traefik.http.routers.api-secure.tls.certresolver: myresolver
traefik.http.routers.api-secure.middlewares: api-stripprefix
traefik.http.services.api-secure.loadbalancer.server.port: 3001
# Middlewares
traefik.http.middlewares.api-stripprefix.stripprefix.prefixes: /api
dashboard:
image: meienberger/runtipi:${TIPI_VERSION}
command: /bin/sh -c "cd /dashboard && node server.js"
restart: unless-stopped
container_name: dashboard
networks:
- tipi_main_network
depends_on:
api:
condition: service_started
tipi-db:
condition: service_healthy
environment:
NODE_ENV: production
INTERNAL_IP: ${INTERNAL_IP}
TIPI_VERSION: ${TIPI_VERSION}
JWT_SECRET: ${JWT_SECRET}
NGINX_PORT: ${NGINX_PORT}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USERNAME: ${POSTGRES_USERNAME}
POSTGRES_DBNAME: ${POSTGRES_DBNAME}
POSTGRES_HOST: ${POSTGRES_HOST}
APPS_REPO_ID: ${APPS_REPO_ID}
APPS_REPO_URL: ${APPS_REPO_URL}
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
REDIS_HOST: ${REDIS_HOST}
volumes:
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
labels:
traefik.enable: true
# Web

40
esbuild.js Normal file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-var-requires */
const esbuild = require('esbuild');
const { spawn } = require('child_process');
const pkg = require('./package.json');
const isDev = process.argv[2] !== 'build';
process.env.NODE_ENV = isDev ? 'development' : 'production';
let server;
const onRebuild = () => {
if (isDev) {
if (server) server.kill('SIGINT');
server = spawn('node', ['dist/index.js'], { stdio: [0, 1, 2] });
} else {
spawn('pnpm', ['next', 'build'], { stdio: [0, 1, 2] });
}
};
const included = ['express', 'pg', '@runtipi/postgres-migrations'];
const excluded = ['pg-native', '*required-server-files.json'];
const external = Object.keys(pkg.dependencies || {}).filter((dep) => !included.includes(dep));
external.push(...excluded);
esbuild
.build({
entryPoints: ['src/server/index.ts'],
external,
define: { 'process.env.NODE_ENV': `"${process.env.NODE_ENV}"` },
platform: 'node',
target: 'node14',
outfile: 'dist/index.js',
tsconfig: 'tsconfig.json',
bundle: true,
minify: isDev,
sourcemap: isDev,
watch: isDev,
})
.finally(onRebuild);

View File

@ -1,7 +0,0 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/__tests__/**/*.test.ts"],
testPathIgnorePatterns: ["/node_modules/", "/packages/"],
};

39
jest.config.ts Normal file
View File

@ -0,0 +1,39 @@
import nextJest from 'next/jest';
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
const customClientConfig = {
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterEnv: ['<rootDir>/tests/client/jest.setup.tsx'],
testMatch: ['<rootDir>/src/client/**/*.{spec,test}.{ts,tsx}', '!<rootDir>/src/server/**/*.{spec,test}.{ts,tsx}'],
};
const customServerConfig = {
testEnvironment: 'node',
testMatch: ['<rootDir>/src/server/**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/tests/server/jest.setup.ts'],
};
export default async () => {
const clientConfig = await createJestConfig(customClientConfig)();
const serverConfig = await createJestConfig(customServerConfig)();
return {
verbose: true,
collectCoverage: true,
collectCoverageFrom: ['src/server/**/*.{ts,tsx}', 'src/client/**/*.{ts,tsx}', '!src/**/mocks/**/*.{ts,tsx}', '!**/*.{spec,test}.{ts,tsx}', '!**/index.{ts,tsx}'],
projects: [
{
displayName: 'client',
...clientConfig,
},
{
displayName: 'server',
...serverConfig,
},
],
};
};

View File

View File

Binary file not shown.

View File

23
next.config.mjs Normal file
View File

@ -0,0 +1,23 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
swcMinify: true,
output: 'standalone',
reactStrictMode: true,
serverRuntimeConfig: {
INTERNAL_IP: process.env.INTERNAL_IP,
TIPI_VERSION: process.env.TIPI_VERSION,
JWT_SECRET: process.env.JWT_SECRET,
POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD,
POSTGRES_USERNAME: process.env.POSTGRES_USERNAME,
POSTGRES_DBNAME: process.env.POSTGRES_DBNAME,
POSTGRES_HOST: process.env.POSTGRES_HOST,
APPS_REPO_ID: process.env.APPS_REPO_ID,
APPS_REPO_URL: process.env.APPS_REPO_URL,
DOMAIN: process.env.DOMAIN,
ARCHITECTURE: process.env.ARCHITECTURE,
NODE_ENV: process.env.NODE_ENV,
REDIS_HOST: process.env.REDIS_HOST,
},
};
export default nextConfig;

View File

@ -1,26 +1,126 @@
{
"name": "runtipi",
"version": "0.8.1",
"version": "1.0.0",
"description": "A homeserver for everyone",
"scripts": {
"commit": "git-cz",
"act:test-install": "act --container-architecture linux/amd64 -j test-install",
"act:docker": "act --container-architecture linux/amd64 --secret-file github.secrets -j build-images",
"copy:migrations": "mkdir -p dist/migrations && cp -r ./src/server/migrations dist",
"prisma:pull": "prisma db pull",
"test": "dotenv -e .env.test -- jest --colors",
"test:client": "jest --colors --selectProjects client --",
"test:server": "jest --colors --selectProjects server --",
"postinstall": "prisma generate",
"dev": "npm run copy:migrations && node ./esbuild.js dev",
"start": "NODE_ENV=production node index.js",
"lint": "next lint",
"lint:fix": "next lint --fix",
"build": "npm run copy:migrations && node ./esbuild.js build",
"build:server": "node ./esbuild.js build",
"build:next": "next build",
"start:dev": "./scripts/start-dev.sh",
"start:dev-container": "./.devcontainer/filewatcher.sh && npm run start:dev",
"start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
"start:prod": "docker-compose -f docker-compose.test.yml --env-file .env up --build",
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres:14",
"version": "echo $npm_package_version",
"release:rc": "./scripts/deploy/release-rc.sh",
"test:build": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:test ."
"test:build": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:test .",
"test:build:arm64": "docker buildx build --platform linux/arm64 -t meienberger/runtipi:test .",
"test:build:arm7": "docker buildx build --platform linux/arm/v7 -t meienberger/runtipi:test .",
"test:build:amd64": "docker buildx build --platform linux/amd64 -t meienberger/runtipi:test ."
},
"dependencies": {
"@hookform/resolvers": "^2.9.10",
"@prisma/client": "^4.8.0",
"@runtipi/postgres-migrations": "^5.3.0",
"@tabler/core": "1.0.0-beta16",
"@tabler/icons": "^1.109.0",
"@tanstack/react-query": "^4.24.4",
"@trpc/client": "^10.11.1",
"@trpc/next": "^10.11.1",
"@trpc/react-query": "^10.11.1",
"@trpc/server": "^10.11.1",
"argon2": "^0.29.1",
"clsx": "^1.1.1",
"express": "^4.17.3",
"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.1.6",
"node-cron": "^3.0.1",
"node-fetch-commonjs": "^3.2.4",
"pg": "^8.7.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.38.0",
"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",
"sass": "^1.55.0",
"semver": "^7.3.7",
"sharp": "0.30.7",
"superjson": "^1.12.0",
"tslib": "^2.4.0",
"uuid": "^9.0.0",
"validator": "^13.7.0",
"winston": "^3.7.2",
"zod": "^3.19.1",
"zustand": "^3.7.2"
},
"devDependencies": {
"@commitlint/cli": "^17.0.3",
"@commitlint/config-conventional": "^17.0.3",
"@commitlint/cz-commitlint": "^17.0.3",
"commitizen": "^4.2.4",
"inquirer": "8.2.4"
"@babel/core": "^7.0.0",
"@faker-js/faker": "^7.6.0",
"@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",
"@types/express": "^4.17.13",
"@types/fs-extra": "^9.0.13",
"@types/isomorphic-fetch": "^0.0.36",
"@types/jest": "^29.2.4",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "18.11.18",
"@types/node-cron": "^3.0.2",
"@types/pg": "^8.6.5",
"@types/react": "18.0.8",
"@types/react-dom": "18.0.3",
"@types/semver": "^7.3.12",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/uuid": "^8.3.4",
"@types/validator": "^13.7.2",
"@typescript-eslint/eslint-plugin": "^5.47.1",
"@typescript-eslint/parser": "^5.47.1",
"dotenv-cli": "^6.0.0",
"esbuild": "^0.16.17",
"eslint": "8.30.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-next": "13.1.1",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-jest": "^27.1.7",
"eslint-plugin-jsdoc": "^39.6.9",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"msw": "^1.0.0",
"next-router-mock": "^0.8.0",
"prettier": "^2.8.4",
"prisma": "^4.10.1",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typescript": "4.9.4",
"wait-for-expect": "^3.0.2",
"whatwg-fetch": "^3.6.2"
},
"msw": {
"workerDirectory": "public"
},
"repository": {
"type": "git",
@ -31,10 +131,5 @@
"bugs": {
"url": "https://github.com/meienberger/runtipi/issues"
},
"homepage": "https://github.com/meienberger/runtipi#readme",
"config": {
"commitizen": {
"path": "@commitlint/cz-commitlint"
}
}
}
"homepage": "https://github.com/meienberger/runtipi#readme"
}

View File

@ -1,5 +0,0 @@
node_modules/
.next/
dist/
sessions/
logs/

View File

@ -1,35 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo

View File

@ -1,34 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -1,9 +0,0 @@
overwrite: true
schema: "http://localhost:3000/api/graphql"
documents: "src/graphql/**/*.graphql"
generates:
src/generated/graphql.tsx:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo"

View File

@ -1,18 +0,0 @@
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/tests/jest.setup.tsx'],
testEnvironment: 'jest-environment-jsdom',
collectCoverage: true,
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/index.ts', '!**/src/pages/**/*.{ts,tsx}', '!**/src/mocks/**', '!**/src/core/apollo/**'],
testMatch: ['<rootDir>/src/**/*.{spec,test}.{ts,tsx}'],
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

View File

@ -1,8 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
swcMinify: true,
};
module.exports = nextConfig;

View File

@ -1,85 +0,0 @@
{
"name": "dashboard",
"version": "0.8.1",
"private": true,
"scripts": {
"test": "jest --colors",
"dev": "next dev",
"dev:msw": "NEXT_PUBLIC_API_MOCKING=enabled next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"gen": "graphql-codegen --config codegen.yml"
},
"dependencies": {
"@apollo/client": "^3.6.8",
"@hookform/resolvers": "^2.9.10",
"@tabler/core": "1.0.0-beta16",
"@tabler/icons": "^1.109.0",
"clsx": "^1.1.1",
"graphql": "^15.8.0",
"graphql-tag": "^2.12.6",
"isomorphic-fetch": "^3.0.0",
"next": "13.0.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.38.0",
"react-markdown": "^8.0.3",
"react-select": "^5.6.1",
"react-tooltip": "^4.4.3",
"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",
"swr": "^1.3.0",
"tslib": "^2.4.0",
"validator": "^13.7.0",
"zod": "^3.19.1",
"zustand": "^3.7.2"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@faker-js/faker": "^7.3.0",
"@graphql-codegen/cli": "^2.6.2",
"@graphql-codegen/typescript": "^2.5.1",
"@graphql-codegen/typescript-operations": "^2.4.2",
"@graphql-codegen/typescript-react-apollo": "^3.2.16",
"@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",
"@types/isomorphic-fetch": "^0.0.36",
"@types/jest": "^27.5.0",
"@types/node": "17.0.31",
"@types/react": "18.0.8",
"@types/react-dom": "18.0.3",
"@types/semver": "^7.3.12",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/validator": "^13.7.2",
"@typescript-eslint/eslint-plugin": "^5.18.0",
"@typescript-eslint/parser": "^5.0.0",
"concurrently": "^7.1.0",
"eslint": "8.12.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-next": "12.1.4",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-jest": "^27.1.6",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0",
"jest": "^28.1.0",
"jest-environment-jsdom": "^29.3.1",
"msw": "^0.49.1",
"next-router-mock": "^0.8.0",
"ts-jest": "^28.0.2",
"typescript": "4.6.4",
"whatwg-fetch": "^3.6.2"
},
"msw": {
"workerDirectory": "public"
}
}

View File

@ -1,47 +0,0 @@
import React, { ReactElement, useEffect, useState } from 'react';
import useSWR from 'swr';
import router from 'next/router';
import { SystemStatus } from '../../../state/systemStore';
import { StatusScreen } from '../../StatusScreen';
interface IProps {
children: ReactElement;
}
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export const StatusProvider: React.FC<IProps> = ({ children }) => {
const [s, setS] = useState<SystemStatus>(SystemStatus.RUNNING);
const { data, isValidating } = useSWR<{ status: SystemStatus }>('/api/status', fetcher, { refreshInterval: 1000 });
useEffect(() => {
// If previous was not running and current is running, we need to refresh the page
if (data?.status === SystemStatus.RUNNING && s !== SystemStatus.RUNNING) {
router.reload();
}
if (data?.status === SystemStatus.RUNNING) {
setS(SystemStatus.RUNNING);
}
if (data?.status === SystemStatus.RESTARTING) {
setS(SystemStatus.RESTARTING);
}
if (data?.status === SystemStatus.UPDATING) {
setS(SystemStatus.UPDATING);
}
}, [data?.status, s]);
if (isValidating && !data?.status) {
return <StatusScreen title="" subtitle="" />;
}
if (s === SystemStatus.RESTARTING) {
return <StatusScreen title="Your system is restarting..." subtitle="Please do not refresh this page" />;
}
if (s === SystemStatus.UPDATING) {
return <StatusScreen title="Your system is updating..." subtitle="Please do not refresh this page" />;
}
return children;
};

View File

@ -1,11 +0,0 @@
import { ApolloClient, from, InMemoryCache } from '@apollo/client';
import links from './links';
export const createApolloClient = (): ApolloClient<unknown> => {
const additiveLink = from([links.errorLink, links.authLink, links.httpLink]);
return new ApolloClient({
link: additiveLink,
cache: new InMemoryCache(),
});
};

View File

@ -1,14 +0,0 @@
import { setContext } from '@apollo/client/link/context';
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
export default authLink;

View File

@ -1,14 +0,0 @@
import { onError } from '@apollo/client/link/error';
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors)
graphQLErrors.forEach(({ message, locations, path }) => {
console.warn(`Error link [GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
});
if (networkError) {
console.warn(`Error link [Network error]: ${networkError}`);
}
});
export default errorLink;

View File

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

View File

@ -1,11 +0,0 @@
import errorLink from './errorLink';
import httpLink from './httpLink';
import authLink from './authLink';
const links = {
errorLink,
httpLink,
authLink,
};
export default links;

View File

@ -1,18 +0,0 @@
import { AppCategoriesEnum } from '../generated/graphql';
export const APP_CATEGORIES = [
{ name: 'Network', id: AppCategoriesEnum.Network, icon: 'FaNetworkWired' },
{ name: 'Media', id: AppCategoriesEnum.Media, icon: 'FaVideo' },
{ name: 'Development', id: AppCategoriesEnum.Development, icon: 'FaCode' },
{ name: 'Automation', id: AppCategoriesEnum.Automation, icon: 'FaRobot' },
{ name: 'Social', id: AppCategoriesEnum.Social, icon: 'FaUserFriends' },
{ name: 'Utilities', id: AppCategoriesEnum.Utilities, icon: 'FaWrench' },
{ name: 'Photography', id: AppCategoriesEnum.Photography, icon: 'FaCamera' },
{ name: 'Security', id: AppCategoriesEnum.Security, icon: 'FaShieldAlt' },
{ name: 'Featured', id: AppCategoriesEnum.Featured, icon: 'FaStar' },
{ name: 'Books', id: AppCategoriesEnum.Books, icon: 'FaBook' },
{ name: 'Data', id: AppCategoriesEnum.Data, icon: 'FaDatabase' },
{ name: 'Music', id: AppCategoriesEnum.Music, icon: 'FaMusic' },
{ name: 'Finance', id: AppCategoriesEnum.Finance, icon: 'FaMoneyBillAlt' },
{ name: 'Gaming', id: AppCategoriesEnum.Gaming, icon: 'FaGamepad' },
];

View File

@ -1,4 +0,0 @@
export interface IUser {
name: string;
email: string;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +0,0 @@
mutation InstallApp($input: AppInputType!) {
installApp(input: $input) {
id
status
__typename
}
}

View File

@ -1,5 +0,0 @@
mutation Login($input: UsernamePasswordInput!) {
login(input: $input) {
token
}
}

View File

@ -1,3 +0,0 @@
mutation Logout {
logout
}

View File

@ -1,5 +0,0 @@
mutation Register($input: UsernamePasswordInput!) {
register(input: $input) {
token
}
}

View File

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

View File

@ -1,7 +0,0 @@
mutation StartApp($id: String!) {
startApp(id: $id) {
id
status
__typename
}
}

View File

@ -1,7 +0,0 @@
mutation StopApp($id: String!) {
stopApp(id: $id) {
id
status
__typename
}
}

View File

@ -1,7 +0,0 @@
mutation UninstallApp($id: String!) {
uninstallApp(id: $id) {
id
status
__typename
}
}

View File

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

View File

@ -1,7 +0,0 @@
mutation UpdateApp($id: String!) {
updateApp(id: $id) {
id
status
__typename
}
}

View File

@ -1,7 +0,0 @@
mutation UpdateAppConfig($input: AppInputType!) {
updateAppConfig(input: $input) {
id
status
__typename
}
}

View File

@ -1,42 +0,0 @@
query GetApp($appId: String!) {
getApp(id: $appId) {
id
status
config
version
exposed
domain
updateInfo {
current
latest
dockerVersion
}
info {
id
port
name
description
available
version
tipi_version
short_desc
author
source
categories
url_suffix
https
exposable
no_gui
form_fields {
type
label
max
min
hint
placeholder
required
env_variable
}
}
}
}

View File

@ -1,21 +0,0 @@
query InstalledApps {
installedApps {
id
status
config
version
updateInfo {
current
latest
dockerVersion
}
info {
id
name
description
tipi_version
short_desc
https
}
}
}

View File

@ -1,3 +0,0 @@
query Configured {
isConfigured
}

View File

@ -1,18 +0,0 @@
# Write your query or mutation here
query ListApps {
listAppsInfo {
apps {
id
available
tipi_version
port
name
version
short_desc
author
categories
https
}
total
}
}

View File

@ -1,5 +0,0 @@
query Me {
me {
id
}
}

View File

@ -1,5 +0,0 @@
query RefreshToken {
refreshToken {
token
}
}

View File

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

View File

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

View File

@ -1,32 +0,0 @@
import { useEffect, useState } from 'react';
import { ApolloClient } from '@apollo/client';
import { createApolloClient } from '../core/apollo/client';
interface IReturnProps {
client?: ApolloClient<unknown>;
isLoadingComplete?: boolean;
}
export default function useCachedResources(): IReturnProps {
const [isLoadingComplete, setLoadingComplete] = useState(false);
const [client, setClient] = useState<ApolloClient<unknown>>();
async function loadResourcesAndDataAsync() {
try {
const restoredClient = createApolloClient();
setClient(restoredClient);
} catch (error) {
// We might want to provide this error information to an error reporting service
console.error(error);
} finally {
setLoadingComplete(true);
}
}
useEffect(() => {
loadResourcesAndDataAsync();
}, []);
return { client, isLoadingComplete };
}

View File

@ -1,133 +0,0 @@
import { graphql, rest } from 'msw';
import {
ConfiguredQuery,
LoginMutation,
LogoutMutationResult,
MeQuery,
RefreshTokenQuery,
RegisterMutation,
RegisterMutationVariables,
UsernamePasswordInput,
VersionQuery,
SystemInfoQuery,
} from '../generated/graphql';
import appHandlers from './handlers/appHandlers';
const restHandlers = [
rest.get('/api/status', (req, res, ctx) =>
res(
ctx.delay(200),
ctx.status(200),
ctx.json({
status: 'RUNNING',
}),
),
),
];
const graphqlHandlers = [
// Handles a "Login" mutation
graphql.mutation('Login', (req, res, ctx) => {
const { username } = req.variables as UsernamePasswordInput;
sessionStorage.setItem('is-authenticated', username);
const result: LoginMutation = {
login: { token: 'token' },
};
return res(ctx.delay(), ctx.data(result));
}),
// Handles a "Logout" mutation
graphql.mutation('Logout', (req, res, ctx) => {
sessionStorage.removeItem('is-authenticated');
const result: LogoutMutationResult['data'] = {
logout: true,
};
return res(ctx.delay(), ctx.data(result));
}),
// Handles me query
graphql.query('Me', (req, res, ctx) => {
const isAuthenticated = sessionStorage.getItem('is-authenticated');
if (!isAuthenticated) {
return res(ctx.errors([{ message: 'Not authenticated' }]));
}
const result: MeQuery = {
me: { id: '1' },
};
return res(ctx.delay(), ctx.data(result));
}),
graphql.query('RefreshToken', (req, res, ctx) => {
const result: RefreshTokenQuery = {
refreshToken: { token: 'token' },
};
return res(ctx.delay(), ctx.data(result));
}),
graphql.mutation('Register', (req, res, ctx) => {
const {
input: { username },
} = req.variables as RegisterMutationVariables;
const result: RegisterMutation = {
register: { token: 'token' },
};
if (username === 'error@error.com') {
return res(ctx.errors([{ message: 'Username is already taken' }]));
}
return res(ctx.data(result));
}),
appHandlers.listApps,
appHandlers.getApp,
appHandlers.installedApps,
appHandlers.installApp,
graphql.query('Version', (req, res, ctx) => {
const result: VersionQuery = {
version: {
current: '1.0.0',
latest: '1.0.0',
},
};
return res(ctx.data(result));
}),
graphql.query('Configured', (req, res, ctx) => {
const result: ConfiguredQuery = {
isConfigured: true,
};
return res(ctx.data(result));
}),
graphql.query('SystemInfo', (req, res, ctx) => {
const result: SystemInfoQuery = {
systemInfo: {
cpu: {
load: 50,
},
disk: {
available: 1000000000,
total: 2000000000,
used: 1000000000,
},
memory: {
available: 1000000000,
total: 2000000000,
used: 1000000000,
},
},
};
return res(ctx.data(result));
}),
];
export const handlers = [...graphqlHandlers, ...restHandlers];

View File

@ -1,173 +0,0 @@
import { graphql } from 'msw';
import { faker } from '@faker-js/faker';
import { createAppsRandomly } from '../fixtures/app.fixtures';
import { AppInputType, AppStatusEnum, GetAppQuery, InstallAppMutation, InstalledAppsQuery, ListAppsQuery } from '../../generated/graphql';
// eslint-disable-next-line no-promise-executor-return
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const notEmpty = <TValue>(value: TValue | null | undefined): value is TValue => value !== null && value !== undefined;
const removeDuplicates = <T extends { id: string }>(array: T[]) =>
array.filter((a, i) => {
const index = array.findIndex((_a) => _a.id === a.id);
return index === i;
});
export const mockedApps = removeDuplicates(createAppsRandomly(faker.datatype.number({ min: 20, max: 30 })));
export const mockInstalledAppIds = mockedApps.slice(0, faker.datatype.number({ min: 5, max: 8 })).map((a) => a.id);
const stoppedAppsIds = mockInstalledAppIds.slice(0, faker.datatype.number({ min: 1, max: 3 }));
/**
* GetApp handler
*/
const getApp = graphql.query('GetApp', (req, res, ctx) => {
const { appId } = req.variables as { appId: string };
const app = mockedApps.find((a) => a.id === appId);
if (!app) {
return res(ctx.errors([{ message: 'App not found' }]));
}
const isInstalled = mockInstalledAppIds.includes(appId);
let status = AppStatusEnum.Missing;
if (isInstalled) {
status = AppStatusEnum.Running;
}
if (isInstalled && stoppedAppsIds.includes(appId)) {
status = AppStatusEnum.Stopped;
}
const result: GetAppQuery = {
getApp: {
id: app.id,
status,
info: app,
__typename: 'App',
config: {},
exposed: false,
updateInfo: null,
domain: null,
version: 1,
},
};
return res(ctx.data(result));
});
const getAppError = graphql.query('GetApp', (req, res, ctx) => res(ctx.errors([{ message: 'test-error' }])));
/**
* ListApps handler
*/
const listApps = graphql.query('ListApps', async (req, res, ctx) => {
const result: ListAppsQuery = {
listAppsInfo: {
apps: mockedApps,
total: mockedApps.length,
},
};
await wait(100);
return res(ctx.data(result));
});
const listAppsEmpty = graphql.query('ListApps', (req, res, ctx) => {
const result: ListAppsQuery = {
listAppsInfo: {
apps: [],
total: 0,
},
};
return res(ctx.data(result));
});
const listAppsError = graphql.query('ListApps', (req, res, ctx) => res(ctx.errors([{ message: 'test-error' }])));
/**
* InstalledApps handler
*/
const installedApps = graphql.query('InstalledApps', (req, res, ctx) => {
const apps: InstalledAppsQuery['installedApps'] = mockInstalledAppIds
.map((id) => {
const app = mockedApps.find((a) => a.id === id);
if (!app) return null;
let status = AppStatusEnum.Running;
if (stoppedAppsIds.includes(id)) {
status = AppStatusEnum.Stopped;
}
return {
__typename: 'App' as const,
id: app.id,
status,
config: {},
info: app,
version: 1,
updateInfo: null,
};
})
.filter(notEmpty);
const result: InstalledAppsQuery = {
installedApps: apps,
};
return res(ctx.data(result));
});
const installedAppsEmpty = graphql.query('InstalledApps', (req, res, ctx) => {
const result: InstalledAppsQuery = {
installedApps: [],
};
return res(ctx.data(result));
});
const installedAppsError = graphql.query('InstalledApps', (req, res, ctx) => res(ctx.errors([{ message: 'test-error' }])));
const installedAppsNoInfo = graphql.query('InstalledApps', (req, res, ctx) => {
const result: InstalledAppsQuery = {
installedApps: [
{
__typename: 'App' as const,
id: 'app-id',
status: AppStatusEnum.Running,
config: {},
info: null,
version: 1,
updateInfo: null,
},
],
};
return res(ctx.data(result));
});
/**
* Install app handler
*/
const installApp = graphql.mutation('InstallApp', (req, res, ctx) => {
const { input } = req.variables as { input: AppInputType };
const app = mockedApps.find((a) => a.id === input.id);
if (!app) {
return res(ctx.errors([{ message: 'App not found' }]));
}
const result: InstallAppMutation = {
installApp: {
__typename: 'App' as const,
id: app.id,
status: AppStatusEnum.Running,
},
};
return res(ctx.data(result));
});
export default { getApp, getAppError, listApps, listAppsEmpty, listAppsError, installedApps, installedAppsEmpty, installedAppsError, installedAppsNoInfo, installApp };

View File

@ -1,22 +0,0 @@
import React from 'react';
import { InstallForm } from '../InstallForm';
import { AppInfo } from '../../../../generated/graphql';
import { Modal, ModalBody, ModalHeader } from '../../../../components/ui/Modal';
interface IProps {
app: Pick<AppInfo, 'name' | 'form_fields' | 'exposable'>;
isOpen: boolean;
onClose: () => void;
onSubmit: (values: Record<string, any>) => void;
}
export const InstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onSubmit }) => (
<Modal onClose={onClose} isOpen={isOpen}>
<ModalHeader>
<h5 className="modal-title">Install {app.name}</h5>
</ModalHeader>
<ModalBody>
<InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} />
</ModalBody>
</Modal>
);

View File

@ -1,25 +0,0 @@
import React from 'react';
import { InstallForm } from './InstallForm';
import { App, AppInfo } from '../../../generated/graphql';
import { Modal, ModalBody, ModalHeader } from '../../../components/ui/Modal';
interface IProps {
app: AppInfo;
config: App['config'];
isOpen: boolean;
exposed?: boolean;
domain?: string;
onClose: () => void;
onSubmit: (values: Record<string, any>) => void;
}
export const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit, exposed, domain }) => (
<Modal onClose={onClose} isOpen={isOpen}>
<ModalHeader>
<h5 className="modal-title">Update {app.name} config</h5>
</ModalHeader>
<ModalBody>
<InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} initalValues={{ ...config, exposed, domain }} />
</ModalBody>
</Modal>
);

View File

@ -1,153 +0,0 @@
import { graphql } from 'msw';
import React from 'react';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
import { AppStatusEnum } from '../../../../generated/graphql';
import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
import { server } from '../../../../mocks/server';
import { useToastStore } from '../../../../state/toastStore';
import { AppDetailsContainer } from './AppDetailsContainer';
describe('Test: AppDetailsContainer', () => {
describe('Test: UI', () => {
it('should render', async () => {
// Arrange
const app = createAppEntity({});
render(<AppDetailsContainer app={app} info={app.info} />);
// Assert
expect(screen.getByText(app.info.short_desc)).toBeInTheDocument();
});
it('should display update button when update is available', async () => {
// Arrange
const app = createAppEntity({ overrides: { updateInfo: { current: 2, latest: 3 } } });
render(<AppDetailsContainer app={app} info={app.info} />);
// Assert
expect(screen.getByTestId('action-button-update')).toBeInTheDocument();
});
it('should display install button when app is not installed', async () => {
// Arrange
const app = createAppEntity({ overrides: { status: AppStatusEnum.Missing } });
render(<AppDetailsContainer app={app} info={app.info} />);
// Assert
expect(screen.getByTestId('action-button-install')).toBeInTheDocument();
});
it('should display uninstall and start button when app is stopped', async () => {
// Arrange
const app = createAppEntity({ overrides: { status: AppStatusEnum.Stopped } });
render(<AppDetailsContainer app={app} info={app.info} />);
// Assert
expect(screen.getByTestId('action-button-remove')).toBeInTheDocument();
expect(screen.getByTestId('action-button-start')).toBeInTheDocument();
});
it('should display stop, open and settings buttons when app is running', async () => {
// Arrange
const app = createAppEntity({ overrides: { status: AppStatusEnum.Running } });
render(<AppDetailsContainer app={app} info={app.info} />);
// Assert
expect(screen.getByTestId('action-button-stop')).toBeInTheDocument();
expect(screen.getByTestId('action-button-open')).toBeInTheDocument();
expect(screen.getByTestId('action-button-settings')).toBeInTheDocument();
});
it('should not display update button when update is not available', async () => {
// Arrange
const app = createAppEntity({ overrides: { updateInfo: { current: 3, latest: 3 } } });
render(<AppDetailsContainer app={app} info={app.info} />);
// Assert
expect(screen.queryByTestId('action-button-update')).not.toBeInTheDocument();
});
it('should not display open button when app has no_gui set to true', async () => {
// Arrange
const app = createAppEntity({ overridesInfo: { no_gui: true } });
render(<AppDetailsContainer app={app} info={app.info} />);
// Assert
expect(screen.queryByTestId('action-button-open')).not.toBeInTheDocument();
});
});
describe('Test: Open app', () => {
it('should call window.open with the correct url when open button is clicked', async () => {
// Arrange
const app = createAppEntity({});
const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
render(<AppDetailsContainer app={app} info={app.info} />);
// Act
const openButton = screen.getByTestId('action-button-open');
openButton.click();
// Assert
expect(spy).toHaveBeenCalledWith(`http://localhost:${app.info.port}`, '_blank', 'noreferrer');
});
it('should open with https when app info has https set to true', async () => {
// Arrange
const app = createAppEntity({ overridesInfo: { https: true } });
const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
render(<AppDetailsContainer app={app} info={app.info} />);
// Act
const openButton = screen.getByTestId('action-button-open');
openButton.click();
// Assert
expect(spy).toHaveBeenCalledWith(`https://localhost:${app.info.port}`, '_blank', 'noreferrer');
});
});
describe('Test: Install app', () => {
const installFn = jest.fn();
const fakeInstallHandler = graphql.mutation('InstallApp', (req, res, ctx) => {
installFn(req.variables);
return res(ctx.data({ installApp: { id: 'id', status: '', __typename: '' } }));
});
it('should call install mutation when install form is submitted', async () => {
// Arrange
server.use(fakeInstallHandler);
const app = createAppEntity({ overrides: { status: AppStatusEnum.Missing } });
render(<AppDetailsContainer app={app} info={app.info} />);
// Act
const installForm = screen.getByTestId('install-form');
fireEvent.submit(installForm);
await waitFor(() => {
expect(installFn).toHaveBeenCalledWith({
input: { id: app.id, form: {}, exposed: false, domain: '' },
});
});
});
it('should display a toast error when install mutation fails', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
server.use(graphql.mutation('InstallApp', (req, res, ctx) => res(ctx.errors([{ message: 'my big error' }]))));
const app = createAppEntity({ overrides: { status: AppStatusEnum.Missing } });
render(<AppDetailsContainer app={app} info={app.info} />);
// Act
const installForm = screen.getByTestId('install-form');
fireEvent.submit(installForm);
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].description).toEqual('my big error');
expect(result.current.toasts[0].status).toEqual('error');
});
});
});
});

View File

@ -1,196 +0,0 @@
import React from 'react';
import { useDisclosure } from '../../../../hooks/useDisclosure';
import { useToastStore } from '../../../../state/toastStore';
import { AppLogo } from '../../../../components/AppLogo/AppLogo';
import { AppStatus } from '../../../../components/AppStatus';
import {
App,
AppInfo,
AppStatusEnum,
GetAppDocument,
InstalledAppsDocument,
useInstallAppMutation,
useStartAppMutation,
useStopAppMutation,
useUninstallAppMutation,
useUpdateAppConfigMutation,
useUpdateAppMutation,
} from '../../../../generated/graphql';
import { AppActions } from '../../components/AppActions';
import { AppDetailsTabs } from '../../components/AppDetailsTabs';
import { InstallModal } from '../../components/InstallModal';
import { StopModal } from '../../components/StopModal';
import { UninstallModal } from '../../components/UninstallModal';
import { UpdateModal } from '../../components/UpdateModal';
import { UpdateSettingsModal } from '../../components/UpdateSettingsModal';
import { FormValues } from '../../components/InstallForm/InstallForm';
interface IProps {
app: Pick<App, 'id' | 'updateInfo' | 'config' | 'exposed' | 'domain' | 'status'>;
info: AppInfo;
}
export const AppDetailsContainer: React.FC<IProps> = ({ app, info }) => {
const { addToast } = useToastStore();
const installDisclosure = useDisclosure();
const uninstallDisclosure = useDisclosure();
const stopDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const updateSettingsDisclosure = useDisclosure();
// Mutations
const [install] = useInstallAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }, { query: InstalledAppsDocument }] });
const [update] = useUpdateAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
const [uninstall] = useUninstallAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }, { query: InstalledAppsDocument }] });
const [stop] = useStopAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
const [start] = useStartAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
const [updateConfig] = useUpdateAppConfigMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
const updateAvailable = Number(app?.updateInfo?.current || 0) < Number(app?.updateInfo?.latest);
const handleError = (error: unknown) => {
if (error instanceof Error) {
addToast({
title: 'Error',
description: error.message,
status: 'error',
position: 'top',
isClosable: true,
});
}
};
const handleInstallSubmit = async (values: FormValues) => {
installDisclosure.close();
const { exposed, domain, ...form } = values;
try {
await install({
variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } },
optimisticResponse: { installApp: { id: info.id, status: AppStatusEnum.Installing, __typename: 'App' } },
});
} catch (error) {
handleError(error);
}
};
const handleUnistallSubmit = async () => {
uninstallDisclosure.close();
try {
await uninstall({ variables: { id: info.id }, optimisticResponse: { uninstallApp: { id: info.id, status: AppStatusEnum.Uninstalling, __typename: 'App' } } });
} catch (error) {
handleError(error);
}
};
const handleStopSubmit = async () => {
stopDisclosure.close();
try {
await stop({ variables: { id: info.id }, optimisticResponse: { stopApp: { id: info.id, status: AppStatusEnum.Stopping, __typename: 'App' } } });
} catch (error) {
handleError(error);
}
};
const handleStartSubmit = async () => {
try {
await start({ variables: { id: info.id }, optimisticResponse: { startApp: { id: info.id, status: AppStatusEnum.Starting, __typename: 'App' } } });
} catch (e: unknown) {
handleError(e);
}
};
const handleUpdateSettingsSubmit = async (values: FormValues) => {
try {
const { exposed, domain, ...form } = values;
await updateConfig({ variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } } });
addToast({
title: 'Success',
description: 'App config updated successfully. Restart the app to apply the changes.',
position: 'top',
status: 'success',
isClosable: true,
});
updateSettingsDisclosure.close();
} catch (error) {
handleError(error);
}
};
const handleUpdateSubmit = async () => {
updateDisclosure.close();
try {
await update({ variables: { id: info.id }, optimisticResponse: { updateApp: { id: info.id, status: AppStatusEnum.Updating, __typename: 'App' } } });
addToast({
title: 'Success',
description: 'App updated successfully',
position: 'top',
status: 'success',
isClosable: true,
});
} catch (error) {
handleError(error);
}
};
const handleOpen = () => {
const { https } = info;
const protocol = https ? 'https' : 'http';
if (typeof window !== 'undefined') {
// Current domain
const domain = window.location.hostname;
window.open(`${protocol}://${domain}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
}
};
const newVersion = [app?.updateInfo?.dockerVersion ? `${app?.updateInfo?.dockerVersion}` : '', `(${String(app?.updateInfo?.latest)})`].join(' ');
return (
<div className="card" data-testid="app-details">
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} app={info} />
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} app={info} />
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} app={info} />
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} app={info} newVersion={newVersion} />
<UpdateSettingsModal
onSubmit={handleUpdateSettingsSubmit}
isOpen={updateSettingsDisclosure.isOpen}
onClose={updateSettingsDisclosure.close}
app={info}
config={app?.config}
exposed={app?.exposed}
domain={app?.domain || ''}
/>
<div className="card-header d-flex flex-column flex-md-row">
<AppLogo id={info.id} size={130} alt={info.name} />
<div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
<div>
<span className="mt-1 me-1">Version: </span>
<span className="badge bg-gray mt-2">{info?.version}</span>
</div>
{app.domain && (
<a target="_blank" rel="noreferrer" className="mt-1" href={`https://${app.domain}`}>
https://{app.domain}
</a>
)}
<span className="mt-1 text-muted text-center mb-2">{info.short_desc}</span>
<div className="mb-1">{app && app?.status !== AppStatusEnum.Missing && <AppStatus status={app.status} />}</div>
<AppActions
updateAvailable={updateAvailable}
onUpdate={updateDisclosure.open}
onUpdateSettings={updateSettingsDisclosure.open}
onStop={stopDisclosure.open}
onCancel={stopDisclosure.open}
onUninstall={uninstallDisclosure.open}
onInstall={installDisclosure.open}
onOpen={handleOpen}
onStart={handleStartSubmit}
app={info}
status={app?.status}
/>
</div>
</div>
<AppDetailsTabs info={info} />
</div>
);
};

View File

@ -1,51 +0,0 @@
import { useApolloClient } from '@apollo/client';
import React, { useState } from 'react';
import { useLoginMutation } from '../../../../generated/graphql';
import { useToastStore } from '../../../../state/toastStore';
import { AuthFormLayout } from '../../components/AuthFormLayout';
import { LoginForm } from '../../components/LoginForm';
type FormValues = { email: string; password: string };
export const LoginContainer: React.FC = () => {
const client = useApolloClient();
const [login] = useLoginMutation({});
const [loading, setLoading] = useState(false);
const { addToast } = useToastStore();
const handleError = (error: unknown) => {
localStorage.removeItem('token');
if (error instanceof Error) {
addToast({
title: 'Error',
description: error.message,
status: 'error',
position: 'top',
isClosable: true,
});
}
};
const handleLogin = async (values: FormValues) => {
try {
setLoading(true);
const { data } = await login({ variables: { input: { username: values.email, password: values.password } } });
if (data?.login?.token) {
localStorage.setItem('token', data.login.token);
}
await client.refetchQueries({ include: ['Me'] });
} catch (error) {
handleError(error);
} finally {
setLoading(false);
}
};
return (
<AuthFormLayout>
<LoginForm onSubmit={handleLogin} loading={loading} />
</AuthFormLayout>
);
};

View File

@ -1,48 +0,0 @@
import router from 'next/router';
import React, { useState } from 'react';
import { useRegisterMutation } from '../../../../generated/graphql';
import { useToastStore } from '../../../../state/toastStore';
import { AuthFormLayout } from '../../components/AuthFormLayout';
import { RegisterForm } from '../../components/RegisterForm';
export const RegisterContainer: React.FC = () => {
const { addToast } = useToastStore();
const [register] = useRegisterMutation({ refetchQueries: ['Me'] });
const [loading, setLoading] = useState(false);
const handleError = (error: unknown) => {
if (error instanceof Error) {
addToast({
title: 'Error',
description: error.message,
status: 'error',
position: 'top',
isClosable: true,
});
}
};
const handleRegister = async (values: { email: string; password: string }) => {
try {
setLoading(true);
const { data } = await register({ variables: { input: { username: values.email, password: values.password } } });
if (data?.register?.token) {
localStorage.setItem('token', data.register.token);
router.reload();
} else {
setLoading(false);
handleError(new Error('Something went wrong'));
}
} catch (error) {
setLoading(false);
handleError(error);
}
};
return (
<AuthFormLayout>
<RegisterForm onSubmit={handleRegister} loading={loading} />
</AuthFormLayout>
);
};

View File

@ -1,14 +0,0 @@
import React from 'react';
import type { NextPage } from 'next';
import { Layout } from '../../../../components/Layout';
import Dashboard from '../../containers/Dashboard';
import { useSystemInfoQuery } from '../../../../generated/graphql';
export const DashboardPage: NextPage = () => {
const { data, loading } = useSystemInfoQuery({ pollInterval: 10000 });
return (
<Layout title="Dashboard" loading={loading && !data}>
{data?.systemInfo && <Dashboard data={data.systemInfo} />}
</Layout>
);
};

View File

@ -1,122 +0,0 @@
import { faker } from '@faker-js/faker';
import { graphql } from 'msw';
import React from 'react';
import { act, fireEvent, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
import { server } from '../../../../mocks/server';
import { useToastStore } from '../../../../state/toastStore';
import { SettingsContainer } from './SettingsContainer';
describe('Test: SettingsContainer', () => {
it('renders without crashing', () => {
const currentVersion = faker.system.semver();
render(<SettingsContainer currentVersion={currentVersion} latestVersion={currentVersion} />);
expect(screen.getByText('Tipi settings')).toBeInTheDocument();
expect(screen.getByText('Already up to date')).toBeInTheDocument();
});
it('should make update button disable if current version is equal to latest version', () => {
const currentVersion = faker.system.semver();
render(<SettingsContainer currentVersion={currentVersion} latestVersion={currentVersion} />);
expect(screen.getByText('Already up to date')).toBeDisabled();
});
it('should make update button disabled if current version is greater than latest version', () => {
const currentVersion = '1.0.0';
const latestVersion = '0.0.1';
render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
expect(screen.getByText('Already up to date')).toBeDisabled();
});
it('should display update button if current version is less than latest version', () => {
const currentVersion = '0.0.1';
const latestVersion = '1.0.0';
render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
expect(screen.getByText(`Update to ${latestVersion}`)).toBeInTheDocument();
expect(screen.getByText(`Update to ${latestVersion}`)).not.toBeDisabled();
});
it('should call update mutation when update button is clicked', async () => {
// Arrange
localStorage.setItem('token', 'token');
const currentVersion = '0.0.1';
const latestVersion = '1.0.0';
const updateFn = jest.fn();
server.use(
graphql.mutation('Update', async (req, res, ctx) => {
updateFn();
return res(ctx.data({ update: true }));
}),
);
render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
// Act
act(() => screen.getByText(`Update to ${latestVersion}`).click());
fireEvent.click(screen.getByText('Update'));
waitFor(() => expect(updateFn).toHaveBeenCalled());
// eslint-disable-next-line no-promise-executor-return
await act(() => new Promise((resolve) => setTimeout(resolve, 1500)));
// Assert
const token = localStorage.getItem('token');
expect(token).toBe(null);
});
it('should display error toast if update mutation fails', async () => {
// Arrange
const { result, unmount } = renderHook(() => useToastStore());
const currentVersion = '0.0.1';
const latestVersion = '1.0.0';
const errorMessage = 'My error';
server.use(graphql.mutation('Update', async (req, res, ctx) => res(ctx.errors([{ message: errorMessage }]))));
render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
// Act
act(() => screen.getByText(`Update to ${latestVersion}`).click());
fireEvent.click(screen.getByText('Update'));
// Assert
await waitFor(() => expect(result.current.toasts[0].description).toBe(errorMessage));
unmount();
});
it('should call restart mutation when restart button is clicked', async () => {
// Arrange
const restartFn = jest.fn();
server.use(
graphql.mutation('Restart', async (req, res, ctx) => {
restartFn();
return res(ctx.data({ restart: true }));
}),
);
render(<SettingsContainer currentVersion="1.0.0" latestVersion="1.0.0" />);
// Act
fireEvent.click(screen.getByTestId('settings-modal-restart-button'));
waitFor(() => expect(restartFn).toHaveBeenCalled());
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, 1500));
// Assert
const token = localStorage.getItem('token');
expect(token).toBe(null);
});
it('should display error toast if restart mutation fails', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
const errorMessage = 'Update error';
server.use(graphql.mutation('Restart', async (req, res, ctx) => res(ctx.errors([{ message: errorMessage }]))));
render(<SettingsContainer currentVersion="1.0.0" latestVersion="1.0.0" />);
// Act
fireEvent.click(screen.getByTestId('settings-modal-restart-button'));
// Assert
await waitFor(() => expect(result.current.toasts[0].description).toBe(errorMessage));
});
});

View File

@ -1,21 +0,0 @@
import { graphql } from 'msw';
import React from 'react';
import { render, screen, waitFor } from '../../../../../tests/test-utils';
import { server } from '../../../../mocks/server';
import { SettingsPage } from './SettingsPage';
describe('Test: SettingsPage', () => {
it('should render', async () => {
render(<SettingsPage />);
await waitFor(() => expect(screen.getByText('Tipi settings')).toBeInTheDocument());
});
it('should render error page if version query fails', async () => {
server.use(graphql.query('Version', (req, res, ctx) => res(ctx.errors([{ message: 'My error' }]))));
render(<SettingsPage />);
await waitFor(() => expect(screen.getByText('My error')).toBeInTheDocument());
});
});

View File

@ -1,54 +0,0 @@
import React, { useEffect } from 'react';
import type { AppProps } from 'next/app';
import { ApolloProvider } from '@apollo/client';
import Head from 'next/head';
import useCachedResources from '../hooks/useCachedRessources';
import '../styles/global.css';
import '../styles/global.scss';
import { useUIStore } from '../state/uiStore';
import { ToastProvider } from '../components/hoc/ToastProvider';
import { StatusProvider } from '../components/hoc/StatusProvider';
import { AuthProvider } from '../components/hoc/AuthProvider';
import { StatusScreen } from '../components/StatusScreen';
function MyApp({ Component, pageProps }: AppProps) {
const { setDarkMode } = useUIStore();
// check theme on component mount
useEffect(() => {
const themeCheck = () => {
if (localStorage.darkMode === 'true' || (!('darkMode' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.body.classList.add('theme-dark');
setDarkMode(true);
} else {
document.body.classList.remove('theme-light');
setDarkMode(false);
}
};
themeCheck();
}, [setDarkMode]);
const { client } = useCachedResources();
if (!client) {
return <StatusScreen title="" subtitle="" />;
}
return (
<main className="h-100">
<ApolloProvider client={client}>
<Head>
<title>Tipi</title>
</Head>
<ToastProvider>
<StatusProvider>
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
</StatusProvider>
</ToastProvider>
</ApolloProvider>
</main>
);
}
export default MyApp;

View File

@ -1 +0,0 @@
export { AppDetailsPage as default } from '../../modules/Apps/pages/AppDetailsPage';

View File

@ -1 +0,0 @@
export { AppStorePage as default } from '../../modules/AppStore/pages/AppStorePage';

View File

@ -1 +0,0 @@
export { AppDetailsPage as default } from '../../modules/Apps/pages/AppDetailsPage';

View File

@ -1 +0,0 @@
export { AppsPage as default } from '../../modules/Apps/pages/AppsPage';

View File

@ -1 +0,0 @@
export { DashboardPage as default } from '../modules/Dashboard/pages/DashboardPage';

View File

@ -1 +0,0 @@
export { SettingsPage as default } from '../modules/Settings/pages/SettingsPage';

View File

@ -1,17 +0,0 @@
import create from 'zustand';
export enum SystemStatus {
RUNNING = 'RUNNING',
RESTARTING = 'RESTARTING',
UPDATING = 'UPDATING',
}
type Store = {
status: SystemStatus;
setStatus: (status: SystemStatus) => void;
};
export const useSystemStore = create<Store>((set) => ({
status: SystemStatus.RUNNING,
setStatus: (status: SystemStatus) => set((state) => ({ ...state, status })),
}));

View File

@ -1,31 +0,0 @@
import React, { FC, ReactElement } from 'react';
import { render, RenderOptions, renderHook } from '@testing-library/react';
import { ApolloClient, ApolloProvider, HttpLink, InMemoryCache } from '@apollo/client';
import fetch from 'isomorphic-fetch';
import { SWRConfig } from 'swr';
const link = new HttpLink({
uri: 'http://localhost:3000/graphql',
// Use explicit `window.fetch` so tha outgoing requests
// are captured and deferred until the Service Worker is ready.
fetch: (...args) => fetch(...args),
});
// create a mock of Apollo Client
export const mockApolloClient = new ApolloClient({
cache: new InMemoryCache({}),
link,
});
const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => (
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
<ApolloProvider client={mockApolloClient}>{children}</ApolloProvider>
</SWRConfig>
);
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => render(ui, { wrapper: AllTheProviders, ...options });
const customRenderHook = (callback: () => any, options?: Omit<RenderOptions, 'wrapper'>) => renderHook(callback, { wrapper: AllTheProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };
export { customRenderHook as renderHook };

View File

@ -1,23 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"strictNullChecks": true,
"allowSyntheticDefaultImports": true,
"types": ["jest", "@testing-library/jest-dom"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@ -1,4 +0,0 @@
node_modules/
dist/
sessions/
logs/

Some files were not shown because too many files have changed in this diff Show More