Merge pull request #276 from meienberger/release/0.8.0

Release/0.8.0
This commit is contained in:
Nicolas Meienberger 2022-12-18 15:25:44 +01:00 committed by GitHub
commit 3ab108c919
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
237 changed files with 7803 additions and 4497 deletions

View file

@ -1,5 +1,12 @@
FROM node:18 AS builder
FROM node:18-alpine3.16 AS builder
# Required for argon2
RUN apk --no-cache add g++
RUN apk --no-cache add make
RUN apk --no-cache add python3
# Required for sharp
RUN apk --no-cache add vips-dev=8.12.2-r5
RUN npm install node-gyp -g
WORKDIR /api
@ -18,23 +25,12 @@ WORKDIR /dashboard
COPY ./packages/dashboard /dashboard
RUN npm run build
FROM alpine:3.16.0 as app
FROM node:18-alpine3.16 as app
WORKDIR /
# # Install dependencies
RUN apk --no-cache add nodejs npm
RUN apk --no-cache add g++
RUN apk --no-cache add make
RUN apk --no-cache add python3
RUN npm install node-gyp -g
WORKDIR /api
COPY ./packages/system-api/package*.json /api/
RUN npm install --omit=dev
COPY ./packages/system-api/package.json /api/
COPY --from=builder /api/dist /api/dist
WORKDIR /dashboard

View file

@ -23,13 +23,14 @@
> ⚠️ Tipi is still at an early stage of development and issues are to be expected. Feel free to open an issue or pull request if you find a bug.
Tipi is a personal homeserver orchestrator. It is running docker containers under the hood and provides a simple web interface to manage them. Every service comes with an opinionated configuration in order to remove the need for manual configuration and network setup.
Tipi is a personal homeserver orchestrator that makes it easy to manage and run multiple services on a single server. It is based on Docker and comes with a simple web interface to manage your services. Tipi is designed to be easy to use, so you don't have to worry about manual configuration or networking. Simply install Tipi on your server and use the web interface to add and manage services. You can see a list of available services in the [App Store repo](https://github.com/meienberger/runtipi-appstore) and request new ones if you don't see what you need. To get started, follow the installation instructions below.
Check our demo instance : **[demo.runtipi.com](https://demo.runtipi.com)** / username: **user@runtipi.com** / password: **runtipi**
## Demo
## Apps available
You can try out a demo of Tipi at [demo.runtipi.com](demo.runtipi.com) using the following credentials:
See the list of apps available and submit your requests in the [App Store repo](https://github.com/meienberger/runtipi-appstore)
username: user@runtipi.com
password: runtipi
## 🛠 Installation
@ -37,24 +38,25 @@ See the list of apps available and submit your requests in the [App Store repo](
Ubuntu 18.04 LTS or higher is recommended. However other major Linux distribution are supported but may lead to installation issues. Please file an issue if you encounter one.
### Step 1. Download Tipi
### Download and install Tipi
Run this in an empty directory where you want to install Tipi.
Download the latest version of Tipi:
```bash
git clone https://github.com/meienberger/runtipi.git
curl -L https://setup.runtipi.com | bash
```
### Step 2. Run Tipi
The script will prompt you the ip address of the dashboard once configured.
cd into the downloaded directory and run the start script.
### Commands
If you already installed Tipi, you can start it manually by running the `start.sh` script in the `runtipi` folder.
```bash
cd runtipi
sudo ./scripts/start.sh
```
The script will prompt you the ip address of the dashboard once configured.
Tipi will run by default on port 80. To select another port you can run the start script with the `--port` argument
```bash
@ -67,9 +69,17 @@ To stop Tipi, run the stop script.
sudo ./scripts/stop.sh
```
### Update Tipi
To update Tipi to the latest version, run the update script.
```bash
sudo ./scripts/system.sh update
```
### Custom settings
You can change the default settings by creating a `settings.json` file. The file should be located in the `state` directory. This file will make your changes persist across restarts. Example file:
You can change the default settings by creating a `settings.json` file. The file should be located in the `runtipi/state` directory. This file will make your changes persist across restarts. Example file:
```json
{
@ -115,7 +125,7 @@ sudo rm -rf runtipi
## 📚 Documentation
For a detailed guide on how to install Tipi. This amazing article by @kycfree1 [Running a Home Server with Tipi](https://kyc3.life/running-a-home-server-with-tipi/)
For a detailed guide on how to install Tipi. This amazing article by @kycfree [Running a Home Server with Tipi](https://kyc3.life/running-a-home-server-with-tipi/)
You can find more documentation and tutorials / FAQ in the [Wiki](https://github.com/meienberger/runtipi/wiki).

View file

@ -24,7 +24,7 @@ services:
restart: unless-stopped
stop_grace_period: 1m
volumes:
- ./data/postgres:/var/lib/postgresql/data
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
environment:
@ -111,22 +111,11 @@ services:
# - /dashboard/.next
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
# 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
# Middlewares
traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
networks:
tipi_main_network:
@ -135,3 +124,6 @@ networks:
driver: default
config:
- subnet: 10.21.21.0/24
volumes:
pgdata:

View file

@ -106,33 +106,17 @@ services:
NODE_ENV: production
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:

151
docker-compose.test.yml Normal file
View file

@ -0,0 +1,151 @@
version: "3.7"
services:
reverse-proxy:
container_name: reverse-proxy
image: traefik:v2.8
restart: unless-stopped
ports:
- ${NGINX_PORT-80}:80
- ${NGINX_PORT_SSL-443}:443
command: --providers.docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ${PWD}/traefik:/root/.config
- ${PWD}/traefik/shared:/shared
networks:
- tipi_main_network
tipi-db:
container_name: tipi-db
image: postgres:14
restart: unless-stopped
stop_grace_period: 1m
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: tipi
POSTGRES_DB: tipi
healthcheck:
test: ["CMD-SHELL", "pg_isready -d tipi -U tipi"]
interval: 5s
timeout: 10s
retries: 120
networks:
- tipi_main_network
tipi-redis:
container_name: tipi-redis
image: redis:alpine
restart: unless-stopped
volumes:
- ./data/redis:/data
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
environment:
NODE_ENV: production
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.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.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:
driver: bridge
ipam:
driver: default
config:
- subnet: 10.21.21.0/24
volumes:
pgdata:

View file

@ -107,33 +107,17 @@ services:
NODE_ENV: production
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:

View file

@ -1,6 +1,6 @@
{
"name": "runtipi",
"version": "0.7.4",
"version": "0.8.0",
"description": "A homeserver for everyone",
"scripts": {
"prepare": "husky install",
@ -9,10 +9,11 @@
"act:docker": "act --container-architecture linux/amd64 --secret-file github.secrets -j build-images",
"start:dev": "./scripts/start-dev.sh",
"start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
"start:prod": "docker-compose --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",
"version": "echo $npm_package_version",
"release:rc": "./scripts/deploy/release-rc.sh"
"release:rc": "./scripts/deploy/release-rc.sh",
"test:build": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:test ."
},
"devDependencies": {
"@commitlint/cli": "^17.0.3",

View file

@ -1,5 +1,16 @@
module.exports = {
extends: ['next/core-web-vitals', 'airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript'],
plugins: ['@typescript-eslint', 'import', 'react', 'jest'],
extends: [
'plugin:@typescript-eslint/recommended',
'next/core-web-vitals',
'next',
'airbnb',
'airbnb-typescript',
'eslint:recommended',
'plugin:import/typescript',
'prettier',
'plugin:react/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
@ -7,14 +18,22 @@ module.exports = {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint', 'import'],
rules: {
'arrow-body-style': 0,
'no-restricted-exports': 0,
'max-len': [1, { code: 200 }],
'import/extensions': ['error', 'ignorePackages', { js: 'never', jsx: 'never', ts: 'never', tsx: 'never' }],
'react/display-name': 0,
'react/prop-types': 0,
'react/function-component-definition': 0,
'react/require-default-props': 0,
'import/prefer-default-export': 0,
'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/**'] }],
},
globals: {
JSX: true,
},
env: {
'jest/globals': true,
},
};

View file

@ -32,4 +32,4 @@ yarn-error.log*
.vercel
# typescript
*.tsbuildinfo
*.tsbuildinfo

View file

@ -1,11 +1,18 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
verbose: true,
// testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
// setupFiles: ['<rootDir>/tests/dotenv-config.ts'],
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}'],
// coverageProvider: 'v8',
passWithNoTests: 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

@ -2,7 +2,7 @@
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
basePath: '/dashboard',
swcMinify: true,
};
module.exports = nextConfig;

View file

@ -1,10 +1,11 @@
{
"name": "dashboard",
"version": "0.7.4",
"version": "0.8.0",
"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",
@ -13,55 +14,72 @@
},
"dependencies": {
"@apollo/client": "^3.6.8",
"@chakra-ui/react": "^2.1.2",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"@fontsource/open-sans": "^4.5.8",
"@hookform/resolvers": "^2.9.10",
"@tabler/core": "1.0.0-beta16",
"@tabler/icons": "^1.109.0",
"clsx": "^1.1.1",
"final-form": "^4.20.6",
"framer-motion": "^6",
"graphql": "^15.8.0",
"graphql-tag": "^2.12.6",
"next": "12.3.1",
"react": "18.1.0",
"react-dom": "18.1.0",
"react-final-form": "^6.5.9",
"react-icons": "^4.3.1",
"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.3.2",
"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",
"@types/js-cookie": "^3.0.2",
"@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/react-slick": "^0.23.8",
"@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",
"autoprefixer": "^10.4.4",
"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",
"postcss": "^8.4.12",
"tailwindcss": "^3.0.23",
"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"
"typescript": "4.6.4",
"whatwg-fetch": "^3.6.2"
},
"msw": {
"workerDirectory": "public"
}
}

View file

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" class="ant-empty-img-simple" width="64" height="41" viewBox="0 0 64 41" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 1)" fill="none" fill-rule="evenodd"><ellipse class="ant-empty-img-simple-ellipse" cx="32" cy="33" rx="32" ry="7" fill="#F5F5F5"></ellipse><g class="ant-empty-img-simple-g" fill-rule="nonzero" stroke="#D9D9D9" fill="none"><path d="M55 12.76L44.854 1.258C44.367.474 43.656 0 42.907 0H21.093c-.749 0-1.46.474-1.947 1.257L9 12.761V22h46v-9.24z" stroke="#D9D9D9" fill="none"></path><path d="M41.613 15.931c0-1.605.994-2.93 2.227-2.931H55v18.137C55 33.26 53.68 35 52.05 35h-40.1C10.32 35 9 33.259 9 31.137V13h11.16c1.233 0 2.227 1.323 2.227 2.928v.022c0 1.605 1.005 2.901 2.237 2.901h14.752c1.232 0 2.237-1.308 2.237-2.913v-.007z" class="ant-empty-img-simple-path" stroke="#D9D9D9" fill="#FAFAFA"></path></g></g></svg>

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View file

@ -0,0 +1,303 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker (0.49.1).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: INTEGRITY_CHECKSUM,
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return
}
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = Math.random().toString(16).slice(2)
event.respondWith(
handleRequest(event, requestId).catch((error) => {
if (error.name === 'NetworkError') {
console.warn(
'[MSW] Successfully emulated a network error for the "%s %s" request.',
request.method,
request.url,
)
return
}
// At this point, any exception indicates an issue with the original request/response.
console.error(
`\
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
request.method,
request.url,
`${error.name}: ${error.message}`,
)
}),
)
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: Object.fromEntries(clonedResponse.headers.entries()),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
const clonedRequest = request.clone()
function passthrough() {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const headers = Object.fromEntries(clonedRequest.headers.entries())
// Remove MSW-specific request headers so the bypassed requests
// comply with the server's CORS preflight check.
// Operate with the headers as an object because request "Headers"
// are immutable.
delete headers['x-msw-bypass']
return fetch(clonedRequest, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
if (request.headers.get('x-msw-bypass') === 'true') {
return passthrough()
}
// Notify the client that a request has been intercepted.
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.text(),
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'MOCK_NOT_FOUND': {
return passthrough()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.data
const networkError = new Error(message)
networkError.name = name
// Rejecting a "respondWith" promise emulates a network error.
throw networkError
}
}
return passthrough()
}
function sendToClient(client, message) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [channel.port2])
})
}
function sleep(timeMs) {
return new Promise((resolve) => {
setTimeout(resolve, timeMs)
})
}
async function respondWithMock(response) {
await sleep(response.delay)
return new Response(response.body, response)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

View file

@ -0,0 +1,3 @@
.dropShadow {
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06));
}

View file

@ -1,10 +1,17 @@
import clsx from 'clsx';
import React from 'react';
import { getUrl } from '../../core/helpers/url-helpers';
import styles from './AppLogo.module.scss';
const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
const logoUrl = `/api/apps/${id}/metadata/logo.jpg`;
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');
}
return (
<div aria-label={alt} className={`drop-shadow ${className}`} style={{ width: size, height: size }}>
<div aria-label={alt} className={clsx(styles.dropShadow, className)} style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200">
<path fillRule="evenodd" clipRule="evenodd" d="M0 100C0 0 0 0 100 0S200 0 200 100 200 200 100 200 0 200 0 100" fill="white" />
@ -14,5 +21,3 @@ const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: s
</div>
);
};
export default AppLogo;

View file

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

View file

@ -1 +0,0 @@
export * from './AppLogo';

View file

@ -0,0 +1,3 @@
.text {
margin-bottom: 1px;
}

View file

@ -0,0 +1,20 @@
import clsx from 'clsx';
import React from 'react';
import styles from './AppStatus.module.scss';
import { AppStatusEnum } from '../../generated/graphql';
export const AppStatus: React.FC<{ status: AppStatusEnum; lite?: boolean }> = ({ status, lite }) => {
const formattedStatus = `${status[0]}${status.substring(1, status.length).toLowerCase()}`;
const classes = clsx('status-dot status-gray', {
'status-dot-animated status-green': status === AppStatusEnum.Running,
'status-red': status === AppStatusEnum.Stopped,
});
return (
<div data-place="top" data-tip={lite && formattedStatus} className="d-flex align-items-center">
<span className={classes} />
{!lite && <span className={clsx(styles.text, 'ms-2 text-muted')}>{formattedStatus}</span>}
</div>
);
};

View file

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

View file

@ -1,33 +0,0 @@
import React from 'react';
import { FiPauseCircle, FiPlayCircle } from 'react-icons/fi';
import { RiLoader4Line } from 'react-icons/ri';
import { AppStatusEnum } from '../../generated/graphql';
const AppStatus: React.FC<{ status: AppStatusEnum }> = ({ status }) => {
if (status === AppStatusEnum.Running) {
return (
<>
<FiPlayCircle className="text-green-500 mr-1" size={20} />
<span className="text-gray-400 text-sm">Running</span>
</>
);
}
if (status === AppStatusEnum.Stopped) {
return (
<>
<FiPauseCircle className="text-red-500 mr-1" size={20} />
<span className="text-gray-400 text-sm">Stopped</span>
</>
);
}
return (
<>
<RiLoader4Line className="text-gray-500 mr-1" size={20} />
<span className="text-gray-400 text-sm">{`${status[0]}${status.substring(1, status.length).toLowerCase()}...`}</span>
</>
);
};
export default AppStatus;

View file

@ -0,0 +1,3 @@
.statusContainer {
margin-bottom: 4px;
}

View file

@ -0,0 +1,40 @@
import Link from 'next/link';
import React from 'react';
import { IconDownload } from '@tabler/icons';
import { AppStatus } from '../AppStatus';
import { AppLogo } from '../AppLogo/AppLogo';
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
import { AppInfo, AppStatusEnum } from '../../generated/graphql';
import styles from './AppTile.module.scss';
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => (
<div data-testid={`app-tile-${app.id}`} className="col-sm-6 col-lg-4">
<div className="card card-sm card-link">
<Link href={`/apps/${app.id}`} className="nav-link" passHref>
<div className="card-body">
<div className="d-flex align-items-center">
<span className="me-3">
<AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={60} />
</span>
<div>
<div className="d-flex h-3 align-items-center">
<span className="h4 me-2 mt-1 fw-bolder">{app.name}</span>
<div className={styles.statusContainer}>
<AppStatus lite status={status} />
</div>
</div>
<div className="text-muted">{limitText(app.short_desc, 50)}</div>
</div>
</div>
</div>
{updateAvailable && (
<div data-tip="Update available" className="ribbon bg-green ribbon-top">
<IconDownload />
</div>
)}
</Link>
</div>
</div>
);

View file

@ -1,44 +1 @@
import { Box, SlideFade, Tag, useColorModeValue, Tooltip } from '@chakra-ui/react';
import Link from 'next/link';
import React from 'react';
import { FiChevronRight } from 'react-icons/fi';
import { MdSystemUpdateAlt } from 'react-icons/md';
import AppStatus from './AppStatus';
import AppLogo from '../AppLogo/AppLogo';
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
import { AppInfo, AppStatusEnum } from '../../generated/graphql';
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
const bg = useColorModeValue('white', '#1a202c');
return (
<Link href={`/apps/${app.id}`} passHref>
<SlideFade in className="flex flex-1" offsetY="20px">
<Box bg={bg} className="flex flex-1 border-2 drop-shadow-sm rounded-lg p-3 items-center cursor-pointer group hover:drop-shadow-md transition-all">
<AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={100} />
<div className="mr-3 flex-1">
<div className="flex">
<h3 className="font-bold text-xl mr-2">{app.name}</h3>
{updateAvailable && (
<Tooltip label="Update available">
<Tag colorScheme="gray">
<MdSystemUpdateAlt size={15} />
</Tag>
</Tooltip>
)}
</div>
<span>{limitText(app.short_desc, 50)}</span>
<div className="flex mt-1">
<AppStatus status={status} />
</div>
</div>
<FiChevronRight className="text-slate-300" size={30} />
</Box>
</SlideFade>
</Link>
);
};
export default AppTile;
export { AppTile } from './AppTile';

View file

@ -1,27 +0,0 @@
import React from 'react';
import { Input } from '@chakra-ui/react';
import clsx from 'clsx';
interface IProps {
placeholder?: string;
error?: string;
type?: Parameters<typeof Input>[0]['type'];
label?: string;
className?: string;
isInvalid?: boolean;
size?: Parameters<typeof Input>[0]['size'];
hint?: string;
}
const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, hint, ...rest }) => {
return (
<div className={clsx('transition-all', className)}>
{label && <label className="mb-1">{label}</label>}
{hint && <div className="text-sm text-gray-500 mb-1">{hint}</div>}
<Input type={type} placeholder={placeholder} isInvalid={isInvalid} size={size} {...rest} />
{isInvalid && <span className="text-red-500 text-sm">{error}</span>}
</div>
);
};
export default FormInput;

View file

@ -1,23 +0,0 @@
import React from 'react';
import { Input, Switch } from '@chakra-ui/react';
import clsx from 'clsx';
interface IProps {
placeholder?: string;
type?: Parameters<typeof Input>[0]['type'];
label?: string;
className?: string;
size?: Parameters<typeof Input>[0]['size'];
checked?: boolean;
}
const FormSwitch: React.FC<IProps> = ({ placeholder, type, label, className, size, ...rest }) => {
return (
<div className={clsx('transition-all', className)}>
{label && <label className="mr-2">{label}</label>}
<Switch isChecked={rest.checked} type={type} placeholder={placeholder} size={size} {...rest} />
</div>
);
};
export default FormSwitch;

View file

@ -1,28 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { Flex } from '@chakra-ui/react';
import { FiMenu } from 'react-icons/fi';
import { getUrl } from '../../core/helpers/url-helpers';
interface IProps {
onClickMenu: () => void;
}
const Header: React.FC<IProps> = ({ onClickMenu }) => {
return (
<header style={{ width: '100%' }} className="flex h-12 md:h-0">
<Flex className="items-center border-b-2 bg-graycool px-5 flex-1 py-2">
<div onClick={onClickMenu} className="visible md:invisible absolute cursor-pointer py-2">
<FiMenu color="black" />
</div>
<Flex justifyContent="center" flex="1">
<Link href="/" passHref>
<img src={getUrl('tipi.png')} alt="Tipi Logo" width={30} height={30} />
</Link>
</Flex>
</Flex>
</header>
);
};
export default Header;

View file

@ -0,0 +1,3 @@
.topActions {
min-height: 50px;
}

View file

@ -1,81 +1,74 @@
import { Flex, useDisclosure, Spinner, Breadcrumb, BreadcrumbItem, useColorModeValue, Box } from '@chakra-ui/react';
import Head from 'next/head';
import Link from 'next/link';
import React, { useEffect } from 'react';
import { FiChevronRight } from 'react-icons/fi';
import Header from './Header';
import Menu from './SideMenu';
import MenuDrawer from './MenuDrawer';
import { useRefreshTokenQuery } from '../../generated/graphql';
import clsx from 'clsx';
import ReactTooltip from 'react-tooltip';
import semver from 'semver';
import { useRefreshTokenQuery, useVersionQuery } from '../../generated/graphql';
import { Header } from '../ui/Header';
import styles from './Layout.module.scss';
interface IProps {
loading?: boolean;
breadcrumbs?: { name: string; href: string; current?: boolean }[];
children: React.ReactNode;
title?: string;
actions?: React.ReactNode;
}
const Layout: React.FC<IProps> = ({ children, loading, breadcrumbs }) => {
const { isOpen, onClose, onOpen } = useDisclosure();
export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions }) => {
const { data } = useRefreshTokenQuery({ fetchPolicy: 'network-only' });
const { data: dataVersion } = useVersionQuery({ nextFetchPolicy: 'network-only' });
const defaultVersion = '0.0.0';
const isLatest = semver.gte(dataVersion?.version.current || defaultVersion, dataVersion?.version.latest || defaultVersion);
useEffect(() => {
if (data?.refreshToken?.token) {
localStorage.setItem('token', data.refreshToken.token);
}
}, [data]);
const menubg = useColorModeValue('#F1F3F4', '#202736');
const bg = useColorModeValue('white', '#1a202c');
const renderContent = () => {
if (loading) {
return (
<Flex className="justify-center flex-1">
<Spinner />
</Flex>
);
}
return children;
};
}, [data?.refreshToken?.token]);
const renderBreadcrumbs = () => {
if (!breadcrumbs) {
return null;
}
return (
<Breadcrumb spacing="8px" separator={<FiChevronRight color="gray.500" />}>
{breadcrumbs?.map((breadcrumb, index) => {
return (
<BreadcrumbItem className="hover:underline" isCurrentPage={breadcrumb.current} key={index}>
<Link href={breadcrumb.href}>{breadcrumb.name}</Link>
</BreadcrumbItem>
);
})}
</Breadcrumb>
<ol className="breadcrumb" aria-label="breadcrumbs">
{breadcrumbs.map((breadcrumb) => (
<li key={breadcrumb.name} data-testid="breadcrumb-item" className={clsx('breadcrumb-item', { active: breadcrumb.current })}>
<Link data-testid="breadcrumb-link" href={breadcrumb.href}>
{breadcrumb.name}
</Link>
</li>
))}
</ol>
);
};
return (
<>
<div data-testid={`${title?.toLowerCase().split(' ').join('-')}-layout`} className="page">
<Head>
<title>Tipi</title>
<title>{title} - Tipi</title>
</Head>
<Flex height="100vh" direction="column">
<MenuDrawer isOpen={isOpen} onClose={onClose}>
<Menu />
</MenuDrawer>
<Header onClickMenu={onOpen} />
<Flex flex={1}>
<Flex height="100vh" bg={menubg} className="sticky top-0 invisible md:visible w-0 md:w-64">
<Menu />
</Flex>
<Box bg={bg} className="flex-1 px-4 py-4 md:px-10 md:py-8">
{/* <UpdateBanner /> */}
{renderBreadcrumbs()}
{renderContent()}
</Box>
</Flex>
</Flex>
</>
<ReactTooltip offset={{ right: 3 }} effect="solid" place="bottom" />
<Header isUpdateAvailable={!isLatest} />
<div className="page-wrapper">
<div className="page-header d-print-none">
<div className="container-xl">
<div className={clsx('align-items-stretch align-items-md-center d-flex flex-column flex-md-row ', styles.topActions)}>
<div className="me-3 text-white">
<div className="page-pretitle">{renderBreadcrumbs()}</div>
<h2 className="page-title">{title}</h2>
</div>
<div className="flex-fill">{actions}</div>
</div>
</div>
</div>
<div className="page-body">
<div className="container-xl">{children}</div>
</div>
</div>
</div>
);
};
export default Layout;

View file

@ -1,25 +0,0 @@
import { Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerHeader, DrawerOverlay, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
interface IProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
const MenuDrawer: React.FC<IProps> = ({ children, isOpen, onClose }) => {
const menubg = useColorModeValue('#F1F3F4', '#202736');
return (
<Drawer size="xs" isOpen={isOpen} placement="left" onClose={onClose}>
<DrawerOverlay />
<DrawerContent bg={menubg}>
<DrawerCloseButton />
<DrawerHeader>My Tipi</DrawerHeader>
<DrawerBody display="flex">{children}</DrawerBody>
</DrawerContent>
</Drawer>
);
};
export default MenuDrawer;

View file

@ -1,96 +0,0 @@
import { AiOutlineDashboard, AiOutlineSetting, AiOutlineAppstore } from 'react-icons/ai';
import { FaAppStore, FaRegMoon } from 'react-icons/fa';
import { FiLogOut } from 'react-icons/fi';
import Package from '../../../package.json';
import { Badge, Box, Divider, Flex, List, ListItem, Switch, useColorMode } from '@chakra-ui/react';
import React from 'react';
import Link from 'next/link';
import clsx from 'clsx';
import { useRouter } from 'next/router';
import { IconType } from 'react-icons';
import { useLogoutMutation, useVersionQuery } from '../../generated/graphql';
import { getUrl } from '../../core/helpers/url-helpers';
import { BsHeart } from 'react-icons/bs';
import semver from 'semver';
const SideMenu: React.FC = () => {
const router = useRouter();
const { colorMode, setColorMode } = useColorMode();
const [logout] = useLogoutMutation({ refetchQueries: ['Me'] });
const versionQuery = useVersionQuery();
const path = router.pathname.split('/')[1];
const defaultVersion = '0.0.0';
const isLatest = semver.gte(versionQuery.data?.version.current || defaultVersion, versionQuery.data?.version.latest || defaultVersion);
const renderMenuItem = (title: string, name: string, Icon: IconType) => {
const selected = path === name;
const itemClass = clsx('mx-3 border-transparent rounded-lg p-3 transition-colors border-1', {
'drop-shadow-sm border-gray-200': selected && colorMode === 'light',
'bg-white': selected && colorMode === 'light',
});
return (
<Link href={`/${name}`} passHref>
<div className={itemClass}>
<ListItem className={'flex items-center cursor-pointer hover:font-bold'}>
<Icon size={20} className={clsx('mr-3', { 'text-red-600': selected && colorMode === 'light', 'text-red-200': selected && colorMode === 'dark' })} />
<p className={clsx({ 'font-bold': selected, 'text-red-600': selected && colorMode === 'light', 'text-red-200': selected && colorMode === 'dark' })}>{title}</p>
</ListItem>
</div>
</Link>
);
};
const handleChangeColorMode = (checked: boolean) => {
setColorMode(checked ? 'dark' : 'light');
};
const handleLogout = async () => {
localStorage.removeItem('token');
logout();
};
return (
<Box className="flex-1 flex flex-col p-0 md:p-4">
<img className="self-center mb-5 logo mt-0 md:mt-5" src={getUrl('tipi.png')} width={512} height={512} />
<List spacing={3} className="pt-5">
{renderMenuItem('Dashboard', '', AiOutlineDashboard)}
{renderMenuItem('My Apps', 'apps', AiOutlineAppstore)}
{renderMenuItem('App Store', 'app-store', FaAppStore)}
{renderMenuItem('Settings', 'settings', AiOutlineSetting)}
</List>
<Divider className="my-3" />
<Flex flex="1" />
<List>
<div className="mx-3">
<a href="https://github.com/meienberger/runtipi?sponsor=1" target="_blank" rel="noreferrer">
<ListItem className="cursor-pointer hover:font-bold flex items-center mb-4">
<BsHeart size={20} className="mr-3" />
<p className="flex-1 mb-1 text-md">Donate</p>
</ListItem>
</a>
<ListItem onClick={handleLogout} className="cursor-pointer hover:font-bold flex items-center mb-5">
<FiLogOut size={20} className="mr-3" />
<p className="flex-1">Log out</p>
</ListItem>
<ListItem className="flex items-center">
<FaRegMoon size={20} className="mr-3" />
<p className="flex-1">Dark mode</p>
<Switch isChecked={colorMode === 'dark'} onChange={(event) => handleChangeColorMode(event.target.checked)} />
</ListItem>
</div>
</List>
<div className="pb-1 text-center text-sm text-gray-400 mt-5">Tipi version {Package.version}</div>
{!isLatest && (
<Badge className="self-center mt-1" colorScheme="green">
New version available
</Badge>
)}
</Box>
);
};
export default SideMenu;

View file

@ -1,36 +0,0 @@
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, CloseButton } from '@chakra-ui/react';
import React from 'react';
import { useVersionQuery } from '../../generated/graphql';
const UpdateBanner = () => {
const { data, loading } = useVersionQuery();
const isLatest = data?.version.latest === data?.version.current;
if (isLatest || (loading && !data?.version)) {
return null;
}
const onClose = () => {};
return (
<div>
<Alert status="info" className="flex mb-3">
<AlertIcon />
<Box className="flex-1">
<AlertTitle>New version available!</AlertTitle>
<AlertDescription>
There is a new version of Tipi available ({data?.version.latest}). Visit{' '}
<a className="text-blue-600" target="_blank" rel="noreferrer" href={'https://github.com/meienberger/runtipi/releases/latest'}>
GitHub
</a>{' '}
for update instructions.
</AlertDescription>
</Box>
<CloseButton alignSelf="flex-start" position="relative" right={-1} top={-1} onClick={onClose} />
</Alert>
</div>
);
};
export default UpdateBanner;

View file

@ -1 +1 @@
export { default } from './Layout';
export { Layout } from './Layout';

View file

@ -1,12 +0,0 @@
import { Flex, Spinner } from '@chakra-ui/react';
import React from 'react';
const LoadingScreen = () => {
return (
<Flex height="100vh" alignItems="center" justifyContent="center">
<Spinner size="lg" />
</Flex>
);
};
export default LoadingScreen;

View file

@ -4,29 +4,30 @@ import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkMdx from 'remark-mdx';
const Markdown: React.FC<{ children: string; className: string }> = ({ children, className }) => {
return (
<ReactMarkdown
className={className}
components={{
h1: (props) => <h1 {...props} className="text-2xl font-bold mb-4 text-center md:text-left" />,
h2: (props) => <h2 {...props} className="text-xl font-bold mb-4 text-center md:text-left" />,
h3: (props) => <h3 {...props} className="text-lg font-bold mb-4 text-center md:text-left" />,
ul: (props) => <ul {...props} className="list-disc pl-4 mb-4" />,
img: (props) => (
<div className="flex justify-center py-2">
<img {...props} className="w-full lg:w-2/3" />
</div>
),
p: (props) => <p {...props} className="mb-4 text-center md:text-left" />,
a: (props) => <a target="_blank" rel="noreferrer" {...props} className="text-blue-500" href={props.href} />,
div: (props) => <div {...props} className="mb-4" />,
}}
remarkPlugins={[remarkBreaks, remarkGfm, remarkMdx]}
>
{children}
</ReactMarkdown>
);
};
const MarkdownImg = (props: Pick<React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, 'key' | keyof React.ImgHTMLAttributes<HTMLImageElement>>) => (
<div className="d-flex justify-content-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img alt="app-demonstration" {...props} />
</div>
);
const Markdown: React.FC<{ children: string; className: string }> = ({ children, className }) => (
<ReactMarkdown
className={className}
components={{
// h1: (props) => <h1 {...props} className="text-2xl font-bold mb-4 text-center md:text-left" />,
// h2: (props) => <h2 {...props} className="text-xl font-bold mb-4 text-center md:text-left" />,
// h3: (props) => <h3 {...props} className="text-lg font-bold mb-4 text-center md:text-left" />,
// ul: (props) => <ul {...props} className="list-disc pl-4 mb-4" />,
img: MarkdownImg,
// p: (props) => <p {...props} className="mb-4 text-left md:text-left" />,
// a: (props) => <a target="_blank" rel="noreferrer" {...props} className="text-blue-500" href={props.href} />,
// div: (props) => <div {...props} className="mb-4" />,
}}
remarkPlugins={[remarkBreaks, remarkGfm, remarkMdx]}
>
{children}
</ReactMarkdown>
);
export default Markdown;

View file

@ -0,0 +1,40 @@
import Image from 'next/image';
import React from 'react';
import { getUrl } from '../../core/helpers/url-helpers';
import { Button } from '../ui/Button';
interface IProps {
title: string;
subtitle: string;
onAction?: () => void;
actionTitle?: string;
loading?: boolean;
}
export const StatusScreen: React.FC<IProps> = ({ title, subtitle, onAction, actionTitle, loading = true }) => (
<div className="page page-center">
<div className="container container-tight py-4 d-flex align-items-center flex-column">
<Image
alt="Tipi log"
className="mb-3"
src={getUrl('tipi.png')}
height={50}
width={50}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<h1 className="text-center mb-1">{title}</h1>
<div className="text-center text-muted mb-3">{subtitle}</div>
{loading && <div className="spinner-border spinner-border-sm text-muted" />}
{onAction && (
<div className="empty-action">
<Button onClick={onAction} className="btn">
{actionTitle}
</Button>
</div>
)}
</div>
</div>
);

View file

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

View file

@ -1,14 +0,0 @@
import { Flex, Spinner, Text } from '@chakra-ui/react';
import React from 'react';
const RestartingScreen = () => {
return (
<Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
<Text fontSize="2xl">Your system is restarting...</Text>
<Text color="gray.500">Please do not refresh this page</Text>
<Spinner size="lg" className="mt-5" />
</Flex>
);
};
export default RestartingScreen;

View file

@ -1,54 +0,0 @@
import { SlideFade } from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import useSWR from 'swr';
import { SystemStatus } from '../../state/systemStore';
import RestartingScreen from './RestartingScreen';
import UpdatingScreen from './UpdatingScreen';
interface IProps {
children: React.ReactNode;
}
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const StatusWrapper: React.FC<IProps> = ({ children }) => {
const [s, setS] = useState<SystemStatus>(SystemStatus.RUNNING);
const { data } = useSWR('/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) {
window.location.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 (s === SystemStatus.RESTARTING) {
return (
<SlideFade in>
<RestartingScreen />
</SlideFade>
);
}
if (s === SystemStatus.UPDATING) {
return (
<SlideFade in>
<UpdatingScreen />
</SlideFade>
);
}
return <>{children}</>;
};
export default StatusWrapper;

View file

@ -1,14 +0,0 @@
import { Text, Flex, Spinner } from '@chakra-ui/react';
import React from 'react';
const UpdatingScreen = () => {
return (
<Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
<Text fontSize="2xl">Your system is updating...</Text>
<Text color="gray.500">Please do not refresh this page</Text>
<Spinner size="lg" className="mt-5" />
</Flex>
);
};
export default UpdatingScreen;

View file

@ -0,0 +1,42 @@
import { graphql } from 'msw';
import React from 'react';
import { render, screen, waitFor } from '../../../../tests/test-utils';
import { server } from '../../../mocks/server';
import { AuthProvider } from './AuthProvider';
describe('Test: AuthProvider', () => {
it('should render login form if user is not logged in', async () => {
render(
<AuthProvider>
<div>Should not render</div>
</AuthProvider>,
);
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
});
it('should render children if user is logged in', async () => {
server.use(graphql.query('Me', (req, res, ctx) => res(ctx.data({ me: { id: '1' } }))));
render(
<AuthProvider>
<div>Should render</div>
</AuthProvider>,
);
await waitFor(() => expect(screen.getByText('Should render')).toBeInTheDocument());
});
it('should render register form if app is not configured', async () => {
server.use(graphql.query('Configured', (req, res, ctx) => res(ctx.data({ isConfigured: false }))));
render(
<AuthProvider>
<div>Should not render</div>
</AuthProvider>,
);
await waitFor(() => expect(screen.getByText('Register')).toBeInTheDocument());
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,29 @@
import React from 'react';
import { useConfiguredQuery, useMeQuery } from '../../../generated/graphql';
import { LoginContainer } from '../../../modules/Auth/containers/LoginContainer';
import { RegisterContainer } from '../../../modules/Auth/containers/RegisterContainer';
import { StatusScreen } from '../../StatusScreen';
interface IProps {
children: React.ReactElement;
}
export const AuthProvider: React.FC<IProps> = ({ children }) => {
const user = useMeQuery();
const isConfigured = useConfiguredQuery();
const loading = user.loading || isConfigured.loading;
if (loading && !user.data?.me) {
return <StatusScreen title="" subtitle="" />;
}
if (user.data?.me) {
return children;
}
if (!isConfigured?.data?.isConfigured) {
return <RegisterContainer />;
}
return <LoginContainer />;
};

View file

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

View file

@ -0,0 +1,74 @@
import { rest } from 'msw';
import React from 'react';
import { render, screen, waitFor } from '../../../../tests/test-utils';
import { server } from '../../../mocks/server';
import { StatusProvider } from './StatusProvider';
const reloadFn = jest.fn();
jest.mock('next/router', () => {
const actualRouter = jest.requireActual('next-router-mock');
return {
...actualRouter,
reload: () => reloadFn(),
};
});
describe('Test: StatusProvider', () => {
it("should render it's children when system is RUNNING", async () => {
render(
<StatusProvider>
<div>system running</div>
</StatusProvider>,
);
await waitFor(() => {
expect(screen.getByText('system running')).toBeInTheDocument();
});
});
it('should render StatusScreen when system is RESTARTING', async () => {
server.use(rest.get('/api/status', (req, res, ctx) => res(ctx.delay(200), ctx.status(200), ctx.json({ status: 'RESTARTING' }))));
render(
<StatusProvider>
<div>system running</div>
</StatusProvider>,
);
await waitFor(() => {
expect(screen.getByText('Your system is restarting...')).toBeInTheDocument();
});
});
it('should render StatusScreen when system is UPDATING', async () => {
server.use(rest.get('/api/status', (req, res, ctx) => res(ctx.delay(200), ctx.status(200), ctx.json({ status: 'UPDATING' }))));
render(
<StatusProvider>
<div>system running</div>
</StatusProvider>,
);
await waitFor(() => {
expect(screen.getByText('Your system is updating...')).toBeInTheDocument();
});
});
it('should reload the page when system is RUNNING after being something else than RUNNING', async () => {
server.use(rest.get('/api/status', (req, res, ctx) => res(ctx.delay(200), ctx.status(200), ctx.json({ status: 'UPDATING' }))));
render(
<StatusProvider>
<div>system running</div>
</StatusProvider>,
);
await waitFor(() => {
expect(screen.getByText('Your system is updating...')).toBeInTheDocument();
});
server.use(rest.get('/api/status', (req, res, ctx) => res(ctx.delay(200), ctx.status(200), ctx.json({ status: 'RUNNING' }))));
await waitFor(() => {
expect(reloadFn).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,47 @@
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

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

View file

@ -0,0 +1,70 @@
import React from 'react';
import { act, render, renderHook, screen, waitFor } from '../../../../tests/test-utils';
import { useToastStore } from '../../../state/toastStore';
import { ToastProvider } from './ToastProvider';
describe('Test: ToastProvider', () => {
it("should render it's children", async () => {
render(
<ToastProvider>
<div>children</div>
</ToastProvider>,
);
await waitFor(() => {
expect(screen.getByText('children')).toBeInTheDocument();
});
});
it('should render Toasts', async () => {
render(
<ToastProvider>
<div>children</div>
</ToastProvider>,
);
const { result } = renderHook(() => useToastStore());
act(() => {
result.current.addToast({
status: 'success',
title: 'title',
description: 'description',
id: 'id',
});
});
await waitFor(() => {
expect(screen.getByText('title')).toBeInTheDocument();
});
});
it('should remove Toasts when the close button is clicked', async () => {
render(
<ToastProvider>
<div>children</div>
</ToastProvider>,
);
const { result } = renderHook(() => useToastStore());
act(() => {
result.current.addToast({
status: 'success',
title: 'title',
description: 'description',
id: 'id',
});
});
await waitFor(() => {
expect(screen.getByText('title')).toBeInTheDocument();
});
act(() => {
screen.getByTestId('toast-close-button').click();
});
await waitFor(() => {
expect(screen.queryByText('title')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,24 @@
import React from 'react';
import { IToast, useToastStore } from '../../../state/toastStore';
import { Toast } from '../../ui/Toast';
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { toasts, removeToast } = useToastStore();
const renderToast = (toast: IToast) => {
const { status, title, description, id } = toast;
return <Toast status={status} title={title} message={description} id={id} onClose={() => removeToast(id)} />;
};
return (
<>
{toasts.map((toast) => (
<div key={toast.id} className="position-fixed bottom-0 end-0 p-3" style={{ zIndex: 11 }}>
{renderToast(toast)}
</div>
))}
{children}
</>
);
};

View file

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

View file

@ -0,0 +1,50 @@
import React from 'react';
import { render, fireEvent, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { Button } from './Button';
afterEach(cleanup);
describe('Button component', () => {
it('should render without crashing', () => {
const { container } = render(<Button>Click me</Button>);
expect(container).toBeTruthy();
});
it('should render children correctly', () => {
const { getByText } = render(<Button>Click me</Button>);
expect(getByText('Click me')).toBeInTheDocument();
});
it('should apply className prop correctly', () => {
const { container } = render(<Button className="test-class">Click me</Button>);
expect(container.querySelector('button')).toHaveClass('test-class');
});
it('should render spinner when loading prop is true', () => {
const { container } = render(<Button loading>Click me</Button>);
expect(container.querySelector('.spinner-border')).toBeInTheDocument();
});
it('should disable button when disabled prop is true', () => {
const { container } = render(<Button disabled>Click me</Button>);
expect(container.querySelector('button')).toBeDisabled();
});
it('should set type correctly', () => {
const { container } = render(<Button type="submit">Click me</Button>);
expect(container.querySelector('button')).toHaveAttribute('type', 'submit');
});
it('should applies width correctly', () => {
const { container } = render(<Button width={100}>Click me</Button>);
expect(container.querySelector('button')).toHaveStyle('width: 100px');
});
it('should call onClick callback when clicked', () => {
const onClick = jest.fn();
const { container } = render(<Button onClick={onClick}>Click me</Button>);
fireEvent.click(container.querySelector('button') as HTMLButtonElement);
expect(onClick).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,21 @@
import React from 'react';
import clsx from 'clsx';
interface IProps {
className?: string;
type?: 'submit' | 'reset' | 'button';
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
children: React.ReactNode;
width?: number | null;
}
export const Button = React.forwardRef<HTMLButtonElement, IProps>(({ type, className, children, loading, disabled, onClick, width, ...rest }, ref) => {
const styles = { width: width ? `${width}px` : 'auto' };
return (
<button style={styles} onClick={onClick} disabled={disabled || loading} ref={ref} className={clsx('btn', className, { disabled: disabled || loading })} type={type} {...rest}>
{loading ? <span className="spinner-border spinner-border-sm mb-1 mx-2" role="status" aria-hidden="true" /> : children}
</button>
);
});

View file

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

View file

@ -0,0 +1,33 @@
import React from 'react';
import { render } from '../../../../tests/test-utils';
import { DataGrid } from './DataGrid';
import { DataGridItem } from './DataGridItem';
describe('DataGrid', () => {
it('renders its children', () => {
const { getByText } = render(
<DataGrid>
<p>Test child</p>
</DataGrid>,
);
expect(getByText('Test child')).toBeInTheDocument();
});
});
describe('DataGridItem', () => {
it('renders its children', () => {
const { getByText } = render(
<DataGridItem title="">
<p>Test child</p>
</DataGridItem>,
);
expect(getByText('Test child')).toBeInTheDocument();
});
it('renders the correct title', () => {
const { getByText } = render(<DataGridItem title="Test Title">Hello</DataGridItem>);
expect(getByText('Test Title')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,13 @@
import React from 'react';
interface IProps {
children: React.ReactNode;
}
export const DataGrid: React.FC<IProps> = ({ children }) => (
<div className="card">
<div className="card-body">
<div className="datagrid">{children}</div>
</div>
</div>
);

View file

@ -0,0 +1,13 @@
import React from 'react';
interface IProps {
title: string;
children: React.ReactNode;
}
export const DataGridItem: React.FC<IProps> = ({ children, title }) => (
<div className="datagrid-item">
<div className="datagrid-title">{title}</div>
<div className="datagrid-content">{children}</div>
</div>
);

View file

@ -0,0 +1,2 @@
export { DataGrid } from './DataGrid';
export { DataGridItem } from './DataGridItem';

View file

@ -0,0 +1,4 @@
.emptyImage {
height: 80px;
width: 80px;
}

View file

@ -0,0 +1,28 @@
import React from 'react';
import { fireEvent, render } from '../../../../tests/test-utils';
import { EmptyPage } from './EmptyPage';
describe('<EmptyPage />', () => {
it('should render the title and subtitle', () => {
const { getByText } = render(<EmptyPage title="Title" subtitle="Subtitle" />);
expect(getByText('Title')).toBeInTheDocument();
expect(getByText('Subtitle')).toBeInTheDocument();
});
it('should render the action button and trigger the onAction callback', () => {
const onAction = jest.fn();
const { getByText } = render(<EmptyPage title="Title" onAction={onAction} actionLabel="Action" />);
expect(getByText('Action')).toBeInTheDocument();
fireEvent.click(getByText('Action'));
expect(onAction).toHaveBeenCalled();
});
it('should not render the action button if onAction is not provided', () => {
const { queryByText } = render(<EmptyPage title="Title" actionLabel="Action" />);
expect(queryByText('Action')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,38 @@
import clsx from 'clsx';
import Image from 'next/image';
import React from 'react';
import { getUrl } from '../../../core/helpers/url-helpers';
import { Button } from '../Button';
import styles from './EmptyPage.module.scss';
interface IProps {
title: string;
subtitle?: string;
onAction?: () => void;
actionLabel?: string;
}
export const EmptyPage: React.FC<IProps> = ({ title, subtitle, onAction, actionLabel }) => (
<div data-testid="empty-page" className="card empty">
<Image
src={getUrl('empty.svg')}
alt="Empty box"
height="80"
width="80"
className={clsx(styles.emptyImage, 'mb-3')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<p className="empty-title">{title}</p>
<p className="empty-subtitle text-muted">{subtitle}</p>
<div className="empty-action">
{onAction && (
<Button data-testid="empty-page-action" onClick={onAction} className="btn-primary">
{actionLabel}
</Button>
)}
</div>
</div>
);

View file

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

View file

@ -0,0 +1,4 @@
.emptyImage {
height: 50px;
width: 50px;
}

View file

@ -0,0 +1,34 @@
import React from 'react';
import { fireEvent, render, screen } from '../../../../tests/test-utils';
import { ErrorPage } from './ErrorPage';
describe('ErrorPage', () => {
it('should render the error message', () => {
const errorMessage = 'There was an error';
render(<ErrorPage error={errorMessage} />);
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it('should render the retry button when onRetry is provided', () => {
const onRetry = jest.fn();
render(<ErrorPage onRetry={onRetry} />);
expect(screen.getByTestId('error-page-action')).toBeInTheDocument();
});
it('should not render the retry button when onRetry is not provided', () => {
render(<ErrorPage />);
expect(screen.queryByTestId('error-page-action')).not.toBeInTheDocument();
});
it('should call the onRetry callback when the retry button is clicked', () => {
const onRetry = jest.fn();
render(<ErrorPage onRetry={onRetry} />);
fireEvent.click(screen.getByTestId('error-page-action'));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,39 @@
import { IconRotateClockwise } from '@tabler/icons';
import clsx from 'clsx';
import Image from 'next/image';
import React from 'react';
import { getUrl } from '../../../core/helpers/url-helpers';
import { Button } from '../Button';
import styles from './ErrorPage.module.scss';
interface IProps {
error?: string;
onRetry?: () => void;
actionLabel?: string;
}
export const ErrorPage: React.FC<IProps> = ({ error, onRetry }) => (
<div data-testid="error-page" className="card empty">
<Image
src={getUrl('error.png')}
alt="Empty box"
height="100"
width="100"
className={clsx(styles.emptyImage, 'mb-3 mt-2')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<p className="empty-title">An error occured</p>
<p className="empty-subtitle text-muted">{error}</p>
<div className="empty-action">
{onRetry && (
<Button data-testid="error-page-action" onClick={onRetry} className="btn-danger">
<IconRotateClockwise />
Retry
</Button>
)}
</div>
</div>
);

View file

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

View file

@ -0,0 +1,81 @@
import React from 'react';
import { fireEvent, render, renderHook, screen } from '../../../../tests/test-utils';
import { useUIStore } from '../../../state/uiStore';
import { Header } from './Header';
const logoutFn = jest.fn();
const reloadFn = jest.fn();
jest.mock('../../../generated/graphql', () => ({
useLogoutMutation: () => [logoutFn],
}));
jest.mock('next/router', () => {
const actualRouter = jest.requireActual('next-router-mock');
return {
...actualRouter,
reload: () => reloadFn(),
};
});
describe('Header', () => {
it('renders without crashing', () => {
const { container } = render(<Header />);
expect(container).toBeInTheDocument();
});
it('renders the brand logo', () => {
const { container } = render(<Header />);
expect(container).toHaveTextContent('Tipi');
expect(container).toContainElement(screen.getByAltText('Tipi logo'));
});
it('renders the dark mode toggle', () => {
const { container } = render(<Header />);
const darkModeToggle = container.querySelector('[data-tip="Dark mode"]');
expect(darkModeToggle).toContainElement(screen.getByTestId('icon-moon'));
});
it('renders the light mode toggle', () => {
const { container } = render(<Header />);
const lightModeToggle = container.querySelector('[data-tip="Light mode"]');
expect(lightModeToggle).toContainElement(screen.getByTestId('icon-sun'));
});
it('Should toggle the dark mode on click of the dark mode toggle', () => {
const { result } = renderHook(() => useUIStore());
const { container } = render(<Header />);
const darkModeToggle = container.querySelector('[data-tip="Dark mode"]');
fireEvent.click(darkModeToggle as Element);
expect(result.current.darkMode).toBe(true);
});
it('Should toggle the dark mode on click of the light mode toggle', () => {
const { result } = renderHook(() => useUIStore());
const { container } = render(<Header />);
const lightModeToggle = container.querySelector('[data-tip="Light mode"]');
fireEvent.click(lightModeToggle as Element);
expect(result.current.darkMode).toBe(false);
});
it('Should call the logout mutation on logout', () => {
const { container } = render(<Header />);
const logoutButton = container.querySelector('[data-tip="Log out"]');
fireEvent.click(logoutButton as Element);
expect(logoutFn).toHaveBeenCalled();
});
it('Should reload the page with next/router on logout', () => {
const { container } = render(<Header />);
const logoutButton = container.querySelector('[data-tip="Log out"]');
fireEvent.click(logoutButton as Element);
expect(reloadFn).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,79 @@
import React from 'react';
import { IconBrandGithub, IconHeart, IconLogout, IconMoon, IconSun } from '@tabler/icons';
import Image from 'next/image';
import clsx from 'clsx';
import router from 'next/router';
import Link from 'next/link';
import { getUrl } from '../../../core/helpers/url-helpers';
import { useUIStore } from '../../../state/uiStore';
import { NavBar } from '../NavBar';
import { useLogoutMutation } from '../../../generated/graphql';
interface IProps {
isUpdateAvailable?: boolean;
}
export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
const { setDarkMode } = useUIStore();
const [logout] = useLogoutMutation();
const handleLogout = async () => {
await logout();
localStorage.removeItem('token');
router.reload();
};
return (
<header className="navbar navbar-expand-md navbar-dark navbar-overlap d-print-none">
<div className="container-xl">
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu">
<span className="navbar-toggler-icon" />
</button>
<Link href="/" passHref>
<h1 className="navbar-brand d-none-navbar-horizontal pe-0 pe-md-3">
<Image
priority
alt="Tipi logo"
className={clsx('navbar-brand-image me-3')}
width={100}
height={100}
src={getUrl('tipi.png')}
style={{
width: '30px',
maxWidth: '30px',
height: 'auto',
}}
/>
Tipi
</h1>
</Link>
<div className="navbar-nav flex-row order-md-last">
<div className="nav-item d-none d-lg-flex me-3">
<div className="btn-list">
<a href="https://github.com/meienberger/runtipi" target="_blank" rel="noreferrer" className="btn btn-dark">
<IconBrandGithub data-testid="icon-github" className="me-1 icon" size={24} />
Source code
</a>
<a href="https://github.com/meienberger/runtipi?sponsor=1" target="_blank" rel="noreferrer" className="btn btn-dark">
<IconHeart className="me-1 icon text-pink" size={24} />
Sponsor
</a>
</div>
</div>
<div className="d-flex">
<div onClick={() => setDarkMode(true)} role="button" aria-hidden="true" className="nav-link px-0 hide-theme-dark cursor-pointer" data-tip="Dark mode">
<IconMoon data-testid="icon-moon" size={24} />
</div>
<div onClick={() => setDarkMode(false)} aria-hidden="true" className="nav-link px-0 hide-theme-light cursor-pointer" data-tip="Light mode">
<IconSun data-testid="icon-sun" size={24} />
</div>
<div onClick={handleLogout} tabIndex={0} onKeyPress={handleLogout} role="button" className="nav-link px-0 cursor-pointer" data-tip="Log out">
<IconLogout size={24} />
</div>
</div>
</div>
<NavBar isUpdateAvailable={isUpdateAvailable} />
</div>
</header>
);
};

View file

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

View file

@ -0,0 +1,105 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { Input } from './Input';
import { fireEvent, render, waitFor } from '../../../../tests/test-utils';
describe('Input', () => {
it('should render without errors', () => {
const { container } = render(<Input name="test-input" />);
expect(container).toBeTruthy();
});
it('should render the label if provided', () => {
const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
const input = getByLabelText('Test Label');
expect(input).toBeTruthy();
});
it('should render the placeholder if provided', () => {
const { getByPlaceholderText } = render(<Input name="test-input" placeholder="Test Placeholder" />);
const input = getByPlaceholderText('Test Placeholder');
expect(input).toBeTruthy();
});
it('should render the error message if provided', () => {
const { getByText } = render(<Input name="test-input" error="Test Error" />);
const error = getByText('Test Error');
expect(error).toBeTruthy();
});
it('should call onChange when the input value is changed', async () => {
const onChange = jest.fn();
const { getByLabelText } = render(<Input name="test-input" label="Test Label" onChange={onChange} />);
const input = getByLabelText('Test Label');
fireEvent.change(input, { target: { value: 'changed' } });
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
});
it('should call onBlur when the input is blurred', async () => {
const onBlur = jest.fn();
const { getByLabelText } = render(<Input name="test-input" label="Test Label" onBlur={onBlur} />);
const input = getByLabelText('Test Label');
fireEvent.blur(input);
await waitFor(() => expect(onBlur).toHaveBeenCalledTimes(1));
});
it('should set the input type if provided', () => {
const { getByLabelText } = render(<Input name="test-input" label="Test Label" type="password" />);
const input = getByLabelText('Test Label') as HTMLInputElement;
expect(input.type).toBe('password');
});
it('should set the input value if provided', () => {
const { getByLabelText } = render(<Input name="test-input" label="Test Label" value="Test Value" onChange={jest.fn} />);
const input = getByLabelText('Test Label') as HTMLInputElement;
expect(input.value).toBe('Test Value');
});
it('should apply the className prop to the container div', () => {
const { container } = render(<Input name="test-input" className="test-class" />);
expect(container.firstChild).toHaveClass('test-class');
});
it('should apply the isInvalid prop to the input element', () => {
const { getByLabelText } = render(<Input name="test-input" label="Test Label" isInvalid />);
const input = getByLabelText('Test Label');
expect(input).toHaveClass('is-invalid', 'is-invalid-lite');
});
it('should apply the disabled prop to the input element', () => {
const { getByLabelText } = render(<Input name="test-input" label="Test Label" disabled />);
const input = getByLabelText('Test Label');
expect(input).toBeDisabled();
});
it('should set the input name attribute if provided', () => {
const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
const input = getByLabelText('Test Label');
expect(input).toHaveAttribute('name', 'test-input');
});
it('should set the input id attribute if provided', () => {
const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
const input = getByLabelText('Test Label');
expect(input).toHaveAttribute('id', 'test-input');
});
it('should set the input ref if provided', () => {
const ref = React.createRef<HTMLInputElement>();
const { getByLabelText } = render(<Input name="test-input" label="Test Label" ref={ref} />);
const input = getByLabelText('Test Label');
expect(input).toEqual(ref.current);
});
it('should set the input type attribute to "text" if not provided or if an invalid value is provided', () => {
const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
const input1 = getByLabelText('Test Label') as HTMLInputElement;
expect(input1.type).toBe('text');
});
it('should set the input placeholder attribute if provided', () => {
const { getByLabelText } = render(<Input name="test-input" label="Test Label" placeholder="Test Placeholder" />);
const input = getByLabelText('Test Label');
expect(input).toHaveAttribute('placeholder', 'Test Placeholder');
});
});

View file

@ -0,0 +1,39 @@
import React from 'react';
import clsx from 'clsx';
interface IProps {
placeholder?: string;
error?: string;
label?: string;
className?: string;
isInvalid?: boolean;
type?: HTMLInputElement['type'];
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
name?: string;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
disabled?: boolean;
value?: string;
}
export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled }, ref) => (
<div className={clsx(className)}>
{label && (
<label htmlFor={name} className="form-label">
{label}
</label>
)}
<input
disabled={disabled}
name={name}
id={name}
onBlur={onBlur}
onChange={onChange}
value={value}
type={type}
ref={ref}
className={clsx('form-control', { 'is-invalid is-invalid-lite': error || isInvalid })}
placeholder={placeholder}
/>
{error && <div className="invalid-feedback">{error}</div>}
</div>
));

View file

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

View file

@ -0,0 +1,37 @@
@keyframes zoomIn {
0% {
transform: scale(0.8);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
@keyframes dimmedBackground {
from {
background-color: rgba(0, 0, 0, 0);
}
to {
background-color: rgba(0, 0, 0, 0.8);
}
}
.dimmedBackground {
background-color: rgba(0, 0, 0, 0.8);
animation-name: dimmedBackground;
animation-duration: 0.2s;
animation-iteration-count: 1;
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
}
.zoomIn {
animation-name: zoomIn;
animation-duration: 0.25s;
animation-iteration-count: 1;
animation-timing-function: spring;
animation-fill-mode: forwards;
}

View file

@ -0,0 +1,141 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render } from '../../../../tests/test-utils';
import { Modal } from './Modal';
import { ModalBody } from './ModalBody';
import { ModalFooter } from './ModalFooter';
import { ModalHeader } from './ModalHeader';
describe('Modal component', () => {
it('should render without errors', () => {
const { container } = render(
<Modal onClose={() => {}}>
<p>Test modal content</p>
</Modal>,
);
expect(container).toBeTruthy();
});
it('should not be visible by default', () => {
const { queryByTestId } = render(
<Modal onClose={() => {}}>
<p>Test modal content</p>
</Modal>,
);
// display should be none
expect(queryByTestId('modal')).toHaveStyle('display: none');
});
it('should be visible when `isOpen` prop is true', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen>
<p>Test modal content</p>
</Modal>,
);
// display should be block
expect(getByTestId('modal')).toHaveStyle('display: block');
});
it('should not be visible when `isOpen` prop is false', () => {
const { queryByTestId } = render(
<Modal onClose={() => {}}>
<p>Test modal content</p>
</Modal>,
);
expect(queryByTestId('modal')).toHaveStyle('display: none');
});
it('should call the `onClose` prop when the close button is clicked', () => {
const onClose = jest.fn();
const { getByLabelText } = render(
<Modal onClose={onClose} isOpen>
<p>Test modal content</p>
</Modal>,
);
fireEvent.click(getByLabelText('Close'));
expect(onClose).toHaveBeenCalled();
});
it('should call the `onClose` callback when user clicks outside of the modal', () => {
const onClose = jest.fn();
const { container } = render(
<Modal onClose={onClose} isOpen>
<p>Test modal content</p>
</Modal>,
);
fireEvent.click(container);
expect(onClose).toHaveBeenCalled();
});
it('should have the correct `size` class when the `size` prop is passed', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen size="sm">
<p>Test modal content</p>
</Modal>,
);
expect(getByTestId('modal')).toHaveClass('modal-sm');
});
it('should have the correct `type` class when the `type` prop is passed', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen type="primary">
<p>Test modal content</p>
</Modal>,
);
expect(getByTestId('modal-status')).toHaveClass('bg-primary');
expect(getByTestId('modal-status')).not.toHaveClass('d-none');
});
it('should render the modal content as a child of the modal', () => {
const { getByTestId, getByText } = render(
<Modal onClose={() => {}} isOpen>
<p>Test modal content</p>
</Modal>,
);
expect(getByTestId('modal')).toContainElement(getByText('Test modal content'));
});
it('should call the `onClose` callback when the escape key is pressed', () => {
const onClose = jest.fn();
render(
<Modal onClose={onClose} isOpen>
<p>Test modal content</p>
</Modal>,
);
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('should correctly render with ModalBody', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen>
<ModalBody>
<p>Test modal content</p>
</ModalBody>
</Modal>,
);
expect(getByTestId('modal-body')).toBeInTheDocument();
});
it('should correctly render with ModalFooter', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen>
<ModalFooter>
<p>Test modal content</p>
</ModalFooter>
</Modal>,
);
expect(getByTestId('modal-footer')).toBeInTheDocument();
});
it('should correctly render with ModalHeader', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen>
<ModalHeader>
<p>Test modal content</p>
</ModalHeader>
</Modal>,
);
expect(getByTestId('modal-header')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,65 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useState } from 'react';
import styles from './Modal.module.scss';
interface IProps {
children: React.ReactNode;
isOpen?: boolean;
onClose: () => void;
size?: 'sm' | 'md' | 'lg' | 'xl';
type?: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger';
}
export const Modal: React.FC<IProps> = ({ children, isOpen, onClose, size = 'lg', type }) => {
const style = { display: 'none' };
if (isOpen) {
style.display = 'block';
}
const [modal, setModal] = useState<HTMLDivElement | null>(null);
// On click outside
const handleClickOutside = useCallback(
(event: MouseEvent) => {
if (modal && !modal.contains(event.target as Node)) {
onClose();
}
},
[modal, onClose],
);
// On click outside
useEffect(() => {
document.addEventListener('click', handleClickOutside, true);
return () => document.removeEventListener('click', handleClickOutside, true);
}, [handleClickOutside]);
// Close on escape
const handleEscape = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
},
[onClose],
);
// Close on escape
useEffect(() => {
document.addEventListener('keydown', handleEscape, true);
return () => document.removeEventListener('keydown', handleEscape, true);
}, [handleEscape]);
return (
<div data-testid="modal" className={clsx('modal modal-sm', styles.dimmedBackground)} tabIndex={-1} style={style} role="dialog">
<div ref={setModal} className={clsx(`modal-dialog modal-dialog-centered modal-${size}`, styles.zoomIn)} role="document">
<div className="shadow modal-content">
<button data-testid="modal-close-button" type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" onClick={onClose} />
<div data-testid="modal-status" className={clsx('modal-status', { [`bg-${type}`]: Boolean(type), 'd-none': !type })} />
{children}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,13 @@
import clsx from 'clsx';
import React from 'react';
interface IProps {
children: React.ReactNode;
className?: string;
}
export const ModalBody: React.FC<IProps> = ({ children, className }) => (
<div data-testid="modal-body" className={clsx('modal-body', className)}>
{children}
</div>
);

View file

@ -0,0 +1,11 @@
import React from 'react';
interface IProps {
children: React.ReactNode;
}
export const ModalFooter: React.FC<IProps> = ({ children }) => (
<div data-testid="modal-footer" className="modal-footer">
{children}
</div>
);

View file

@ -0,0 +1,11 @@
import React from 'react';
interface IProps {
children: React.ReactNode;
}
export const ModalHeader: React.FC<IProps> = ({ children }) => (
<div data-testid="modal-header" className="modal-header">
{children}
</div>
);

View file

@ -0,0 +1,4 @@
export { Modal } from './Modal';
export { ModalBody } from './ModalBody';
export { ModalFooter } from './ModalFooter';
export { ModalHeader } from './ModalHeader';

View file

@ -0,0 +1,50 @@
import { useRouter } from 'next/router';
import React from 'react';
import { render } from '../../../../tests/test-utils';
import { NavBar } from './NavBar';
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
describe('<NavBar />', () => {
beforeEach(() => {
(useRouter as jest.Mock).mockImplementation(() => ({
pathname: '/',
}));
});
it('should render the navbar items', () => {
const { getByText } = render(<NavBar isUpdateAvailable />);
expect(getByText('Dashboard')).toBeInTheDocument();
expect(getByText('My Apps')).toBeInTheDocument();
expect(getByText('App Store')).toBeInTheDocument();
expect(getByText('Settings')).toBeInTheDocument();
});
it('should highlight the active navbar item', () => {
(useRouter as jest.Mock).mockImplementation(() => ({
pathname: '/app-store',
}));
const { getByTestId } = render(<NavBar isUpdateAvailable />);
const activeItem = getByTestId('nav-item-app-store');
const inactiveItem = getByTestId('nav-item-settings');
expect(activeItem.classList.contains('active')).toBe(true);
expect(inactiveItem.classList.contains('active')).toBe(false);
});
it('should render the update available badge', () => {
const { getByText } = render(<NavBar isUpdateAvailable />);
expect(getByText('Update available')).toBeInTheDocument();
});
it('should not render the update available badge', () => {
const { queryByText } = render(<NavBar isUpdateAvailable={false} />);
expect(queryByText('Update available')).toBeNull();
});
});

View file

@ -0,0 +1,44 @@
import { IconApps, IconBrandAppstore, IconHome, IconSettings, TablerIcon } from '@tabler/icons';
import clsx from 'clsx';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
interface IProps {
isUpdateAvailable?: boolean;
}
export const NavBar: React.FC<IProps> = ({ isUpdateAvailable }) => {
const router = useRouter();
const path = router.pathname.split('/')[1];
const renderItem = (title: string, name: string, Icon: TablerIcon) => {
const isActive = path === name;
const itemClass = clsx('nav-item', { active: isActive, 'border-primary': isActive, 'border-bottom-wide': isActive });
return (
<li data-testid={`nav-item-${name}`} className={itemClass}>
<Link href={`/${name}`} className="nav-link" passHref>
<span className="nav-link-icon d-md-none d-lg-inline-block">
<Icon size={24} />
</span>
<span className="nav-link-title">{title}</span>
</Link>
</li>
);
};
return (
<div id="navbar-menu" className="collapse navbar-collapse" style={{}}>
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
<ul className="navbar-nav">
{renderItem('Dashboard', '', IconHome)}
{renderItem('My Apps', 'apps', IconApps)}
{renderItem('App Store', 'app-store', IconBrandAppstore)}
{renderItem('Settings', 'settings', IconSettings)}
</ul>
{Boolean(isUpdateAvailable) && <span className="ms-2 badge bg-green d-none d-lg-block">Update available</span>}
</div>
</div>
);
};

View file

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

View file

@ -0,0 +1,59 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { Switch } from './Switch';
import { fireEvent, render } from '../../../../tests/test-utils';
describe('Switch', () => {
it('renders the label', () => {
const label = 'Test Label';
const { getByText } = render(<Switch label={label} />);
expect(getByText(label)).toBeInTheDocument();
});
it('renders the className', () => {
const className = 'test-class';
const { container } = render(<Switch className={className} />);
const switchContainer = container.querySelector('.test-class');
expect(switchContainer).toBeInTheDocument();
});
it('renders the checked state', () => {
const { container } = render(<Switch checked onChange={jest.fn} />);
const checkbox = container.querySelector('input[type="checkbox"]');
expect(checkbox).toBeChecked();
});
it('triggers onChange event when clicked', () => {
const onChange = jest.fn();
const { container } = render(<Switch onChange={onChange} />);
const checkbox = container.querySelector('input[type="checkbox"]') as Element;
fireEvent.click(checkbox);
expect(onChange).toHaveBeenCalled();
});
it('triggers onBlur event when blurred', () => {
const onBlur = jest.fn();
const { container } = render(<Switch onBlur={onBlur} />);
const checkbox = container.querySelector('input[type="checkbox"]') as Element;
fireEvent.blur(checkbox);
expect(onBlur).toHaveBeenCalled();
});
it('should change the checked state when clicked', () => {
const { container } = render(<Switch onChange={jest.fn} />);
const checkbox = container.querySelector('input[type="checkbox"]') as Element;
fireEvent.click(checkbox);
expect(checkbox).toBeChecked();
});
});

View file

@ -0,0 +1,19 @@
import React from 'react';
interface IProps {
label?: string;
className?: string;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
name?: string;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
}
export const Switch = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, checked, className }, ref) => (
<div className={className}>
<label htmlFor={name} aria-labelledby={name} className="form-check form-switch">
<input id={name} name={name} ref={ref} onChange={onChange} onBlur={onBlur} className="form-check-input" type="checkbox" checked={checked} />
<span className="form-check-label">{label}</span>
</label>
</div>
));

View file

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

View file

@ -0,0 +1,18 @@
@keyframes slideInAndOut {
0% {
transform: translateX(100%);
}
5% {
transform: translateX(0);
}
95% {
transform: translateX(0);
}
100% {
transform: translateX(100%);
}
}
.slideIn {
animation: slideInAndOut 5s ease-in-out;
}

View file

@ -0,0 +1,34 @@
import React from 'react';
import { fireEvent, render } from '../../../../tests/test-utils';
import { Toast } from './Toast';
describe('Toast', () => {
it('renders the correct title', () => {
const { getByText } = render(<Toast id="toast-1" title="Test Title" onClose={jest.fn} status="info" />);
expect(getByText('Test Title')).toBeInTheDocument();
});
it('renders the correct message', () => {
const { getByText } = render(<Toast id="toast-1" title="Test Title" message="Test message" onClose={jest.fn} status="info" />);
expect(getByText('Test message')).toBeInTheDocument();
});
it('renders the correct status', () => {
const { container } = render(<Toast id="toast-1" title="Test Title" status="success" onClose={jest.fn} />);
const toastElement = container.querySelector('.tipi-toast');
expect(toastElement).toHaveClass('alert-success');
});
it('calls the correct function when the close button is clicked', () => {
const onCloseMock = jest.fn();
const { getByLabelText } = render(<Toast id="toast-1" title="Test Title" onClose={onCloseMock} status="info" />);
const closeButton = getByLabelText('close');
fireEvent.click(closeButton);
expect(onCloseMock).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,51 @@
import clsx from 'clsx';
import React from 'react';
import styles from './Toast.module.scss';
interface IProps {
onClose: () => void;
status: 'success' | 'error' | 'warning' | 'info';
title: string;
message?: string;
id: string;
}
export const Toast: React.FC<IProps> = ({ status, onClose, title, message, id }) => (
<div
id={id}
className={clsx(styles.slideIn, 'show fade alert alert-important alert-dismissible tipi-toast', {
'alert-danger': status === 'error',
'alert-success': status === 'success',
'alert-info': status === 'info',
warning: status === 'warning',
})}
role="alert"
>
<div className="d-flex align-items-center">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon alert-icon"
width="24"
height="24"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<div className="flex-fill">
<h4 className="alert-title text-white font-weight-bold">{title}</h4>
{message && <div className="text-white">{message}</div>}
</div>
</div>
<button onClick={onClose} data-testid="toast-close-button" className="btn-close btn-close-white" data-bs-dismiss="alert" aria-label="close" />
</div>
);

View file

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

View file

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

View file

@ -14,4 +14,5 @@ export const APP_CATEGORIES = [
{ 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,5 +1 @@
export const getUrl = (url: string) => {
let prefix = 'dashboard';
return `/${prefix}/${url}`;
};
export const getUrl = (url: string) => `/${url}`;

View file

@ -1,9 +1,3 @@
export enum RequestStatus {
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
LOADING = 'LOADING',
}
export interface IUser {
name: string;
email: string;

View file

@ -1,5 +1,6 @@
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
@ -154,42 +155,34 @@ export type Mutation = {
updateAppConfig: App;
};
export type MutationInstallAppArgs = {
input: AppInputType;
};
export type MutationLoginArgs = {
input: UsernamePasswordInput;
};
export type MutationRegisterArgs = {
input: UsernamePasswordInput;
};
export type MutationStartAppArgs = {
id: Scalars['String'];
};
export type MutationStopAppArgs = {
id: Scalars['String'];
};
export type MutationUninstallAppArgs = {
id: Scalars['String'];
};
export type MutationUpdateAppArgs = {
id: Scalars['String'];
};
export type MutationUpdateAppConfigArgs = {
input: AppInputType;
};
@ -206,7 +199,6 @@ export type Query = {
version: VersionResponse;
};
export type QueryGetAppArgs = {
id: Scalars['String'];
};
@ -253,125 +245,184 @@ export type InstallAppMutationVariables = Exact<{
input: AppInputType;
}>;
export type InstallAppMutation = { __typename?: 'Mutation', installApp: { __typename: 'App', id: string, status: AppStatusEnum } };
export type InstallAppMutation = { __typename?: 'Mutation'; installApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
export type LoginMutationVariables = Exact<{
input: UsernamePasswordInput;
}>;
export type LoginMutation = { __typename?: 'Mutation'; login: { __typename?: 'TokenResponse'; token: string } };
export type LoginMutation = { __typename?: 'Mutation', login: { __typename?: 'TokenResponse', token: string } };
export type LogoutMutationVariables = Exact<{ [key: string]: never }>;
export type LogoutMutationVariables = Exact<{ [key: string]: never; }>;
export type LogoutMutation = { __typename?: 'Mutation', logout: boolean };
export type LogoutMutation = { __typename?: 'Mutation'; logout: boolean };
export type RegisterMutationVariables = Exact<{
input: UsernamePasswordInput;
}>;
export type RegisterMutation = { __typename?: 'Mutation'; register: { __typename?: 'TokenResponse'; token: string } };
export type RegisterMutation = { __typename?: 'Mutation', register: { __typename?: 'TokenResponse', token: string } };
export type RestartMutationVariables = Exact<{ [key: string]: never }>;
export type RestartMutationVariables = Exact<{ [key: string]: never; }>;
export type RestartMutation = { __typename?: 'Mutation', restart: boolean };
export type RestartMutation = { __typename?: 'Mutation'; restart: boolean };
export type StartAppMutationVariables = Exact<{
id: Scalars['String'];
}>;
export type StartAppMutation = { __typename?: 'Mutation', startApp: { __typename: 'App', id: string, status: AppStatusEnum } };
export type StartAppMutation = { __typename?: 'Mutation'; startApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
export type StopAppMutationVariables = Exact<{
id: Scalars['String'];
}>;
export type StopAppMutation = { __typename?: 'Mutation', stopApp: { __typename: 'App', id: string, status: AppStatusEnum } };
export type StopAppMutation = { __typename?: 'Mutation'; stopApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
export type UninstallAppMutationVariables = Exact<{
id: Scalars['String'];
}>;
export type UninstallAppMutation = { __typename?: 'Mutation'; uninstallApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
export type UninstallAppMutation = { __typename?: 'Mutation', uninstallApp: { __typename: 'App', id: string, status: AppStatusEnum } };
export type UpdateMutationVariables = Exact<{ [key: string]: never }>;
export type UpdateMutationVariables = Exact<{ [key: string]: never; }>;
export type UpdateMutation = { __typename?: 'Mutation', update: boolean };
export type UpdateMutation = { __typename?: 'Mutation'; update: boolean };
export type UpdateAppMutationVariables = Exact<{
id: Scalars['String'];
}>;
export type UpdateAppMutation = { __typename?: 'Mutation', updateApp: { __typename: 'App', id: string, status: AppStatusEnum } };
export type UpdateAppMutation = { __typename?: 'Mutation'; updateApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
export type UpdateAppConfigMutationVariables = Exact<{
input: AppInputType;
}>;
export type UpdateAppConfigMutation = { __typename?: 'Mutation', updateAppConfig: { __typename: 'App', id: string, status: AppStatusEnum } };
export type UpdateAppConfigMutation = { __typename?: 'Mutation'; updateAppConfig: { __typename: 'App'; id: string; status: AppStatusEnum } };
export type GetAppQueryVariables = Exact<{
appId: Scalars['String'];
}>;
export type GetAppQuery = {
__typename?: 'Query';
getApp: {
__typename?: 'App';
id: string;
status: AppStatusEnum;
config: any;
version?: number | null;
exposed: boolean;
domain?: string | null;
updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
info?: {
__typename?: 'AppInfo';
id: string;
port: number;
name: string;
description: string;
available: boolean;
version?: string | null;
tipi_version: number;
short_desc: string;
author: string;
source: string;
categories: Array<AppCategoriesEnum>;
url_suffix?: string | null;
https?: boolean | null;
exposable?: boolean | null;
no_gui?: boolean | null;
form_fields: Array<{
__typename?: 'FormField';
type: FieldTypesEnum;
label: string;
max?: number | null;
min?: number | null;
hint?: string | null;
placeholder?: string | null;
required?: boolean | null;
env_variable: string;
}>;
} | null;
};
};
export type GetAppQuery = { __typename?: 'Query', getApp: { __typename?: 'App', id: string, status: AppStatusEnum, config: any, version?: number | null, exposed: boolean, domain?: string | null, updateInfo?: { __typename?: 'UpdateInfo', current: number, latest: number, dockerVersion?: string | null } | null, info?: { __typename?: 'AppInfo', id: string, port: number, name: string, description: string, available: boolean, version?: string | null, tipi_version: number, short_desc: string, author: string, source: string, categories: Array<AppCategoriesEnum>, url_suffix?: string | null, https?: boolean | null, exposable?: boolean | null, no_gui?: boolean | null, form_fields: Array<{ __typename?: 'FormField', type: FieldTypesEnum, label: string, max?: number | null, min?: number | null, hint?: string | null, placeholder?: string | null, required?: boolean | null, env_variable: string }> } | null } };
export type InstalledAppsQueryVariables = Exact<{ [key: string]: never }>;
export type InstalledAppsQueryVariables = Exact<{ [key: string]: never; }>;
export type InstalledAppsQuery = {
__typename?: 'Query';
installedApps: Array<{
__typename?: 'App';
id: string;
status: AppStatusEnum;
config: any;
version?: number | null;
updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
info?: { __typename?: 'AppInfo'; id: string; name: string; description: string; tipi_version: number; short_desc: string; https?: boolean | null } | null;
}>;
};
export type ConfiguredQueryVariables = Exact<{ [key: string]: never }>;
export type InstalledAppsQuery = { __typename?: 'Query', installedApps: Array<{ __typename?: 'App', id: string, status: AppStatusEnum, config: any, version?: number | null, updateInfo?: { __typename?: 'UpdateInfo', current: number, latest: number, dockerVersion?: string | null } | null, info?: { __typename?: 'AppInfo', id: string, name: string, description: string, tipi_version: number, short_desc: string, https?: boolean | null } | null }> };
export type ConfiguredQuery = { __typename?: 'Query'; isConfigured: boolean };
export type ConfiguredQueryVariables = Exact<{ [key: string]: never; }>;
export type ListAppsQueryVariables = Exact<{ [key: string]: never }>;
export type ListAppsQuery = {
__typename?: 'Query';
listAppsInfo: {
__typename?: 'ListAppsResonse';
total: number;
apps: Array<{
__typename?: 'AppInfo';
id: string;
available: boolean;
tipi_version: number;
port: number;
name: string;
version?: string | null;
short_desc: string;
author: string;
categories: Array<AppCategoriesEnum>;
https?: boolean | null;
}>;
};
};
export type ConfiguredQuery = { __typename?: 'Query', isConfigured: boolean };
export type MeQueryVariables = Exact<{ [key: string]: never }>;
export type ListAppsQueryVariables = Exact<{ [key: string]: never; }>;
export type MeQuery = { __typename?: 'Query'; me?: { __typename?: 'User'; id: string } | null };
export type RefreshTokenQueryVariables = Exact<{ [key: string]: never }>;
export type ListAppsQuery = { __typename?: 'Query', listAppsInfo: { __typename?: 'ListAppsResonse', total: number, apps: Array<{ __typename?: 'AppInfo', id: string, available: boolean, tipi_version: number, port: number, name: string, version?: string | null, short_desc: string, author: string, categories: Array<AppCategoriesEnum>, https?: boolean | null }> } };
export type RefreshTokenQuery = { __typename?: 'Query'; refreshToken?: { __typename?: 'TokenResponse'; token: string } | null };
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
export type SystemInfoQueryVariables = Exact<{ [key: string]: never }>;
export type SystemInfoQuery = {
__typename?: 'Query';
systemInfo?: {
__typename?: 'SystemInfoResponse';
cpu: { __typename?: 'Cpu'; load: number };
disk: { __typename?: 'DiskMemory'; available: number; used: number; total: number };
memory: { __typename?: 'DiskMemory'; available: number; used: number; total: number };
} | null;
};
export type MeQuery = { __typename?: 'Query', me?: { __typename?: 'User', id: string } | null };
export type RefreshTokenQueryVariables = Exact<{ [key: string]: never; }>;
export type RefreshTokenQuery = { __typename?: 'Query', refreshToken?: { __typename?: 'TokenResponse', token: string } | null };
export type SystemInfoQueryVariables = Exact<{ [key: string]: never; }>;
export type SystemInfoQuery = { __typename?: 'Query', systemInfo?: { __typename?: 'SystemInfoResponse', cpu: { __typename?: 'Cpu', load: number }, disk: { __typename?: 'DiskMemory', available: number, used: number, total: number }, memory: { __typename?: 'DiskMemory', available: number, used: number, total: number } } | null };
export type VersionQueryVariables = Exact<{ [key: string]: never; }>;
export type VersionQuery = { __typename?: 'Query', version: { __typename?: 'VersionResponse', current: string, latest?: string | null } };
export type VersionQueryVariables = Exact<{ [key: string]: never }>;
export type VersionQuery = { __typename?: 'Query'; version: { __typename?: 'VersionResponse'; current: string; latest?: string | null } };
export const InstallAppDocument = gql`
mutation InstallApp($input: AppInputType!) {
installApp(input: $input) {
id
status
__typename
mutation InstallApp($input: AppInputType!) {
installApp(input: $input) {
id
status
__typename
}
}
}
`;
`;
export type InstallAppMutationFn = Apollo.MutationFunction<InstallAppMutation, InstallAppMutationVariables>;
/**
@ -399,12 +450,12 @@ export type InstallAppMutationHookResult = ReturnType<typeof useInstallAppMutati
export type InstallAppMutationResult = Apollo.MutationResult<InstallAppMutation>;
export type InstallAppMutationOptions = Apollo.BaseMutationOptions<InstallAppMutation, InstallAppMutationVariables>;
export const LoginDocument = gql`
mutation Login($input: UsernamePasswordInput!) {
login(input: $input) {
token
mutation Login($input: UsernamePasswordInput!) {
login(input: $input) {
token
}
}
}
`;
`;
export type LoginMutationFn = Apollo.MutationFunction<LoginMutation, LoginMutationVariables>;
/**
@ -432,10 +483,10 @@ export type LoginMutationHookResult = ReturnType<typeof useLoginMutation>;
export type LoginMutationResult = Apollo.MutationResult<LoginMutation>;
export type LoginMutationOptions = Apollo.BaseMutationOptions<LoginMutation, LoginMutationVariables>;
export const LogoutDocument = gql`
mutation Logout {
logout
}
`;
mutation Logout {
logout
}
`;
export type LogoutMutationFn = Apollo.MutationFunction<LogoutMutation, LogoutMutationVariables>;
/**
@ -462,12 +513,12 @@ export type LogoutMutationHookResult = ReturnType<typeof useLogoutMutation>;
export type LogoutMutationResult = Apollo.MutationResult<LogoutMutation>;
export type LogoutMutationOptions = Apollo.BaseMutationOptions<LogoutMutation, LogoutMutationVariables>;
export const RegisterDocument = gql`
mutation Register($input: UsernamePasswordInput!) {
register(input: $input) {
token
mutation Register($input: UsernamePasswordInput!) {
register(input: $input) {
token
}
}
}
`;
`;
export type RegisterMutationFn = Apollo.MutationFunction<RegisterMutation, RegisterMutationVariables>;
/**
@ -495,10 +546,10 @@ export type RegisterMutationHookResult = ReturnType<typeof useRegisterMutation>;
export type RegisterMutationResult = Apollo.MutationResult<RegisterMutation>;
export type RegisterMutationOptions = Apollo.BaseMutationOptions<RegisterMutation, RegisterMutationVariables>;
export const RestartDocument = gql`
mutation Restart {
restart
}
`;
mutation Restart {
restart
}
`;
export type RestartMutationFn = Apollo.MutationFunction<RestartMutation, RestartMutationVariables>;
/**
@ -525,14 +576,14 @@ export type RestartMutationHookResult = ReturnType<typeof useRestartMutation>;
export type RestartMutationResult = Apollo.MutationResult<RestartMutation>;
export type RestartMutationOptions = Apollo.BaseMutationOptions<RestartMutation, RestartMutationVariables>;
export const StartAppDocument = gql`
mutation StartApp($id: String!) {
startApp(id: $id) {
id
status
__typename
mutation StartApp($id: String!) {
startApp(id: $id) {
id
status
__typename
}
}
}
`;
`;
export type StartAppMutationFn = Apollo.MutationFunction<StartAppMutation, StartAppMutationVariables>;
/**
@ -560,14 +611,14 @@ export type StartAppMutationHookResult = ReturnType<typeof useStartAppMutation>;
export type StartAppMutationResult = Apollo.MutationResult<StartAppMutation>;
export type StartAppMutationOptions = Apollo.BaseMutationOptions<StartAppMutation, StartAppMutationVariables>;
export const StopAppDocument = gql`
mutation StopApp($id: String!) {
stopApp(id: $id) {
id
status
__typename
mutation StopApp($id: String!) {
stopApp(id: $id) {
id
status
__typename
}
}
}
`;
`;
export type StopAppMutationFn = Apollo.MutationFunction<StopAppMutation, StopAppMutationVariables>;
/**
@ -595,14 +646,14 @@ export type StopAppMutationHookResult = ReturnType<typeof useStopAppMutation>;
export type StopAppMutationResult = Apollo.MutationResult<StopAppMutation>;
export type StopAppMutationOptions = Apollo.BaseMutationOptions<StopAppMutation, StopAppMutationVariables>;
export const UninstallAppDocument = gql`
mutation UninstallApp($id: String!) {
uninstallApp(id: $id) {
id
status
__typename
mutation UninstallApp($id: String!) {
uninstallApp(id: $id) {
id
status
__typename
}
}
}
`;
`;
export type UninstallAppMutationFn = Apollo.MutationFunction<UninstallAppMutation, UninstallAppMutationVariables>;
/**
@ -630,10 +681,10 @@ export type UninstallAppMutationHookResult = ReturnType<typeof useUninstallAppMu
export type UninstallAppMutationResult = Apollo.MutationResult<UninstallAppMutation>;
export type UninstallAppMutationOptions = Apollo.BaseMutationOptions<UninstallAppMutation, UninstallAppMutationVariables>;
export const UpdateDocument = gql`
mutation Update {
update
}
`;
mutation Update {
update
}
`;
export type UpdateMutationFn = Apollo.MutationFunction<UpdateMutation, UpdateMutationVariables>;
/**
@ -660,14 +711,14 @@ export type UpdateMutationHookResult = ReturnType<typeof useUpdateMutation>;
export type UpdateMutationResult = Apollo.MutationResult<UpdateMutation>;
export type UpdateMutationOptions = Apollo.BaseMutationOptions<UpdateMutation, UpdateMutationVariables>;
export const UpdateAppDocument = gql`
mutation UpdateApp($id: String!) {
updateApp(id: $id) {
id
status
__typename
mutation UpdateApp($id: String!) {
updateApp(id: $id) {
id
status
__typename
}
}
}
`;
`;
export type UpdateAppMutationFn = Apollo.MutationFunction<UpdateAppMutation, UpdateAppMutationVariables>;
/**
@ -695,14 +746,14 @@ export type UpdateAppMutationHookResult = ReturnType<typeof useUpdateAppMutation
export type UpdateAppMutationResult = Apollo.MutationResult<UpdateAppMutation>;
export type UpdateAppMutationOptions = Apollo.BaseMutationOptions<UpdateAppMutation, UpdateAppMutationVariables>;
export const UpdateAppConfigDocument = gql`
mutation UpdateAppConfig($input: AppInputType!) {
updateAppConfig(input: $input) {
id
status
__typename
mutation UpdateAppConfig($input: AppInputType!) {
updateAppConfig(input: $input) {
id
status
__typename
}
}
}
`;
`;
export type UpdateAppConfigMutationFn = Apollo.MutationFunction<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>;
/**
@ -730,49 +781,49 @@ export type UpdateAppConfigMutationHookResult = ReturnType<typeof useUpdateAppCo
export type UpdateAppConfigMutationResult = Apollo.MutationResult<UpdateAppConfigMutation>;
export type UpdateAppConfigMutationOptions = Apollo.BaseMutationOptions<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>;
export const GetAppDocument = gql`
query GetApp($appId: String!) {
getApp(id: $appId) {
id
status
config
version
exposed
domain
updateInfo {
current
latest
dockerVersion
}
info {
query GetApp($appId: String!) {
getApp(id: $appId) {
id
port
name
description
available
status
config
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
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
}
}
}
}
}
`;
`;
/**
* __useGetAppQuery__
@ -802,28 +853,28 @@ export type GetAppQueryHookResult = ReturnType<typeof useGetAppQuery>;
export type GetAppLazyQueryHookResult = ReturnType<typeof useGetAppLazyQuery>;
export type GetAppQueryResult = Apollo.QueryResult<GetAppQuery, GetAppQueryVariables>;
export const InstalledAppsDocument = gql`
query InstalledApps {
installedApps {
id
status
config
version
updateInfo {
current
latest
dockerVersion
}
info {
query InstalledApps {
installedApps {
id
name
description
tipi_version
short_desc
https
status
config
version
updateInfo {
current
latest
dockerVersion
}
info {
id
name
description
tipi_version
short_desc
https
}
}
}
}
`;
`;
/**
* __useInstalledAppsQuery__
@ -852,10 +903,10 @@ export type InstalledAppsQueryHookResult = ReturnType<typeof useInstalledAppsQue
export type InstalledAppsLazyQueryHookResult = ReturnType<typeof useInstalledAppsLazyQuery>;
export type InstalledAppsQueryResult = Apollo.QueryResult<InstalledAppsQuery, InstalledAppsQueryVariables>;
export const ConfiguredDocument = gql`
query Configured {
isConfigured
}
`;
query Configured {
isConfigured
}
`;
/**
* __useConfiguredQuery__
@ -884,24 +935,24 @@ export type ConfiguredQueryHookResult = ReturnType<typeof useConfiguredQuery>;
export type ConfiguredLazyQueryHookResult = ReturnType<typeof useConfiguredLazyQuery>;
export type ConfiguredQueryResult = Apollo.QueryResult<ConfiguredQuery, ConfiguredQueryVariables>;
export const ListAppsDocument = gql`
query ListApps {
listAppsInfo {
apps {
id
available
tipi_version
port
name
version
short_desc
author
categories
https
query ListApps {
listAppsInfo {
apps {
id
available
tipi_version
port
name
version
short_desc
author
categories
https
}
total
}
total
}
}
`;
`;
/**
* __useListAppsQuery__
@ -930,12 +981,12 @@ export type ListAppsQueryHookResult = ReturnType<typeof useListAppsQuery>;
export type ListAppsLazyQueryHookResult = ReturnType<typeof useListAppsLazyQuery>;
export type ListAppsQueryResult = Apollo.QueryResult<ListAppsQuery, ListAppsQueryVariables>;
export const MeDocument = gql`
query Me {
me {
id
query Me {
me {
id
}
}
}
`;
`;
/**
* __useMeQuery__
@ -964,12 +1015,12 @@ export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
export type MeQueryResult = Apollo.QueryResult<MeQuery, MeQueryVariables>;
export const RefreshTokenDocument = gql`
query RefreshToken {
refreshToken {
token
query RefreshToken {
refreshToken {
token
}
}
}
`;
`;
/**
* __useRefreshTokenQuery__
@ -998,24 +1049,24 @@ export type RefreshTokenQueryHookResult = ReturnType<typeof useRefreshTokenQuery
export type RefreshTokenLazyQueryHookResult = ReturnType<typeof useRefreshTokenLazyQuery>;
export type RefreshTokenQueryResult = Apollo.QueryResult<RefreshTokenQuery, RefreshTokenQueryVariables>;
export const SystemInfoDocument = gql`
query SystemInfo {
systemInfo {
cpu {
load
}
disk {
available
used
total
}
memory {
available
used
total
query SystemInfo {
systemInfo {
cpu {
load
}
disk {
available
used
total
}
memory {
available
used
total
}
}
}
}
`;
`;
/**
* __useSystemInfoQuery__
@ -1044,13 +1095,13 @@ export type SystemInfoQueryHookResult = ReturnType<typeof useSystemInfoQuery>;
export type SystemInfoLazyQueryHookResult = ReturnType<typeof useSystemInfoLazyQuery>;
export type SystemInfoQueryResult = Apollo.QueryResult<SystemInfoQuery, SystemInfoQueryVariables>;
export const VersionDocument = gql`
query Version {
version {
current
latest
query Version {
version {
current
latest
}
}
}
`;
`;
/**
* __useVersionQuery__
@ -1077,4 +1128,4 @@ export function useVersionLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<Ve
}
export type VersionQueryHookResult = ReturnType<typeof useVersionQuery>;
export type VersionLazyQueryHookResult = ReturnType<typeof useVersionLazyQuery>;
export type VersionQueryResult = Apollo.QueryResult<VersionQuery, VersionQueryVariables>;
export type VersionQueryResult = Apollo.QueryResult<VersionQuery, VersionQueryVariables>;

View file

@ -13,7 +13,7 @@ export default function useCachedResources(): IReturnProps {
async function loadResourcesAndDataAsync() {
try {
const restoredClient = await createApolloClient();
const restoredClient = createApolloClient();
setClient(restoredClient);
} catch (error) {

View file

@ -0,0 +1,17 @@
import { useCallback, useState } from 'react';
export const useDisclosure = (isOpenDefault = false) => {
const [isOpen, setIsOpen] = useState(isOpenDefault);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback((toSet: boolean) => {
if (typeof toSet === 'undefined') {
setIsOpen((state) => !state);
} else {
setIsOpen(toSet);
}
}, []);
return { isOpen, open, close, toggle };
};

View file

@ -0,0 +1,4 @@
import { setupWorker } from 'msw';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);

View file

@ -0,0 +1,57 @@
import { faker } from '@faker-js/faker';
import { App, AppCategoriesEnum, AppInfo, AppStatusEnum } from '../../generated/graphql';
const randomCategory = (): AppCategoriesEnum[] => {
const categories = Object.values(AppCategoriesEnum);
const randomIndex = faker.datatype.number({ min: 0, max: categories.length - 1 });
return [categories[randomIndex]];
};
export const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
const name = faker.random.word();
return {
id: name.toLowerCase(),
name,
description: faker.random.words(),
author: faker.random.word(),
available: true,
categories: randomCategory(),
form_fields: [],
port: faker.datatype.number({ min: 1000, max: 9999 }),
short_desc: faker.random.words(),
tipi_version: 1,
version: faker.system.semver(),
source: faker.internet.url(),
https: false,
no_gui: false,
exposable: true,
url_suffix: '',
...overrides,
};
};
type CreateAppEntityParams = {
overrides?: Omit<Partial<App>, 'info'>;
overridesInfo?: Partial<AppInfo>;
status?: AppStatusEnum;
};
export const createAppEntity = (params: CreateAppEntityParams) => {
const { overrides, overridesInfo, status = AppStatusEnum.Running } = params;
const id = faker.random.word().toLowerCase();
const app = createApp({ id, ...overridesInfo });
return {
id,
status,
info: app,
config: {},
exposed: false,
updateInfo: null,
domain: null,
version: 1,
...overrides,
};
};
export const createAppsRandomly = (count: number): AppInfo[] => Array.from({ length: count }).map(() => createApp());

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