浏览代码

Merge pull request #3 from meienberger/feature/install-app

Feature/install app
Nicolas Meienberger 3 年之前
父节点
当前提交
206b822cac
共有 100 个文件被更改,包括 2861 次插入487 次删除
  1. 1 0
      README.md
  2. 2 3
      ansible/host_vars/tipi.yml
  3. 40 1
      ansible/tasks/common/docker.yml
  4. 9 10
      ansible/tasks/common/essential.yml
  5. 1 1
      ansible/tasks/common/system-api.yml
  6. 45 0
      apps/anonaddy/config.json
  7. 14 11
      apps/anonaddy/docker-compose.yml
  8. 0 0
      apps/busybox/Dockerfile
  9. 11 0
      apps/busybox/config.json
  10. 6 0
      apps/busybox/docker-compose.yml
  11. 0 1
      apps/docker-compose.common.yml
  12. 11 0
      apps/filerun/config.json
  13. 38 0
      apps/filerun/docker-compose.yml
  14. 11 0
      apps/freshrss/config.json
  15. 10 10
      apps/freshrss/docker-compose.yml
  16. 11 0
      apps/jellyfin/config.json
  17. 0 0
      apps/jellyfin/data/config/.gitkeep
  18. 0 0
      apps/jellyfin/data/media/.gitkeep
  19. 18 0
      apps/jellyfin/docker-compose.yml
  20. 8 2
      apps/nextcloud/config.json
  21. 16 19
      apps/nextcloud/docker-compose.yml
  22. 0 24
      apps/pi-hole/data/unbound/unbound.conf
  23. 0 34
      apps/pi-hole/docker-compose.yml
  24. 23 0
      apps/pihole/config.json
  25. 24 0
      apps/pihole/docker-compose.yml
  26. 20 0
      apps/radarr/config.json
  27. 35 0
      apps/radarr/docker-compose.yml
  28. 11 0
      apps/simple-torrent/config.json
  29. 2 2
      apps/simple-torrent/docker-compose.yml
  30. 12 0
      apps/syncthing/config.json
  31. 0 0
      apps/syncthing/data/.gitkeep
  32. 20 0
      apps/syncthing/docker-compose.yml
  33. 0 8
      apps/test/docker-compose.yml
  34. 31 0
      apps/transmission/config.json
  35. 0 0
      apps/transmission/data/config/.gitkeep
  36. 0 0
      apps/transmission/data/downloads/.gitkeep
  37. 0 0
      apps/transmission/data/watch/.gitkeep
  38. 25 0
      apps/transmission/docker-compose.yml
  39. 35 0
      apps/wg-easy/config.json
  40. 0 0
      apps/wg-easy/data/.gitkeep
  41. 33 27
      apps/wg-easy/docker-compose.yml
  42. 17 0
      dashboard/.eslintrc.js
  43. 0 3
      dashboard/.eslintrc.json
  44. 6 0
      dashboard/.prettierrc.js
  45. 3 0
      dashboard/Dockerfile
  46. 12 0
      dashboard/Dockerfile.dev
  47. 7 2
      dashboard/next.config.js
  48. 14 1
      dashboard/package.json
  49. 6 0
      dashboard/postcss.config.js
  50. 二进制
      dashboard/public/logo.png
  51. 0 4
      dashboard/public/logo.svg
  52. 23 0
      dashboard/src/components/AppTile/AppStatus.tsx
  53. 30 0
      dashboard/src/components/AppTile/index.tsx
  54. 24 0
      dashboard/src/components/Form/FormInput.tsx
  55. 28 0
      dashboard/src/components/Form/validators.ts
  56. 10 15
      dashboard/src/components/Layout/Header.tsx
  57. 46 16
      dashboard/src/components/Layout/Layout.tsx
  58. 35 6
      dashboard/src/components/Layout/Menu.tsx
  59. 4 12
      dashboard/src/components/Layout/MenuDrawer.tsx
  60. 1 1
      dashboard/src/components/Layout/index.ts
  61. 43 45
      dashboard/src/constants/apps.ts
  62. 33 0
      dashboard/src/core/api.ts
  63. 9 0
      dashboard/src/core/fetcher.ts
  64. 51 0
      dashboard/src/core/types.ts
  65. 70 0
      dashboard/src/modules/Apps/components/AppActions.tsx
  66. 46 0
      dashboard/src/modules/Apps/components/InstallForm.tsx
  67. 28 0
      dashboard/src/modules/Apps/components/InstallModal.tsx
  68. 30 0
      dashboard/src/modules/Apps/components/StopModal.tsx
  69. 30 0
      dashboard/src/modules/Apps/components/UninstallModal.tsx
  70. 36 0
      dashboard/src/modules/Apps/components/UpdateModal.tsx
  71. 136 0
      dashboard/src/modules/Apps/containers/AppDetails.tsx
  72. 11 3
      dashboard/src/pages/_app.tsx
  73. 2 2
      dashboard/src/pages/api/device/cpu.ts
  74. 2 2
      dashboard/src/pages/api/device/disk.ts
  75. 2 2
      dashboard/src/pages/api/device/memory.ts
  76. 39 0
      dashboard/src/pages/apps/[id].tsx
  77. 39 0
      dashboard/src/pages/apps/index.tsx
  78. 2 2
      dashboard/src/pages/index.tsx
  79. 12 0
      dashboard/src/pages/settings.tsx
  80. 127 0
      dashboard/src/state/appsStore.ts
  81. 19 0
      dashboard/src/state/networkStore.ts
  82. 12 0
      dashboard/src/state/uiStore.ts
  83. 5 0
      dashboard/src/styles/globals.css
  84. 3 0
      dashboard/src/utils/typescript.ts
  85. 7 0
      dashboard/tailwind.config.js
  86. 452 14
      dashboard/yarn.lock
  87. 32 26
      docker-compose.yml
  88. 14 8
      scripts/app.sh
  89. 8 39
      scripts/configure.sh
  90. 64 3
      scripts/start.sh
  91. 0 6
      state/apps.json
  92. 5 3
      system-api/.eslintrc.cjs
  93. 436 28
      system-api/package-lock.json
  94. 9 1
      system-api/package.json
  95. 1 0
      system-api/src/config/apps.ts
  96. 21 2
      system-api/src/config/types.ts
  97. 124 84
      system-api/src/modules/apps/apps.controller.ts
  98. 101 0
      system-api/src/modules/apps/apps.helpers.ts
  99. 8 3
      system-api/src/modules/apps/apps.routes.ts
  100. 3 1
      system-api/src/modules/fs/fs.helpers.ts

+ 1 - 0
README.md

@@ -0,0 +1 @@
+[![forthebadge](https://svgshare.com/i/g4Y.svg)](https://forthebadge.com)

+ 2 - 3
ansible/host_vars/tipi.yml

@@ -5,9 +5,8 @@ packages:
   - iptables
   - iptables
   - coreutils
   - coreutils
   - git
   - git
-  - base-devel
   - docker
   - docker
-  - avahi
+  - avahi-daemon
   - nodejs
   - nodejs
   - npm
   - npm
 
 
@@ -15,4 +14,4 @@ username: nicolas
 
 
 ### ZSH Settings
 ### ZSH Settings
 zsh_theme: "powerlevel10k/powerlevel10k"
 zsh_theme: "powerlevel10k/powerlevel10k"
-ohmyzsh_git_url: https://github.com/robbyrussell/oh-my-zsh
+ohmyzsh_git_url: https://github.com/robbyrussell/oh-my-zsh

+ 40 - 1
ansible/tasks/common/docker.yml

@@ -1,8 +1,33 @@
 - name: Install docker
 - name: Install docker
   package:
   package:
-    name: docker
+    name:
+      - docker
+      - ca-certificates
+      - curl
+      - gnupg
+      - lsb-release
     state: latest
     state: latest
 
 
+- name: Add docker gpg key
+  shell: curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
+
+- name: Check lsb_release -cs
+  shell: lsb_release -cs
+  register: lsb_release
+
+- name: Add deb for bookworm release
+  shell: echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bullseye stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
+  when: lsb_release.stdout == "bookworm"
+
+- name: Add deb for non-bookworm
+  shell: echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
+  when: lsb_release.stdout != "bookworm"
+
+- name: Update packages
+  apt:
+    update_cache: yes
+    upgrade: yes
+
 - name: Install essential packages
 - name: Install essential packages
   package:
   package:
     name:
     name:
@@ -24,6 +49,20 @@
 - name: Make docker-compose executable
 - name: Make docker-compose executable
   shell: chmod +x /usr/local/bin/docker-compose
   shell: chmod +x /usr/local/bin/docker-compose
 
 
+# - name: Disable iptables for docker by editing file /etc/default/docker
+#   lineinfile:
+#     path: /etc/default/docker
+#     regexp: "^DOCKER_OPTS="
+#     line: "DOCKER_OPTS=\"--iptables=false\""
+#     state: present
+
+# - name: Create file /etc/docker/daemon.json with content hello world written inside
+#   lineinfile:
+#     path: /etc/docker/daemon.json
+#     regexp: "^"
+#     line: "{ \"iptables\": false }"
+#     state: present
+
 - name: Create group docker
 - name: Create group docker
   group:
   group:
     name: docker
     name: docker

+ 9 - 10
ansible/tasks/common/essential.yml

@@ -1,22 +1,21 @@
 - name: Change machine hostname to tipi.local
 - name: Change machine hostname to tipi.local
   shell: hostnamectl set-hostname tipi.local
   shell: hostnamectl set-hostname tipi.local
 
 
-- name: Update packages
-  become: yes
-  pacman:
-    update_cache: yes
-    upgrade: yes
-
-- name: Add user to root group
-  user:
-    name: "{{ username }}"
-    group: root
+# - name: Update packages
+#   apt:
+#     update_cache: yes
+#     upgrade: yes
 
 
 - name: Install essential packages
 - name: Install essential packages
   package:
   package:
     name: "{{ packages }}"
     name: "{{ packages }}"
     state: latest
     state: latest
 
 
+- name: Add user to root group
+  user:
+    name: "{{ username }}"
+    group: root
+
 - name: Disable SSH password auth
 - name: Disable SSH password auth
   lineinfile:
   lineinfile:
     dest: /etc/ssh/sshd_config
     dest: /etc/ssh/sshd_config

+ 1 - 1
ansible/tasks/common/system-api.yml

@@ -1,6 +1,6 @@
 - name: Install "pm2" package globally.
 - name: Install "pm2" package globally.
   community.general.npm:
   community.general.npm:
-    name: yarn
+    name: pm2
     global: yes
     global: yes
 
 
 - name: Run pm2 first time
 - name: Run pm2 first time

+ 45 - 0
apps/anonaddy/config.json

@@ -0,0 +1,45 @@
+{
+  "name": "Anonaddy",
+  "port": 8084,
+  "id": "anonaddy",
+  "description": "",
+  "short_desc": "Anonymous email forwarding",
+  "author": "",
+  "source": "https://github.com/anonaddy/anonaddy",
+  "image": "https://avatars.githubusercontent.com/u/51450862?s=200&v=4",
+  "requirements": {
+    "ports": [25]
+  },
+  "form_fields": {
+    "username": {
+      "type": "text",
+      "label": "Username",
+      "required": true,
+      "env_variable": "ANONADDY_USERNAME"
+    },
+    "key": {
+      "type": "text",
+      "label": "App key",
+      "hint": "Application key for encrypter service. Generate one with : echo \"base64:$(openssl rand -base64 32)\"",
+      "required": true,
+      "env_variable": "ANONADDY_KEY"
+    },
+    "domain": {
+      "type": "fqdn",
+      "label": "Your email domain (eg. example.com)",
+      "max": 50,
+      "min": 3,
+      "required": true,
+      "env_variable": "ANONADDY_DOMAIN"
+    },
+    "secret": {
+      "type": "text",
+      "label": "App secret",
+      "hint": "Long random string used when hashing data for the anonymous replies",
+      "max": 50,
+      "min": 3,
+      "required": true,
+      "env_variable": "ANONADDY_SECRET"
+    }
+  }
+}

+ 14 - 11
apps/anonaddy/docker-compose.yml

@@ -32,29 +32,32 @@ services:
     container_name: anonaddy
     container_name: anonaddy
     ports:
     ports:
       - 25:25
       - 25:25
-      - ${APP_ANONADDY_PORT}:8000
+      - ${APP_PORT}:8000
     depends_on:
     depends_on:
       - db
       - db
       - redis
       - redis
     volumes:
     volumes:
       - "${APP_DATA_DIR}/data:/data"
       - "${APP_DATA_DIR}/data:/data"
     environment:
     environment:
+      TZ: ${TZ}
       DB_HOST: db-anonaddy
       DB_HOST: db-anonaddy
       DB_PASSWORD: anonaddy
       DB_PASSWORD: anonaddy
       REDIS_HOST: redis-anonaddy
       REDIS_HOST: redis-anonaddy
-      APP_KEY: ${APP_ANONADDY_KEY}
-      ANONADDY_DOMAIN: ${APP_ANONADDY_DOMAIN}
-      ANONADDY_SECRET: ${APP_ANONADDY_SECRET}
+      APP_KEY: ${ANONADDY_KEY}
+      ANONADDY_DOMAIN: ${ANONADDY_DOMAIN}
+      ANONADDY_SECRET: ${ANONADDY_SECRET}
+      ANONADDY_ADMIN_USERNAME: ${ANONADDY_USERNAME}
+      POSTFIX_DEBUG: true
     restart: unless-stopped
     restart: unless-stopped
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
-    labels:
-        traefik.enable: true
-        traefik.http.routers.anonaddy.rule: Host(`anonaddy.tipi.home`)
-        traefik.http.routers.anonaddy.tls: true
-        traefik.http.routers.anonaddy.entrypoints: websecure
-        traefik.http.routers.anonaddy.service: anonaddy
-        traefik.http.services.anonaddy.loadbalancer.server.port: 8000
+    # labels:
+    #     traefik.enable: true
+    #     traefik.http.routers.anonaddy.rule: Host(`anonaddy.tipi.home`)
+    #     traefik.http.routers.anonaddy.tls: true
+    #     traefik.http.routers.anonaddy.entrypoints: websecure
+    #     traefik.http.routers.anonaddy.service: anonaddy
+    #     traefik.http.services.anonaddy.loadbalancer.server.port: 8000
     # labels:
     # labels:
     #     traefik.enable: true
     #     traefik.enable: true
     #     traefik.http.routers.anonaddy.rule: PathPrefix(`/anonaddy`)
     #     traefik.http.routers.anonaddy.rule: PathPrefix(`/anonaddy`)

+ 0 - 0
apps/test/Dockerfile → apps/busybox/Dockerfile


+ 11 - 0
apps/busybox/config.json

@@ -0,0 +1,11 @@
+{
+  "name": "BusyBox",
+  "port": 3000,
+  "id": "busybox",
+  "description": "",
+  "short_desc": "",
+  "author": "",
+  "source": "",
+  "image": "https://raw.githubusercontent.com/docker-library/docs/cc5d5e47fd7e0c57c9b8de4c1bfb6258e0dac85d/busybox/logo.png",
+  "form_fields": {}
+}

+ 6 - 0
apps/busybox/docker-compose.yml

@@ -0,0 +1,6 @@
+version: "3.7"
+services:
+  test:
+    image: meienberger/ubuntu-test
+    networks:
+      - tipi_main_network

+ 0 - 1
apps/docker-compose.common.yml

@@ -2,5 +2,4 @@ version: "3.7"
 
 
 networks:
 networks:
   tipi_main_network:
   tipi_main_network:
-    external:
       name: runtipi_tipi_main_network
       name: runtipi_tipi_main_network

+ 11 - 0
apps/filerun/config.json

@@ -0,0 +1,11 @@
+{
+  "name": "FileRun",
+  "port": 8087,
+  "id": "filerun",
+  "description": "Reliable and Performant File Management Desktop Sync and File Sharing",
+  "short_desc": "Access your homeserver files from your browser",
+  "author": "FileRun, LDA - Portugal",
+  "source": "https://www.filerun.com/",
+  "image": "https://avatars.githubusercontent.com/u/6422152?v=4",
+  "form_fields": {}
+}

+ 38 - 0
apps/filerun/docker-compose.yml

@@ -0,0 +1,38 @@
+services:
+  filerun-db:
+    container_name: filerun-db
+    image: mariadb:10.1
+    environment:
+      MYSQL_ROOT_PASSWORD: tipi
+      MYSQL_USER: tipi
+      MYSQL_PASSWORD: tipi
+      MYSQL_DATABASE: tipi
+    volumes:
+      - ${APP_DATA_DIR}/data/db:/var/lib/mysql
+    networks:
+      - tipi_main_network
+
+  filerun:
+    container_name: filerun
+    image: filerun/filerun:arm64v8
+    environment:
+      FR_DB_HOST: filerun-db
+      FR_DB_PORT: 3306
+      FR_DB_NAME: tipi
+      FR_DB_USER: tipi
+      FR_DB_PASS: tipi
+      APACHE_RUN_USER: ${PUID}
+      APACHE_RUN_GROUP: ${PGID}
+      APACHE_RUN_USER_ID: 33
+      APACHE_RUN_GROUP_ID: 33
+    depends_on:
+      - db
+    links:
+      - db:db
+    ports:
+      - ${APP_PORT}:80
+    volumes:
+      - ${APP_DATA_DIR}/data/html:/var/www/html
+      - ${ROOT_FOLDER}/app-data:/user-files
+    networks:
+      - tipi_main_network

+ 11 - 0
apps/freshrss/config.json

@@ -0,0 +1,11 @@
+{
+  "name": "FreshRSS",
+  "port": 8086,
+  "id": "freshrss",
+  "description": "FreshRSS is a self-hosted RSS feed aggregator like Leed or Kriss Feed.\nIt is lightweight, easy to work with, powerful, and customizable.\n\nIt is a multi-user application with an anonymous reading mode. It supports custom tags. There is an API for (mobile) clients, and a Command-Line Interface.\n\nThanks to the WebSub standard (formerly PubSubHubbub), FreshRSS is able to receive instant push notifications from compatible sources, such as Mastodon, Friendica, WordPress, Blogger, FeedBurner, etc.\n\nFreshRSS natively supports basic Web scraping, based on XPath, for Web sites not providing any RSS / Atom feed.\n\nFinally, it supports extensions for further tuning.",
+  "short_desc": "A free, self-hostable aggregator… ",
+  "author": "https://freshrss.org/",
+  "source": "https://github.com/FreshRSS/FreshRSS",
+  "image": "https://avatars.githubusercontent.com/u/9414285?s=200&v=4",
+  "form_fields": {}
+}

+ 10 - 10
apps/freshrss/docker-compose.yml

@@ -6,19 +6,19 @@ services:
     image: freshrss/freshrss:arm
     image: freshrss/freshrss:arm
     restart: unless-stopped
     restart: unless-stopped
     ports:
     ports:
-      - "${APP_FRESHRSS_PORT}:80"
+      - ${APP_PORT}:80
     volumes:
     volumes:
-      - ${APP_DATA_DIR}/data/:/var/www/FreshRSS/data
-      - ${APP_DATA_DIR}/extensions/:/var/www/FreshRSS/extensions
+      - ${APP_DATA_DIR}/data/freshrss:/var/www/FreshRSS/data
+      - ${APP_DATA_DIR}/data/extensions/:/var/www/FreshRSS/extensions
     environment:
     environment:
       CRON_MIN: '*/20'
       CRON_MIN: '*/20'
       TZ: $TZ
       TZ: $TZ
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
-    labels:
-        traefik.enable: true
-        traefik.http.routers.freshrss.rule: Host(`freshrss.tipi.home`)
-        traefik.http.routers.freshrss.service: freshrss
-        traefik.http.routers.freshrss.tls: true
-        traefik.http.routers.freshrss.entrypoints: websecure
-        traefik.http.services.freshrss.loadbalancer.server.port: 80
+    # labels:
+    #     traefik.enable: true
+    #     traefik.http.routers.freshrss.rule: Host(`freshrss.tipi.home`)
+    #     traefik.http.routers.freshrss.service: freshrss
+    #     traefik.http.routers.freshrss.tls: true
+    #     traefik.http.routers.freshrss.entrypoints: websecure
+    #     traefik.http.services.freshrss.loadbalancer.server.port: 80

+ 11 - 0
apps/jellyfin/config.json

@@ -0,0 +1,11 @@
+{
+  "name": "Jellyfin",
+  "port": 8091,
+  "id": "jellyfin",
+  "description": "",
+  "short_desc": "",
+  "author": "",
+  "source": "",
+  "image": "https://avatars.githubusercontent.com/u/45698031?s=200&v=4",
+  "form_fields": {}
+}

+ 0 - 0
apps/nextcloud/data/db/.gitkeep → apps/jellyfin/data/config/.gitkeep


+ 0 - 0
apps/pi-hole/data/dnsmasq/.gitkeep → apps/jellyfin/data/media/.gitkeep


+ 18 - 0
apps/jellyfin/docker-compose.yml

@@ -0,0 +1,18 @@
+version: "3.7"
+
+services:
+  jellyfin:
+    image: lscr.io/linuxserver/jellyfin
+    container_name: jellyfin
+    volumes:
+      - ${APP_DATA_DIR}/data/config:/config
+      - ${APP_DATA_DIR}/data/media:/data/media
+    environment:
+      - PUID=1000
+      - PGID=1000
+      - TZ=${TZ}
+    restart: "unless-stopped"
+    ports:
+      - ${APP_PORT}:8096
+    networks:
+      - tipi_main_network

+ 8 - 2
apps/nextcloud/config.json

@@ -1,6 +1,12 @@
 {
 {
   "name": "Nextcloud",
   "name": "Nextcloud",
+  "port": 8083,
+  "id": "nextcloud",
   "description": "Nextcloud is a self-hosted, open source, and fully-featured cloud storage solution for your personal files, office documents, and photos.",
   "description": "Nextcloud is a self-hosted, open source, and fully-featured cloud storage solution for your personal files, office documents, and photos.",
+  "short_desc": "Productivity platform that keeps you in control",
+  "author": "Nextcloud GmbH",
+  "source": "https://github.com/nextcloud/server",
+  "image": "https://avatars.githubusercontent.com/u/19211038?s=200&v=4",
   "form_fields": {
   "form_fields": {
     "username": {
     "username": {
       "type": "text",
       "type": "text",
@@ -8,7 +14,7 @@
       "max": 50,
       "max": 50,
       "min": 3,
       "min": 3,
       "required": true,
       "required": true,
-      "env_variable": "NEXTCLOUD_USERNAME"
+      "env_variable": "NEXTCLOUD_ADMIN_USER"
     },
     },
     "password": {
     "password": {
       "type": "password",
       "type": "password",
@@ -16,7 +22,7 @@
       "max": 50,
       "max": 50,
       "min": 3,
       "min": 3,
       "required": true,
       "required": true,
-      "env_variable": "NEXTCLOUD_PASSWORD"
+      "env_variable": "NEXTCLOUD_ADMIN_PASSWORD"
     }
     }
   }
   }
 }
 }

+ 16 - 19
apps/nextcloud/docker-compose.yml

@@ -3,24 +3,21 @@ version: "3.7"
 services:
 services:
   db-nextcloud:
   db-nextcloud:
     container_name: db-nextcloud
     container_name: db-nextcloud
-    # user: '1000:1000'
-    image: mariadb:10.5.12
-    command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
+    image: postgres:14.2
     restart: on-failure
     restart: on-failure
     volumes:
     volumes:
-      - ${APP_DATA_DIR}/data/db:/var/lib/mysql
+      - ${APP_DATA_DIR}/data/db:/var/lib/postgresql/data
     environment:
     environment:
-      - MYSQL_ROOT_PASSWORD=password
-      - MYSQL_PASSWORD=password
-      - MYSQL_DATABASE=nextcloud
-      - MYSQL_USER=nextcloud
+      - POSTGRES_PASSWORD=tipi
+      - POSTGRES_USER=tipi
+      - POSTGRES_DB=nextcloud
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
 
 
   redis-nextcloud:
   redis-nextcloud:
     container_name: redis-nextcloud
     container_name: redis-nextcloud
-    # user: '1000:1000'
-    image: redis:6.2.2-buster
+    user: "1000:1000"
+    image: redis:6.2.6
     restart: on-failure
     restart: on-failure
     volumes:
     volumes:
       - "${APP_DATA_DIR}/data/redis:/data"
       - "${APP_DATA_DIR}/data/redis:/data"
@@ -28,7 +25,7 @@ services:
       - tipi_main_network
       - tipi_main_network
 
 
   cron:
   cron:
-    image: nextcloud:22.0.0-apache
+    image: nextcloud:23.0.3-apache
     restart: on-failure
     restart: on-failure
     volumes:
     volumes:
       - ${APP_DATA_DIR}/data/nextcloud:/var/www/html
       - ${APP_DATA_DIR}/data/nextcloud:/var/www/html
@@ -40,23 +37,23 @@ services:
       - tipi_main_network
       - tipi_main_network
 
 
   nextcloud:
   nextcloud:
-    user: root
     container_name: nextcloud
     container_name: nextcloud
-    image: nextcloud:22.1.1-apache
+    image: nextcloud:23.0.3-apache
     restart: unless-stopped
     restart: unless-stopped
     ports:
     ports:
-      - ${APP_NEXTCLOUD_PORT}:80
+      - ${APP_PORT}:80
     volumes:
     volumes:
       - ${APP_DATA_DIR}/data/nextcloud:/var/www/html
       - ${APP_DATA_DIR}/data/nextcloud:/var/www/html
+      - /volumes/nfs:/nfs
     environment:
     environment:
-      - MYSQL_HOST=db-nextcloud
+      - POSTGRES_HOST=db-nextcloud
       - REDIS_HOST=redis-nextcloud
       - REDIS_HOST=redis-nextcloud
-      - MYSQL_PASSWORD=password
-      - MYSQL_DATABASE=nextcloud
-      - MYSQL_USER=nextcloud
+      - POSTGRES_PASSWORD=tipi
+      - POSTGRES_USER=tipi
+      - POSTGRES_DB=nextcloud
       - NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER}
       - NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER}
       - NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD}
       - NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD}
-      - NEXTCLOUD_TRUSTED_DOMAINS=tipi.local
+      - NEXTCLOUD_TRUSTED_DOMAINS=tipi.local ${DEVICE_IP}:${APP_PORT}
     depends_on:
     depends_on:
       - db-nextcloud
       - db-nextcloud
       - redis-nextcloud
       - redis-nextcloud

+ 0 - 24
apps/pi-hole/data/unbound/unbound.conf

@@ -1,24 +0,0 @@
-## DNS Over TLS, Simple ENCRYPTED recursive caching DNS, TCP port 853
-## unbound.conf, original at https://calomel.org/unbound_dns.html
-# tweaks by bartonbytes.com
-server:
-access-control: 127.0.0.0/8 allow
-cache-max-ttl: 14400
-cache-min-ttl: 600
-do-tcp: yes
-hide-identity: yes
-hide-version: yes
-interface: 127.0.0.1
-minimal-responses: yes
-prefetch: yes
-qname-minimisation: yes
-rrset-roundrobin: yes
-ssl-upstream: yes
-use-caps-for-id: yes
-verbosity: 1
-port: 5533
-#
-forward-zone:
-name: "."
-forward-addr: 194.242.2.3@853 # Mullvad primary
-forward-addr: 193.19.108.3@853 # Mullvad secondary

+ 0 - 34
apps/pi-hole/docker-compose.yml

@@ -1,34 +0,0 @@
-version: "3.7"
-
-services:
-  unbound:
-    user: '1000:1000'
-    image: "klutchell/unbound:latest"
-    volumes:
-      - ${APP_DATA_DIR}/data/unbound:/etc/unbound
-    networks:
-        - tipi_main_network
-  
-  pihole:
-    image: pihole/pihole
-    restart: on-failure
-    ports:
-      - 53:53
-      - 53:53/udp
-      - ${APP_PI_HOLE_PORT}:80
-    volumes:
-      - ${APP_DATA_DIR}/data/pihole:/etc/pihole/
-      - ${APP_DATA_DIR}/data/dnsmasq:/etc/dnsmasq.d/
-    environment:
-      - VIRTUAL_HOST="pihole.${DOMAIN}"
-      - WEBPASSWORD=${APP_PASSWORD}
-      - PIHOLE_DNS=unbound
-    depends_on:
-      - unbound
-    networks:
-        - tipi_main_network
-    labels:
-      traefik.enable: true
-      traefik.http.routers.traefik.rule: Host(`pihole.${DOMAIN}`)
-      traefik.http.services.traefik.loadbalancer.server.port: $APP_PI_HOLE_PORT
-

+ 23 - 0
apps/pihole/config.json

@@ -0,0 +1,23 @@
+{
+    "name": "PiHole",
+    "port": 8081,
+    "requirements": {
+        "ports": [53]
+    },
+    "id": "pihole",
+    "description": "",
+    "short_desc": "",
+    "author": "",
+    "source": "",
+    "image": "https://avatars.githubusercontent.com/u/16827203?s=200&v=4",
+    "form_fields": {
+        "password": {
+            "type": "password",
+            "label": "Password",
+            "max": 50,
+            "min": 3,
+            "required": true,
+            "env_variable": "APP_PASSWORD"
+        }
+    }
+}

+ 24 - 0
apps/pihole/docker-compose.yml

@@ -0,0 +1,24 @@
+version: "3.7"
+
+services:
+  pihole:
+    container_name: pihole
+    image: cbcrowe/pihole-unbound:latest
+    restart: unless-stopped
+    ports:
+      - 53:53/tcp
+      - 53:53/udp
+      - ${APP_PORT}:80
+    volumes:
+      - ${APP_DATA_DIR}/data/pihole:/etc/pihole
+      - ${APP_DATA_DIR}/data/dnsmasq:/etc/dnsmasq.d
+    environment:
+      TZ: ${TZ}
+      WEBPASSWORD: ${APP_PASSWORD}
+      PIHOLE_DNS_: 127.0.0.1#5335
+      FTLCONF_REPLY_ADDR4: 192.168.2.132
+      PIHOLE_DNS_: 127.0.0.1#5335
+      DNSSEC: "true"
+      DNSMASQ_LISTENING: single
+    networks:
+      - tipi_main_network

+ 20 - 0
apps/radarr/config.json

@@ -0,0 +1,20 @@
+{
+  "name": "Radarr",
+  "port": 8088,
+  "id": "radarr",
+  "description": "",
+  "short_desc": "",
+  "author": "",
+  "source": "",
+  "image": "https://avatars.githubusercontent.com/u/25025331?s=200&v=4",
+  "form_fields": {
+    "torrent-client": {
+      "type": "text",
+      "label": "Torrent Client",
+      "max": 50,
+      "min": 3,
+      "required": true,
+      "env_variable": "TORRENT_CLIENT"
+    }
+  }
+}

+ 35 - 0
apps/radarr/docker-compose.yml

@@ -0,0 +1,35 @@
+version: "3.7"
+services:
+  jackett:
+    image: lscr.io/linuxserver/jackett
+    container_name: jackett
+    environment:
+      - PUID=1000
+      - PGID=1000
+      - TZ=${TZ}
+      - AUTO_UPDATE=true
+    volumes:
+      - ${APP_DATA_DIR}/data/config:/config
+      - ${APP_DATA_DIR}/data/downloads:/downloads
+    ports:
+      - 9117:9117
+    restart: unless-stopped
+    networks:
+      - tipi_main_network
+
+  radarr:
+    image: lscr.io/linuxserver/radarr
+    container_name: radarr
+    environment:
+      - PUID=1000
+      - PGID=1000
+      - TZ=${TZ}
+    volumes:
+      - ${APP_DATA_DIR}/data/config:/config
+      - ${APP_DATA_DIR}/data/movies:/movies #optional
+      - ${ROOT_FOLDER}/app-data/${TORRENT_CLIENT}/data/downloads:/downloads #optional
+    ports:
+      - ${APP_PORT}:7878
+    restart: unless-stopped
+    networks:
+      - tipi_main_network

+ 11 - 0
apps/simple-torrent/config.json

@@ -0,0 +1,11 @@
+{
+  "name": "Simple Torrent",
+  "port": 8085,
+  "id": "simple-torrent",
+  "description": "SimpleTorrent is a a self-hosted remote torrent client, written in Go (golang). Started torrents remotely, download sets of files on the local disk of the server, which are then retrievable or streamable via HTTP.",
+  "short_desc": "A self-hosted remote torrent client",
+  "author": "",
+  "source": "https://github.com/boypt/simple-torrent",
+  "image": "https://getumbrel.github.io/umbrel-apps-gallery/simple-torrent/icon.svg",
+  "form_fields": {}
+}

+ 2 - 2
apps/simple-torrent/docker-compose.yml

@@ -6,9 +6,9 @@ services:
     image: boypt/cloud-torrent:1.3.9
     image: boypt/cloud-torrent:1.3.9
     restart: on-failure
     restart: on-failure
     ports:
     ports:
-      - "${APP_SIMPLETORRENT_PORT}:${APP_SIMPLETORRENT_PORT}"
+      - ${APP_PORT}:${APP_PORT}
     command: >
     command: >
-      --port=${APP_SIMPLETORRENT_PORT}
+      --port=${APP_PORT}
       --config-path /config/simple-torrent.json
       --config-path /config/simple-torrent.json
     volumes:
     volumes:
       - ${APP_DATA_DIR}/data/torrents:/torrents
       - ${APP_DATA_DIR}/data/torrents:/torrents

+ 12 - 0
apps/syncthing/config.json

@@ -0,0 +1,12 @@
+{
+  "name": "Syncthing",
+  "port": 8090,
+  "id": "syncthing",
+  "description": "Syncthing is a peer-to-peer continuous file synchronization program. It synchronizes files between two or more computers in real time, safely protected from prying eyes. Your data is your data alone and you deserve to choose where it is stored, whether it is shared with some third party, and how it's transmitted over the internet.\n\nInstall the Syncthing app on your Umbrel and pair it with the Syncthing app on your phone or computer for a self hosted peer-to-peer backup solution.",
+  "short_desc": "Peer-to-peer file synchronization between your devices",
+  "author": "The Syncthing Foundation",
+  "source": "https://github.com/syncthing",
+  "website": "https://syncthing.net",
+  "image": "https://avatars.githubusercontent.com/u/7628018?s=200&v=4",
+  "form_fields": {}
+}

+ 0 - 0
apps/pi-hole/data/pihole/.gitkeep → apps/syncthing/data/.gitkeep


+ 20 - 0
apps/syncthing/docker-compose.yml

@@ -0,0 +1,20 @@
+version: "3.7"
+
+services:
+  server:
+    image: syncthing/syncthing:1.19
+    stop_grace_period: 1m
+    hostname: tipi
+    environment:
+      - PUID=1000
+      - PGID=1000
+    volumes:
+      - ${APP_DATA_DIR}/data:/var/syncthing
+    ports:
+      - ${APP_PORT}:8384
+      - 22000:22000/tcp # TCP file transfers
+      - 22000:22000/udp # QUIC file transfers
+      - 21027:21027/udp # Receive local discovery broadcasts
+    restart: unless-stopped
+    networks:
+      - tipi_main_network

+ 0 - 8
apps/test/docker-compose.yml

@@ -1,8 +0,0 @@
-version: '3.7'
-services:
-  test:
-    build:
-      context: .
-      dockerfile: Dockerfile
-    networks:
-      - tipi_main_network

+ 31 - 0
apps/transmission/config.json

@@ -0,0 +1,31 @@
+{
+  "name": "Transmission",
+  "port": 8089,
+  "requirements": {
+    "ports": [51413]
+  },
+  "id": "transmission",
+  "description": "",
+  "short_desc": "",
+  "author": "",
+  "source": "https://transmissionbt.com",
+  "image": "https://avatars.githubusercontent.com/u/223312?s=200&v=4",
+  "form_fields": {
+    "username": {
+      "type": "text",
+      "label": "Username",
+      "max": 50,
+      "min": 3,
+      "required": true,
+      "env_variable": "TRANSMISSION_USERNAME"
+    },
+    "password": {
+      "type": "password",
+      "label": "Password",
+      "max": 50,
+      "min": 3,
+      "required": true,
+      "env_variable": "TRANSMISSION_PASSWORD"
+    }
+  }
+}

+ 0 - 0
apps/transmission/data/config/.gitkeep


+ 0 - 0
apps/transmission/data/downloads/.gitkeep


+ 0 - 0
apps/transmission/data/watch/.gitkeep


+ 25 - 0
apps/transmission/docker-compose.yml

@@ -0,0 +1,25 @@
+version: "3.7"
+services:
+  transmission:
+    image: lscr.io/linuxserver/transmission
+    container_name: transmission
+    environment:
+      - PUID=1000
+      - PGID=1000
+      - TZ=${TZ}
+      - USER=${TRANSMISSION_USERNAME}
+      - PASS=${TRANSMISSION_PASSWORD}
+      # - WHITELIST=iplist #optional
+      # - PEERPORT=peerport #optional
+      # - HOST_WHITELIST=dnsnane list #optional
+    volumes:
+      - ${APP_DATA_DIR}/data/config:/config
+      - ${APP_DATA_DIR}/data/downloads:/downloads
+      - ${APP_DATA_DIR}/data/watch:/watch
+    ports:
+      - ${APP_PORT}:9091
+      - 51413:51413
+      - 51413:51413/udp
+    restart: unless-stopped
+    networks:
+      - tipi_main_network

+ 35 - 0
apps/wg-easy/config.json

@@ -0,0 +1,35 @@
+{
+  "name": "Wireguard",
+  "port": 8082,
+  "requirements": {
+    "ports": [51820]
+  },
+  "id": "wg-easy",
+  "description": "Access your homeserver from anywhere even on your mobile device. Wireguard-easy is a simple tool to configure and manage Wireguard VPN servers. It is written in Go and uses the official Wireguard client. You have to open and redirect port 51820 to your homeserver in order to connect.",
+  "short_desc": "VPN server for your homeserver",
+  "author": "WeeJeWel",
+  "source": "https://github.com/WeeJeWel/wg-easy/",
+  "image": "https://avatars.githubusercontent.com/u/13991055?s=200&v=4",
+  "form_fields": {
+    "host": {
+      "type": "fqdnip",
+      "label": "Your public IP address or domain name",
+      "required": true,
+      "env_variable": "WIREGUARD_HOST"
+    },
+    "password": {
+      "type": "password",
+      "label": "Password",
+      "max": 50,
+      "min": 3,
+      "required": true,
+      "env_variable": "WIREGUARD_PASSWORD"
+    },
+    "dns": {
+      "type": "ip",
+      "label": "Default DNS server",
+      "required": false,
+      "env_variable": "WIREGUARD_DNS"
+    }
+  }
+}

+ 0 - 0
apps/wg-easy/data/.gitkeep


+ 33 - 27
apps/wg-easy/docker-compose.yml

@@ -1,29 +1,35 @@
-version: '3.7'
+version: "3.7"
 services:
 services:
   wg-easy:
   wg-easy:
-      container_name: wg-easy
-      image: 'weejewel/wg-easy:latest'
-      restart: unless-stopped
-      volumes:
-        - ${APP_DATA_DIR}:/etc/wireguard
-      ports:
-        - 51820:51820
-        - ${APP_WGEASY_PORT}:51821
-      environment:
-        WG_HOST: '${WIREGUARD_HOST}'
-        PASSWORD: '${WIREGUARD_PASSWORD}'
-      cap_add:
-        - NET_ADMIN
-        - SYS_MODULE
-      sysctls:
-        - net.ipv4.conf.all.src_valid_mark=1
-        - net.ipv4.ip_forward=1
-      networks:
-        - tipi_main_network
-      # labels:
-      #   traefik.enable: true
-      #   traefik.http.routers.wireguard.rule: Host(`wireguard.tipi.home`)
-      #   traefik.http.routers.wireguard.service: wireguard
-      #   traefik.http.routers.wireguard.tls: true
-      #   traefik.http.routers.wireguard.entrypoints: websecure
-      #   traefik.http.services.wireguard.loadbalancer.server.port: 51821
+    container_name: wg-easy
+    image: "meienberger/wg-easy:latest"
+    restart: unless-stopped
+    # network_mode: "host"
+    volumes:
+      - ${APP_DATA_DIR}/data:/etc/wireguard
+      - /lib/modules:/lib/modules
+    ports:
+      - 51822:51820/udp
+      - ${APP_PORT}:51821/tcp
+    environment:
+      WG_HOST: "${WIREGUARD_HOST}"
+      PASSWORD: "${WIREGUARD_PASSWORD}"
+      WG_ALLOWED_IPS: 0.0.0.0/0,::/0
+      WG_PORT: 51822
+      WG_DEFAULT_DNS: "${WIREGUARD_DNS:-8.8.8.8}"
+      # WG_FWMARK: 51820
+    cap_add:
+      - NET_ADMIN
+      - SYS_MODULE
+    sysctls:
+      - net.ipv4.conf.all.src_valid_mark=1
+      - net.ipv4.ip_forward=1
+    networks:
+      - tipi_main_network
+    # labels:
+    #   traefik.enable: true
+    #   traefik.http.routers.wireguard.rule: Host(`wireguard.tipi.home`)
+    #   traefik.http.routers.wireguard.service: wireguard
+    #   traefik.http.routers.wireguard.tls: true
+    #   traefik.http.routers.wireguard.entrypoints: websecure
+    #   traefik.http.services.wireguard.loadbalancer.server.port: 51821

+ 17 - 0
dashboard/.eslintrc.js

@@ -0,0 +1,17 @@
+module.exports = {
+  extends: ['next/core-web-vitals', 'airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript'],
+  parser: '@typescript-eslint/parser',
+  parserOptions: {
+    ecmaVersion: 'latest',
+    sourceType: 'module',
+    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' }],
+  },
+};

+ 0 - 3
dashboard/.eslintrc.json

@@ -1,3 +0,0 @@
-{
-  "extends": "next/core-web-vitals"
-}

+ 6 - 0
dashboard/.prettierrc.js

@@ -0,0 +1,6 @@
+module.exports = {
+  singleQuote: true,
+  semi: true,
+  trailingComma: "all",
+  printWidth: 200,
+};

+ 3 - 0
dashboard/Dockerfile

@@ -9,6 +9,9 @@ RUN yarn
 
 
 COPY ./ ./
 COPY ./ ./
 
 
+ARG INTERNAL_IP_ARG
+ENV INTERNAL_IP $INTERNAL_IP_ARG
+
 RUN yarn build
 RUN yarn build
 
 
 CMD ["yarn", "start"]
 CMD ["yarn", "start"]

+ 12 - 0
dashboard/Dockerfile.dev

@@ -0,0 +1,12 @@
+FROM node:latest
+
+WORKDIR /app
+
+COPY ./package.json ./
+COPY ./yarn.lock ./
+
+RUN yarn
+
+COPY ./ ./
+
+CMD ["yarn", "dev"]

+ 7 - 2
dashboard/next.config.js

@@ -1,6 +1,11 @@
 /** @type {import('next').NextConfig} */
 /** @type {import('next').NextConfig} */
+console.log(process.env);
+
 const nextConfig = {
 const nextConfig = {
   reactStrictMode: true,
   reactStrictMode: true,
-}
+  env: {
+    INTERNAL_IP: process.env.INTERNAL_IP,
+  },
+};
 
 
-module.exports = nextConfig
+module.exports = nextConfig;

+ 14 - 1
dashboard/package.json

@@ -12,20 +12,33 @@
     "@chakra-ui/react": "^1.8.7",
     "@chakra-ui/react": "^1.8.7",
     "@emotion/react": "^11",
     "@emotion/react": "^11",
     "@emotion/styled": "^11",
     "@emotion/styled": "^11",
+    "axios": "^0.26.1",
+    "clsx": "^1.1.1",
+    "final-form": "^4.20.6",
     "framer-motion": "^6",
     "framer-motion": "^6",
+    "immer": "^9.0.12",
     "next": "12.1.4",
     "next": "12.1.4",
     "react": "18.0.0",
     "react": "18.0.0",
     "react-dom": "18.0.0",
     "react-dom": "18.0.0",
+    "react-final-form": "^6.5.9",
+    "react-icons": "^4.3.1",
+    "swr": "^1.3.0",
     "systeminformation": "^5.11.9",
     "systeminformation": "^5.11.9",
-    "validator": "^13.7.0"
+    "validator": "^13.7.0",
+    "zustand": "^3.7.2"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/node": "17.0.23",
     "@types/node": "17.0.23",
     "@types/react": "17.0.43",
     "@types/react": "17.0.43",
     "@types/react-dom": "17.0.14",
     "@types/react-dom": "17.0.14",
     "@types/validator": "^13.7.2",
     "@types/validator": "^13.7.2",
+    "@typescript-eslint/eslint-plugin": "^5.18.0",
+    "autoprefixer": "^10.4.4",
     "eslint": "8.12.0",
     "eslint": "8.12.0",
+    "eslint-config-airbnb-typescript": "^17.0.0",
     "eslint-config-next": "12.1.4",
     "eslint-config-next": "12.1.4",
+    "postcss": "^8.4.12",
+    "tailwindcss": "^3.0.23",
     "typescript": "4.6.3"
     "typescript": "4.6.3"
   }
   }
 }
 }

+ 6 - 0
dashboard/postcss.config.js

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

二进制
dashboard/public/logo.png


+ 0 - 4
dashboard/public/logo.svg

@@ -1,4 +0,0 @@
-<svg width="283" height="64" viewBox="0 0 283 64" fill="none" 
-    xmlns="http://www.w3.org/2000/svg">
-    <path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
-</svg>

+ 23 - 0
dashboard/src/components/AppTile/AppStatus.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+import { FiPauseCircle, FiPlayCircle } from 'react-icons/fi';
+import { AppStatus as TAppStatus } from '../../core/types';
+
+const AppStatus: React.FC<{ status: TAppStatus }> = ({ status }) => {
+  if (status === 'running') {
+    return (
+      <>
+        <FiPlayCircle className="text-green-500 mr-1" size={20} />
+        <span className="text-gray-400 text-sm">Running</span>
+      </>
+    );
+  }
+
+  return (
+    <>
+      <FiPauseCircle className="text-red-500 mr-1" size={20} />
+      <span className="text-gray-400 text-sm">Stopped</span>
+    </>
+  );
+};
+
+export default AppStatus;

+ 30 - 0
dashboard/src/components/AppTile/index.tsx

@@ -0,0 +1,30 @@
+import { Box, SlideFade, Image } from '@chakra-ui/react';
+import Link from 'next/link';
+import React from 'react';
+import { FiChevronRight } from 'react-icons/fi';
+import { AppConfig } from '../../core/types';
+import AppStatus from './AppStatus';
+
+const AppTile: React.FC<{ app: AppConfig }> = ({ app }) => {
+  return (
+    <Link href={`/apps/${app.id}`} passHref>
+      <SlideFade in className="flex flex-1" offsetY="20px">
+        <Box minWidth={400} className="flex flex-1 bg-white drop-shadow-lg rounded-lg p-3 items-center cursor-pointer group hover:drop-shadow-md hover:bg-gray-100 transition-all">
+          <Image alt={`${app.name} logo`} className="rounded-md drop-shadow mr-3 group-hover:scale-105 transition-all" src={app.image} width={100} height={100} />
+          <div className="mr-3 flex-1">
+            <h3 className="font-bold text-xl">{app.name}</h3>
+            <span>{app.short_desc}</span>
+            {app.installed && (
+              <div className="flex mt-1">
+                <AppStatus status={app.status} />
+              </div>
+            )}
+          </div>
+          <FiChevronRight className="text-slate-300" size={30} />
+        </Box>
+      </SlideFade>
+    </Link>
+  );
+};
+
+export default AppTile;

+ 24 - 0
dashboard/src/components/Form/FormInput.tsx

@@ -0,0 +1,24 @@
+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;
+}
+
+const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, ...rest }) => {
+  return (
+    <div className={clsx('transition-all', className)}>
+      <label>{label}</label>
+      <Input type={type} placeholder={placeholder} isInvalid={isInvalid} {...rest} />
+      {isInvalid && <span className="text-red-500 text-sm">{error}</span>}
+    </div>
+  );
+};
+
+export default FormInput;

+ 28 - 0
dashboard/src/components/Form/validators.ts

@@ -0,0 +1,28 @@
+import validator from 'validator';
+import { AppConfig, FieldTypes } from '../../core/types';
+
+export const validateAppConfig = (values: Record<string, string>, fields: (AppConfig['form_fields'][0] & { id: string })[]) => {
+  const errors: any = {};
+
+  fields.forEach((field) => {
+    if (field.required && !values[field.id]) {
+      errors[field.id] = 'Field required';
+    } else if (values[field.id] && field.min && values[field.id].length < field.min) {
+      errors[field.id] = `Field must be at least ${field.min} characters long`;
+    } else if (values[field.id] && field.max && values[field.id].length > field.max) {
+      errors[field.id] = `Field must be at most ${field.max} characters long`;
+    } else if (values[field.id] && field.type === FieldTypes.number && !validator.isNumeric(values[field.id])) {
+      errors[field.id] = 'Field must be a number';
+    } else if (values[field.id] && field.type === FieldTypes.email && !validator.isEmail(values[field.id])) {
+      errors[field.id] = 'Field must be a valid email';
+    } else if (values[field.id] && field.type === FieldTypes.fqdn && !validator.isFQDN(values[field.id] || '')) {
+      errors[field.id] = 'Field must be a valid domain';
+    } else if (values[field.id] && field.type === FieldTypes.ip && !validator.isIP(values[field.id])) {
+      errors[field.id] = 'Field must be a valid IP address';
+    } else if (values[field.id] && field.type === FieldTypes.fqdnip && !validator.isFQDN(values[field.id] || '') && !validator.isIP(values[field.id])) {
+      errors[field.id] = 'Field must be a valid domain or IP address';
+    }
+  });
+
+  return errors;
+};

+ 10 - 15
dashboard/src/components/Layout/Header.tsx

@@ -1,27 +1,22 @@
-import React from "react";
-import Img from "next/image";
-import Link from "next/link";
-import { Button, Flex, useBreakpointValue } from "@chakra-ui/react";
+import React from 'react';
+import Link from 'next/link';
+import { Flex } from '@chakra-ui/react';
+import { FiMenu } from 'react-icons/fi';
 
 
 interface IProps {
 interface IProps {
   onClickMenu: () => void;
   onClickMenu: () => void;
 }
 }
 
 
 const Header: React.FC<IProps> = ({ onClickMenu }) => {
 const Header: React.FC<IProps> = ({ onClickMenu }) => {
-  const buttonVisibility = useBreakpointValue<"visible" | "hidden">({
-    base: "visible",
-    md: "hidden",
-  });
-
   return (
   return (
-    <header>
-      <Flex alignItems="center" bg="tomato" paddingLeft={5} paddingRight={5}>
-        <Flex position="absolute" visibility={buttonVisibility || "visible"}>
-          <Button onClick={onClickMenu}>O</Button>
-        </Flex>
+    <header style={{ width: '100%' }} className="flex">
+      <Flex className="items-center bg-gray-700 drop-shadow-md px-5 flex-1">
+        <div onClick={onClickMenu} className="visible md:invisible absolute cursor-pointer py-2">
+          <FiMenu color="white" />
+        </div>
         <Flex justifyContent="center" flex="1">
         <Flex justifyContent="center" flex="1">
           <Link href="/" passHref>
           <Link href="/" passHref>
-            <Img src="/logo.svg" alt="Tipi" width={100} height={60} />
+            <img src="/logo.png" alt="Tipi" width={230} height={60} />
           </Link>
           </Link>
         </Flex>
         </Flex>
       </Flex>
       </Flex>

+ 46 - 16
dashboard/src/components/Layout/Layout.tsx

@@ -1,30 +1,60 @@
-import {
-  Button,
-  Flex,
-  useBreakpointValue,
-  useDisclosure,
-} from "@chakra-ui/react";
-import React from "react";
-import Header from "./Header";
-import Menu from "./Menu";
-import MenuDrawer from "./MenuDrawer";
+import { Flex, useDisclosure, Spinner, Breadcrumb, BreadcrumbItem } from '@chakra-ui/react';
+import Link from 'next/link';
+import React from 'react';
+import { FiChevronRight } from 'react-icons/fi';
+import Header from './Header';
+import Menu from './Menu';
+import MenuDrawer from './MenuDrawer';
 
 
-const Layout: React.FC = ({ children }) => {
-  const menuWidth = useBreakpointValue({ base: 0, md: 200 });
+interface IProps {
+  loading?: boolean;
+  breadcrumbs?: { name: string; href: string; current?: boolean }[];
+}
+
+const Layout: React.FC<IProps> = ({ children, loading, breadcrumbs }) => {
   const { isOpen, onOpen, onClose } = useDisclosure();
   const { isOpen, onOpen, onClose } = useDisclosure();
 
 
+  const renderContent = () => {
+    if (loading) {
+      return (
+        <Flex className="justify-center flex-1">
+          <Spinner />
+        </Flex>
+      );
+    }
+
+    return children;
+  };
+
+  const renderBreadcrumbs = () => {
+    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>
+    );
+  };
+
   return (
   return (
-    <Flex height="100vh" bg="green.500" direction="column">
+    <Flex height="100vh" className="drop-shadow-md border-r-8" direction="column">
       <MenuDrawer isOpen={isOpen} onClose={onClose}>
       <MenuDrawer isOpen={isOpen} onClose={onClose}>
         <Menu />
         <Menu />
       </MenuDrawer>
       </MenuDrawer>
       <Header onClickMenu={onOpen} />
       <Header onClickMenu={onOpen} />
       <Flex flex="1">
       <Flex flex="1">
-        <Flex width={menuWidth} bg="blue.500">
+        <Flex className="invisible md:visible w-0 md:w-56">
           <Menu />
           <Menu />
         </Flex>
         </Flex>
-        <Flex flex="1" padding={5} bg="yellow.300">
-          {children}
+        <Flex className="bg-slate-200 flex flex-1 p-5">
+          <div className="flex-1 flex flex-col">
+            {renderBreadcrumbs()}
+            <div className="flex-1 ">{renderContent()}</div>
+          </div>
         </Flex>
         </Flex>
       </Flex>
       </Flex>
     </Flex>
     </Flex>

+ 35 - 6
dashboard/src/components/Layout/Menu.tsx

@@ -1,11 +1,40 @@
-import React from "react";
+import { AiOutlineDashboard, AiOutlineSetting, AiOutlineAppstore } from 'react-icons/ai';
+import { Divider, List, ListItem } 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';
+
+const SideMenu: React.FC = () => {
+  const router = useRouter();
+
+  const path = router.pathname.split('/')[1];
+
+  const renderMenuItem = (title: string, name: string, Icon: IconType) => {
+    const selected = path === name;
+
+    return (
+      <Link href={`/${name}`} passHref>
+        <div className={clsx('mx-3  rounded-lg p-3 transition-colors', { 'bg-slate-200 drop-shadow-sm': selected })}>
+          <ListItem className={'flex items-center cursor-pointer hover:font-bold'}>
+            <Icon size={20} className="mr-3" />
+            <p className={clsx({ 'font-bold': selected })}>{title}</p>
+          </ListItem>
+        </div>
+      </Link>
+    );
+  };
 
 
-const Menu: React.FC = () => {
   return (
   return (
-    <div>
-      <h1>Menu</h1>
-    </div>
+    <List spacing={3} className="pt-5 flex-1 bg-white md:border-r-2">
+      {renderMenuItem('Dashboard', '', AiOutlineDashboard)}
+      <Divider />
+      {renderMenuItem('Apps', 'apps', AiOutlineAppstore)}
+      <Divider />
+      {renderMenuItem('Settings', 'settings', AiOutlineSetting)}
+    </List>
   );
   );
 };
 };
 
 
-export default Menu;
+export default SideMenu;

+ 4 - 12
dashboard/src/components/Layout/MenuDrawer.tsx

@@ -1,13 +1,5 @@
-import {
-  Drawer,
-  DrawerBody,
-  DrawerCloseButton,
-  DrawerContent,
-  DrawerFooter,
-  DrawerHeader,
-  DrawerOverlay,
-} from "@chakra-ui/react";
-import React from "react";
+import { Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerFooter, DrawerHeader, DrawerOverlay } from '@chakra-ui/react';
+import React from 'react';
 
 
 interface IProps {
 interface IProps {
   isOpen: boolean;
   isOpen: boolean;
@@ -16,11 +8,11 @@ interface IProps {
 
 
 const MenuDrawer: React.FC<IProps> = ({ children, isOpen, onClose }) => {
 const MenuDrawer: React.FC<IProps> = ({ children, isOpen, onClose }) => {
   return (
   return (
-    <Drawer isOpen={isOpen} placement="left" onClose={onClose}>
+    <Drawer size="xs" isOpen={isOpen} placement="left" onClose={onClose}>
       <DrawerOverlay />
       <DrawerOverlay />
       <DrawerContent>
       <DrawerContent>
         <DrawerCloseButton />
         <DrawerCloseButton />
-        <DrawerHeader>Create your account</DrawerHeader>
+        <DrawerHeader>My Tipi</DrawerHeader>
         <DrawerBody>{children}</DrawerBody>
         <DrawerBody>{children}</DrawerBody>
         <DrawerFooter>
         <DrawerFooter>
           <div>Github</div>
           <div>Github</div>

+ 1 - 1
dashboard/src/components/Layout/index.ts

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

+ 43 - 45
dashboard/src/constants/apps.ts

@@ -1,4 +1,4 @@
-import validator from "validator";
+import validator from 'validator';
 
 
 interface IFormField {
 interface IFormField {
   name: string;
   name: string;
@@ -20,81 +20,79 @@ interface IAppConfig {
 }
 }
 
 
 const APP_ANONADDY: IAppConfig = {
 const APP_ANONADDY: IAppConfig = {
-  id: "anonaddy",
-  name: "Anonaddy",
-  description: "Create Unlimited Email Aliases For Free",
-  url: "https://anonaddy.com/",
-  color: "#00a8ff",
-  logo: "https://anonaddy.com/favicon.ico",
+  id: 'anonaddy',
+  name: 'Anonaddy',
+  description: 'Create Unlimited Email Aliases For Free',
+  url: 'https://anonaddy.com/',
+  color: '#00a8ff',
+  logo: 'https://anonaddy.com/favicon.ico',
   install_form: {
   install_form: {
     fields: [
     fields: [
       {
       {
-        name: "API Key",
-        type: "text",
-        placeholder: "API Key",
+        name: 'API Key',
+        type: 'text',
+        placeholder: 'API Key',
         required: true,
         required: true,
         validate: (value: string) => validator.isBase64(value),
         validate: (value: string) => validator.isBase64(value),
       },
       },
       {
       {
-        name: "Return Path",
-        type: "text",
-        description: "The email address that bounces will be sent to",
-        placeholder: "Return Path",
+        name: 'Return Path',
+        type: 'text',
+        description: 'The email address that bounces will be sent to',
+        placeholder: 'Return Path',
         required: false,
         required: false,
         validate: (value: string) => validator.isEmail(value),
         validate: (value: string) => validator.isEmail(value),
       },
       },
       {
       {
-        name: "Admin Username",
-        type: "text",
-        description: "The username of the admin user",
-        placeholder: "Admin Username",
+        name: 'Admin Username',
+        type: 'text',
+        description: 'The username of the admin user',
+        placeholder: 'Admin Username',
         required: true,
         required: true,
       },
       },
       {
       {
-        name: "Enable Registration",
-        type: "boolean",
-        description: "Allow users to register",
-        placeholder: "Enable Registration",
+        name: 'Enable Registration',
+        type: 'boolean',
+        description: 'Allow users to register',
+        placeholder: 'Enable Registration',
         required: false,
         required: false,
       },
       },
       {
       {
-        name: "Domain",
-        type: "text",
-        description: "The domain that will be used for the email address",
-        placeholder: "Domain",
+        name: 'Domain',
+        type: 'text',
+        description: 'The domain that will be used for the email address',
+        placeholder: 'Domain',
         required: true,
         required: true,
         validate: (value: string) => validator.isFQDN(value),
         validate: (value: string) => validator.isFQDN(value),
       },
       },
       {
       {
-        name: "Hostname",
-        type: "text",
-        description: "The hostname that will be used for the email address",
-        placeholder: "Hostname",
+        name: 'Hostname',
+        type: 'text',
+        description: 'The hostname that will be used for the email address',
+        placeholder: 'Hostname',
         required: true,
         required: true,
         validate: (value: string) => validator.isFQDN(value),
         validate: (value: string) => validator.isFQDN(value),
       },
       },
       {
       {
-        name: "Secret",
-        type: "text",
-        description: "The secret that will be used for the email address",
-        placeholder: "Secret",
+        name: 'Secret',
+        type: 'text',
+        description: 'The secret that will be used for the email address',
+        placeholder: 'Secret',
         required: true,
         required: true,
       },
       },
       {
       {
-        name: "From Name",
-        type: "text",
-        description: "The name that will be used for the email address",
-        placeholder: "From Name",
+        name: 'From Name',
+        type: 'text',
+        description: 'The name that will be used for the email address',
+        placeholder: 'From Name',
         required: true,
         required: true,
-        validate: (value: string) =>
-          validator.isLength(value, { min: 1, max: 64 }),
+        validate: (value: string) => validator.isLength(value, { min: 1, max: 64 }),
       },
       },
       {
       {
-        name: "From Address",
-        type: "text",
-        description:
-          "The email address that will be used for the email address",
-        placeholder: "From Address",
+        name: 'From Address',
+        type: 'text',
+        description: 'The email address that will be used for the email address',
+        placeholder: 'From Address',
         required: true,
         required: true,
         validate: (value: string) => validator.isEmail(value),
         validate: (value: string) => validator.isEmail(value),
       },
       },

+ 33 - 0
dashboard/src/core/api.ts

@@ -0,0 +1,33 @@
+import axios, { Method } from 'axios';
+
+export const BASE_URL = 'http://192.168.2.132:3001';
+
+console.log(process.env);
+
+interface IFetchParams {
+  endpoint: string;
+  method?: Method;
+  params?: JSON;
+  data?: Record<string, unknown>;
+}
+
+const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
+  const { endpoint, method = 'GET', params, data } = fetchParams;
+
+  const response = await axios.request<T & { error?: string }>({
+    method,
+    params,
+    data,
+    url: `${BASE_URL}${endpoint}`,
+  });
+
+  if (response.data.error) {
+    throw new Error(response.data.error);
+  }
+
+  if (response.data) return response.data;
+
+  throw new Error(`Network request error. status : ${response.status}`);
+};
+
+export default { fetch: api };

+ 9 - 0
dashboard/src/core/fetcher.ts

@@ -0,0 +1,9 @@
+import { BareFetcher } from 'swr';
+import axios from 'axios';
+import { BASE_URL } from './api';
+
+const fetcher: BareFetcher<any> = (url: string) => {
+  return axios.get(url, { baseURL: BASE_URL }).then((res) => res.data);
+};
+
+export default fetcher;

+ 51 - 0
dashboard/src/core/types.ts

@@ -0,0 +1,51 @@
+export enum FieldTypes {
+  text = 'text',
+  password = 'password',
+  email = 'email',
+  number = 'number',
+  fqdn = 'fqdn',
+  ip = 'ip',
+  fqdnip = 'fqdnip',
+}
+
+interface FormField {
+  type: FieldTypes;
+  label: string;
+  max?: number;
+  min?: number;
+  required?: boolean;
+  env_variable: string;
+}
+
+export interface AppConfig {
+  id: string;
+  port: number;
+  requirements?: {
+    ports?: number[];
+  };
+  name: string;
+  description: string;
+  version: string;
+  image: string;
+  form_fields: Record<string, FormField>;
+  short_desc: string;
+  author: string;
+  source: string;
+  installed: boolean;
+  status: AppStatus;
+}
+
+export enum RequestStatus {
+  SUCCESS = 'SUCCESS',
+  ERROR = 'ERROR',
+  LOADING = 'LOADING',
+}
+
+export enum AppStatus {
+  RUNNING = 'running',
+  STOPPED = 'stopped',
+  INSTALLING = 'installing',
+  UNINSTALLING = 'uninstalling',
+  STOPPING = 'stopping',
+  STARTING = 'starting',
+}

+ 70 - 0
dashboard/src/modules/Apps/components/AppActions.tsx

@@ -0,0 +1,70 @@
+import { Button } from '@chakra-ui/react';
+import React from 'react';
+import { FiExternalLink, FiPause, FiPlay, FiSettings, FiTrash2 } from 'react-icons/fi';
+import { AppConfig, AppStatus } from '../../../core/types';
+
+interface IProps {
+  app: AppConfig;
+  onInstall: () => void;
+  onUninstall: () => void;
+  onStart: () => void;
+  onStop: () => void;
+  onOpen: () => void;
+  onUpdate: () => void;
+}
+
+const AppActions: React.FC<IProps> = ({ app, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate }) => {
+  const hasSettings = Object.keys(app.form_fields).length > 0;
+
+  if (app?.installed && app.status === AppStatus.STOPPED) {
+    return (
+      <div className="flex flex-wrap justify-center">
+        <Button onClick={onStart} width={160} colorScheme="green" className="mt-3 mr-2">
+          Start
+          <FiPlay className="ml-1" />
+        </Button>
+        <Button onClick={onUninstall} width={160} colorScheme="gray" className="mt-3 mr-2">
+          Remove
+          <FiTrash2 className="ml-1" />
+        </Button>
+        {hasSettings && (
+          <Button onClick={onUpdate} width={160} className="mt-3 mr-2">
+            Settings
+            <FiSettings className="ml-1" />
+          </Button>
+        )}
+      </div>
+    );
+  } else if (app?.installed && app.status === AppStatus.RUNNING) {
+    return (
+      <div>
+        <Button onClick={onOpen} width={160} colorScheme="gray" className="mt-3 mr-2">
+          Open
+          <FiExternalLink className="ml-1" />
+        </Button>
+        <Button onClick={onStop} width={160} colorScheme="red" className="mt-3">
+          Stop
+          <FiPause className="ml-2" />
+        </Button>
+      </div>
+    );
+  } else if (app.status === AppStatus.INSTALLING || app.status === AppStatus.UNINSTALLING || app.status === AppStatus.STARTING || app.status === AppStatus.STOPPING) {
+    return (
+      <div className="flex items-center flex-col md:flex-row">
+        <Button isLoading onClick={() => null} width={160} colorScheme="green" className="mt-3">
+          Install
+          <FiPlay className="ml-1" />
+        </Button>
+        <span className="text-gray-500 text-sm ml-2 mt-3">{`App is ${app.status} please wait and don't refresh page...`}</span>
+      </div>
+    );
+  }
+
+  return (
+    <Button onClick={onInstall} width={160} colorScheme="green" className="mt-3">
+      Install
+    </Button>
+  );
+};
+
+export default AppActions;

+ 46 - 0
dashboard/src/modules/Apps/components/InstallForm.tsx

@@ -0,0 +1,46 @@
+import { Button } from '@chakra-ui/react';
+import React from 'react';
+import { Form, Field } from 'react-final-form';
+import FormInput from '../../../components/Form/FormInput';
+import { validateAppConfig } from '../../../components/Form/validators';
+import { AppConfig } from '../../../core/types';
+import { objectKeys } from '../../../utils/typescript';
+
+interface IProps {
+  formFields: AppConfig['form_fields'];
+  onSubmit: (values: Record<string, unknown>) => void;
+  initalValues?: Record<string, string>;
+}
+
+const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues }) => {
+  const fields = objectKeys(formFields).map((key) => ({ ...formFields[key], id: key }));
+
+  const renderField = (field: typeof fields[0]) => {
+    return (
+      <Field
+        key={field.id}
+        name={field.id}
+        render={({ input, meta }) => <FormInput className="mb-3" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} label={field.label} {...input} />}
+      />
+    );
+  };
+
+  return (
+    <Form<Record<string, string>>
+      initialValues={initalValues}
+      onSubmit={onSubmit}
+      validateOnBlur={true}
+      validate={(values) => validateAppConfig(values, fields)}
+      render={({ handleSubmit, validating, submitting }) => (
+        <form className="flex flex-col" onSubmit={handleSubmit}>
+          {fields.map(renderField)}
+          <Button isLoading={validating || submitting} className="self-end mb-2" colorScheme="green" type="submit">
+            {initalValues ? 'Update' : 'Install'}
+          </Button>
+        </form>
+      )}
+    />
+  );
+};
+
+export default InstallForm;

+ 28 - 0
dashboard/src/modules/Apps/components/InstallModal.tsx

@@ -0,0 +1,28 @@
+import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
+import React from 'react';
+import { AppConfig } from '../../../core/types';
+import InstallForm from './InstallForm';
+
+interface IProps {
+  app: AppConfig;
+  isOpen: boolean;
+  onClose: () => void;
+  onSubmit: (values: Record<string, any>) => void;
+}
+
+const InstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onSubmit }) => {
+  return (
+    <Modal isOpen={isOpen} onClose={onClose}>
+      <ModalOverlay />
+      <ModalContent>
+        <ModalHeader>Install {app.name}</ModalHeader>
+        <ModalCloseButton />
+        <ModalBody>
+          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} />
+        </ModalBody>
+      </ModalContent>
+    </Modal>
+  );
+};
+
+export default InstallModal;

+ 30 - 0
dashboard/src/modules/Apps/components/StopModal.tsx

@@ -0,0 +1,30 @@
+import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from '@chakra-ui/react';
+import React from 'react';
+import { AppConfig } from '../../../core/types';
+
+interface IProps {
+  app: AppConfig;
+  isOpen: boolean;
+  onClose: () => void;
+  onConfirm: () => void;
+}
+
+const StopModal: React.FC<IProps> = ({ app, isOpen, onClose, onConfirm }) => {
+  return (
+    <Modal isOpen={isOpen} onClose={onClose}>
+      <ModalOverlay />
+      <ModalContent>
+        <ModalHeader>Stop {app.name} ?</ModalHeader>
+        <ModalCloseButton />
+        <ModalBody>All the data will be retained.</ModalBody>
+        <ModalFooter>
+          <Button onClick={onConfirm} colorScheme="red">
+            Stop
+          </Button>
+        </ModalFooter>
+      </ModalContent>
+    </Modal>
+  );
+};
+
+export default StopModal;

+ 30 - 0
dashboard/src/modules/Apps/components/UninstallModal.tsx

@@ -0,0 +1,30 @@
+import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from '@chakra-ui/react';
+import React from 'react';
+import { AppConfig } from '../../../core/types';
+
+interface IProps {
+  app: AppConfig;
+  isOpen: boolean;
+  onClose: () => void;
+  onConfirm: () => void;
+}
+
+const UninstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onConfirm }) => {
+  return (
+    <Modal isOpen={isOpen} onClose={onClose}>
+      <ModalOverlay />
+      <ModalContent>
+        <ModalHeader>Uninstall {app.name} ?</ModalHeader>
+        <ModalCloseButton />
+        <ModalBody>All data for this app will be lost.</ModalBody>
+        <ModalFooter>
+          <Button onClick={onConfirm} colorScheme="red">
+            Uninstall
+          </Button>
+        </ModalFooter>
+      </ModalContent>
+    </Modal>
+  );
+};
+
+export default UninstallModal;

+ 36 - 0
dashboard/src/modules/Apps/components/UpdateModal.tsx

@@ -0,0 +1,36 @@
+import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
+import React, { useEffect } from 'react';
+import useSWR from 'swr';
+import fetcher from '../../../core/fetcher';
+import { AppConfig } from '../../../core/types';
+import InstallForm from './InstallForm';
+
+interface IProps {
+  app: AppConfig;
+  isOpen: boolean;
+  onClose: () => void;
+  onSubmit: (values: Record<string, any>) => void;
+}
+
+const UpdateModal: React.FC<IProps> = ({ app, isOpen, onClose, onSubmit }) => {
+  const { data, mutate } = useSWR<Record<string, string>>(`/apps/form/${app.id}`, fetcher, { refreshInterval: 10 });
+
+  useEffect(() => {
+    mutate({}, true);
+  }, [isOpen, mutate]);
+
+  return (
+    <Modal isOpen={isOpen} onClose={onClose}>
+      <ModalOverlay />
+      <ModalContent>
+        <ModalHeader>Update {app.name} config</ModalHeader>
+        <ModalCloseButton />
+        <ModalBody>
+          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} initalValues={data} />
+        </ModalBody>
+      </ModalContent>
+    </Modal>
+  );
+};
+
+export default UpdateModal;

+ 136 - 0
dashboard/src/modules/Apps/containers/AppDetails.tsx

@@ -0,0 +1,136 @@
+import { SlideFade, Image, VStack, Flex, Divider, useDisclosure, useToast } from '@chakra-ui/react';
+import React from 'react';
+import { FiExternalLink } from 'react-icons/fi';
+import { AppConfig } from '../../../core/types';
+import { useAppsStore } from '../../../state/appsStore';
+import { useNetworkStore } from '../../../state/networkStore';
+import AppActions from '../components/AppActions';
+import InstallModal from '../components/InstallModal';
+import StopModal from '../components/StopModal';
+import UninstallModal from '../components/UninstallModal';
+import UpdateModal from '../components/UpdateModal';
+
+interface IProps {
+  app: AppConfig;
+}
+
+const AppDetails: React.FC<IProps> = ({ app }) => {
+  const toast = useToast();
+  const installDisclosure = useDisclosure();
+  const uninstallDisclosure = useDisclosure();
+  const stopDisclosure = useDisclosure();
+  const updateDisclosure = useDisclosure();
+
+  const { internalIp } = useNetworkStore();
+  const { install, update, uninstall, stop, start, fetchApp } = useAppsStore();
+
+  const handleError = (error: unknown) => {
+    if (error instanceof Error) {
+      toast({
+        title: 'Error',
+        description: error.message,
+        status: 'error',
+        position: 'top',
+        isClosable: true,
+      });
+      fetchApp(app.id);
+    }
+  };
+
+  const handleInstallSubmit = async (values: Record<string, any>) => {
+    installDisclosure.onClose();
+    try {
+      await install(app.id, values);
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  const handleUnistallSubmit = async () => {
+    uninstallDisclosure.onClose();
+    try {
+      await uninstall(app.id);
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  const handleStopSubmit = async () => {
+    stopDisclosure.onClose();
+    try {
+      await stop(app.id);
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  const handleStartSubmit = async () => {
+    try {
+      await start(app.id);
+    } catch (e: unknown) {
+      handleError(e);
+    }
+  };
+
+  const handleUpdateSubmit = async (values: Record<string, any>) => {
+    try {
+      await update(app.id, values);
+      toast({
+        title: 'Success',
+        description: 'App config updated successfully',
+        position: 'top',
+        status: 'success',
+      });
+      updateDisclosure.onClose();
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  const handleOpen = () => {
+    window.open(`http://${internalIp}:${app.port}`, '_blank');
+  };
+
+  return (
+    <SlideFade in className="flex flex-1" offsetY="20px">
+      <div className="flex flex-1 bg-white p-4 mt-3 rounded-lg drop-shadow-xl flex-col">
+        <Flex className="flex-col md:flex-row">
+          <Image src={app?.image} height={180} width={180} className="rounded-xl self-center sm:self-auto" alt={app.name} />
+          <VStack align="flex-start" justify="space-between" className="ml-0 md:ml-4">
+            <div className="mt-3 items-center self-center flex flex-col sm:items-start sm:self-start md:mt-0">
+              <h1 className="font-bold text-2xl">{app?.name}</h1>
+              <h2 className="text-center md:text-left">{app?.short_desc}</h2>
+              {app.source && (
+                <a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={app?.source}>
+                  <Flex className="mt-2 items-center">
+                    <FiExternalLink className="ml-1" />
+                  </Flex>
+                </a>
+              )}
+              <p className="text-xs text-gray-600">By {app?.author}</p>
+            </div>
+            <div className="flex justify-center sm:absolute md:static top-0 right-5 self-center sm:self-auto">
+              <AppActions
+                onUpdate={updateDisclosure.onOpen}
+                onOpen={handleOpen}
+                onStart={handleStartSubmit}
+                onStop={stopDisclosure.onOpen}
+                onUninstall={uninstallDisclosure.onOpen}
+                onInstall={installDisclosure.onOpen}
+                app={app}
+              />
+            </div>
+          </VStack>
+        </Flex>
+        <Divider className="mt-5" />
+        <p className="mt-3">{app?.description}</p>
+        <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={app} />
+        <UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.onClose} app={app} />
+        <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={app} />
+        <UpdateModal onSubmit={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={app} />
+      </div>
+    </SlideFade>
+  );
+};
+
+export default AppDetails;

+ 11 - 3
dashboard/src/pages/_app.tsx

@@ -1,8 +1,16 @@
-import { ChakraProvider } from "@chakra-ui/react";
-import "../styles/globals.css";
-import type { AppProps } from "next/app";
+import { ChakraProvider } from '@chakra-ui/react';
+import '../styles/globals.css';
+import type { AppProps } from 'next/app';
+import { useEffect } from 'react';
+import { useNetworkStore } from '../state/networkStore';
 
 
 function MyApp({ Component, pageProps }: AppProps) {
 function MyApp({ Component, pageProps }: AppProps) {
+  const { fetchInternalIp } = useNetworkStore();
+
+  useEffect(() => {
+    fetchInternalIp();
+  }, [fetchInternalIp]);
+
   return (
   return (
     <ChakraProvider>
     <ChakraProvider>
       <Component {...pageProps} />
       <Component {...pageProps} />

+ 2 - 2
dashboard/src/pages/api/device/cpu.ts

@@ -1,6 +1,6 @@
 // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
 // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
-import type { NextApiRequest, NextApiResponse } from "next";
-import si from "systeminformation";
+import type { NextApiRequest, NextApiResponse } from 'next';
+import si from 'systeminformation';
 
 
 type Data = Awaited<ReturnType<typeof si.currentLoad>>;
 type Data = Awaited<ReturnType<typeof si.currentLoad>>;
 
 

+ 2 - 2
dashboard/src/pages/api/device/disk.ts

@@ -1,6 +1,6 @@
 // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
 // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
-import type { NextApiRequest, NextApiResponse } from "next";
-import si from "systeminformation";
+import type { NextApiRequest, NextApiResponse } from 'next';
+import si from 'systeminformation';
 
 
 type Data = Awaited<ReturnType<typeof si.fsSize>>;
 type Data = Awaited<ReturnType<typeof si.fsSize>>;
 
 

+ 2 - 2
dashboard/src/pages/api/device/memory.ts

@@ -1,6 +1,6 @@
 // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
 // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
-import type { NextApiRequest, NextApiResponse } from "next";
-import si from "systeminformation";
+import type { NextApiRequest, NextApiResponse } from 'next';
+import si from 'systeminformation';
 
 
 type Data = Awaited<ReturnType<typeof si.mem>>;
 type Data = Awaited<ReturnType<typeof si.mem>>;
 
 

+ 39 - 0
dashboard/src/pages/apps/[id].tsx

@@ -0,0 +1,39 @@
+import type { NextPage } from 'next';
+import { useEffect } from 'react';
+import Layout from '../../components/Layout';
+import { useAppsStore } from '../../state/appsStore';
+import AppDetails from '../../modules/Apps/containers/AppDetails';
+
+interface IProps {
+  appId: string;
+}
+
+const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
+  const { fetchApp, getApp } = useAppsStore((state) => state);
+  const app = getApp(appId);
+
+  useEffect(() => {
+    fetchApp(appId);
+  }, [appId, fetchApp]);
+
+  const breadcrumb = [
+    { name: 'Apps', href: '/apps' },
+    { name: app?.name || '', href: `/apps/${appId}`, current: true },
+  ];
+
+  return (
+    <Layout breadcrumbs={breadcrumb} loading={!app}>
+      {app && <AppDetails app={app} />}
+    </Layout>
+  );
+};
+
+AppDetailsPage.getInitialProps = async (ctx) => {
+  const { query } = ctx;
+
+  const appId = query.id as string;
+
+  return { appId };
+};
+
+export default AppDetailsPage;

+ 39 - 0
dashboard/src/pages/apps/index.tsx

@@ -0,0 +1,39 @@
+import React, { useEffect } from 'react';
+import { Flex, SimpleGrid } from '@chakra-ui/react';
+import type { NextPage } from 'next';
+import Layout from '../../components/Layout';
+import { RequestStatus } from '../../core/types';
+import { useAppsStore } from '../../state/appsStore';
+import AppTile from '../../components/AppTile';
+
+const Apps: NextPage = () => {
+  const { available, installed, fetch, status } = useAppsStore((state) => state);
+
+  useEffect(() => {
+    fetch();
+  }, [fetch]);
+
+  const installedCount: number = installed().length || 0;
+  const loading = status === RequestStatus.LOADING && !installed && !available;
+
+  return (
+    <Layout loading={loading}>
+      <Flex className="flex-col">
+        {installedCount > 0 && <h1 className="font-bold text-2xl mb-3">Your Apps ({installedCount})</h1>}
+        <SimpleGrid minChildWidth="400px" spacing="20px">
+          {installed().map((app) => (
+            <AppTile key={app.name} app={app} />
+          ))}
+        </SimpleGrid>
+        {available().length && <h1 className="font-bold text-2xl mb-3 mt-3">Available Apps</h1>}
+        <SimpleGrid minChildWidth="400px" spacing="20px">
+          {available().map((app) => (
+            <AppTile key={app.name} app={app} />
+          ))}
+        </SimpleGrid>
+      </Flex>
+    </Layout>
+  );
+};
+
+export default Apps;

+ 2 - 2
dashboard/src/pages/index.tsx

@@ -1,5 +1,5 @@
-import type { NextPage } from "next";
-import Layout from "../components/Layout";
+import type { NextPage } from 'next';
+import Layout from '../components/Layout';
 
 
 const Home: NextPage = () => {
 const Home: NextPage = () => {
   return (
   return (

+ 12 - 0
dashboard/src/pages/settings.tsx

@@ -0,0 +1,12 @@
+import type { NextPage } from 'next';
+import Layout from '../components/Layout';
+
+const Settings: NextPage = () => {
+  return (
+    <Layout>
+      <div>Settings</div>
+    </Layout>
+  );
+};
+
+export default Settings;

+ 127 - 0
dashboard/src/state/appsStore.ts

@@ -0,0 +1,127 @@
+import produce from 'immer';
+import create, { GetState, SetState } from 'zustand';
+import api from '../core/api';
+import { AppConfig, AppStatus, RequestStatus } from '../core/types';
+
+type AppsStore = {
+  apps: AppConfig[];
+  status: RequestStatus;
+  installed: () => AppConfig[];
+  available: () => AppConfig[];
+  fetch: () => void;
+  getApp: (id: string) => AppConfig | undefined;
+  fetchApp: (id: string) => void;
+  install: (id: string, form: Record<string, string>) => Promise<void>;
+  update: (id: string, form: Record<string, string>) => Promise<void>;
+  uninstall: (id: string) => Promise<void>;
+  stop: (id: string) => Promise<void>;
+  start: (id: string) => Promise<void>;
+};
+
+type Set = SetState<AppsStore>;
+type Get = GetState<AppsStore>;
+
+const sortApps = (apps: AppConfig[]) => apps.sort((a, b) => a.name.localeCompare(b.name));
+
+const setAppStatus = (appId: string, status: AppStatus, set: Set) => {
+  set((state) => {
+    return produce(state, (draft) => {
+      const app = draft.apps.find((a) => a.id === appId);
+      if (app) app.status = status;
+    });
+  });
+};
+
+const installed = (get: Get) => {
+  const i = get().apps.filter((app) => app.installed);
+  return i;
+};
+
+/**
+ * Fetch one app and add it to the list of apps.
+ * @param appId
+ * @param set
+ */
+const fetchApp = async (appId: string, set: Set) => {
+  const response = await api.fetch<AppConfig>({
+    endpoint: `/apps/info/${appId}`,
+    method: 'get',
+  });
+
+  set((state) => {
+    const apps = state.apps.filter((app) => app.id !== appId);
+    apps.push(response);
+
+    return { ...state, apps: sortApps(apps) };
+  });
+};
+
+export const useAppsStore = create<AppsStore>((set, get) => ({
+  apps: [],
+  status: RequestStatus.LOADING,
+  installed: () => installed(get),
+  available: () => {
+    return get().apps.filter((app) => !app.installed);
+  },
+  fetchApp: async (appId: string) => fetchApp(appId, set),
+  fetch: async () => {
+    set({ status: RequestStatus.LOADING });
+
+    const response = await api.fetch<AppConfig[]>({
+      endpoint: '/apps/list',
+      method: 'get',
+    });
+
+    set({ apps: sortApps(response), status: RequestStatus.SUCCESS });
+  },
+  getApp: (appId: string) => {
+    return get().apps.find((app) => app.id === appId);
+  },
+  install: async (appId: string, form?: Record<string, string>) => {
+    setAppStatus(appId, AppStatus.INSTALLING, set);
+
+    await api.fetch({
+      endpoint: `/apps/install/${appId}`,
+      method: 'POST',
+      data: { form },
+    });
+
+    await get().fetchApp(appId);
+  },
+  update: async (appId: string, form?: Record<string, string>) => {
+    await api.fetch({
+      endpoint: `/apps/update/${appId}`,
+      method: 'POST',
+      data: { form },
+    });
+
+    await get().fetchApp(appId);
+  },
+  uninstall: async (appId: string) => {
+    setAppStatus(appId, AppStatus.UNINSTALLING, set);
+
+    await api.fetch({
+      endpoint: `/apps/uninstall/${appId}`,
+    });
+
+    await get().fetchApp(appId);
+  },
+  stop: async (appId: string) => {
+    setAppStatus(appId, AppStatus.STOPPING, set);
+
+    await api.fetch({
+      endpoint: `/apps/stop/${appId}`,
+    });
+
+    await get().fetchApp(appId);
+  },
+  start: async (appId: string) => {
+    setAppStatus(appId, AppStatus.STARTING, set);
+
+    await api.fetch({
+      endpoint: `/apps/start/${appId}`,
+    });
+
+    await get().fetchApp(appId);
+  },
+}));

+ 19 - 0
dashboard/src/state/networkStore.ts

@@ -0,0 +1,19 @@
+import create from 'zustand';
+import api from '../core/api';
+
+type AppsStore = {
+  internalIp: string;
+  fetchInternalIp: () => void;
+};
+
+export const useNetworkStore = create<AppsStore>((set) => ({
+  internalIp: '',
+  fetchInternalIp: async () => {
+    const response = await api.fetch<string>({
+      endpoint: '/network/internal-ip',
+      method: 'get',
+    });
+
+    set({ internalIp: response });
+  },
+}));

+ 12 - 0
dashboard/src/state/uiStore.ts

@@ -0,0 +1,12 @@
+import create from 'zustand';
+
+type UIStore = {
+  menuItem: string;
+};
+
+export const useUIStore = create<UIStore>((set) => ({
+  menuItem: 'dashboard',
+  setMenuItem: (menuItem: string) => {
+    set({ menuItem: menuItem });
+  },
+}));

+ 5 - 0
dashboard/src/styles/globals.css

@@ -1,6 +1,11 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
 html,
 html,
 body {
 body {
   padding: 0;
   padding: 0;
+  overflow-x: hidden;
   margin: 0;
   margin: 0;
   font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
   font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
     Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
     Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;

+ 3 - 0
dashboard/src/utils/typescript.ts

@@ -0,0 +1,3 @@
+const objectKeys = <T>(obj: T): (keyof T)[] => Object.keys(obj) as (keyof T)[];
+
+export { objectKeys };

+ 7 - 0
dashboard/tailwind.config.js

@@ -0,0 +1,7 @@
+module.exports = {
+  content: ['./src/pages/**/*.{ts,tsx}', './src/components/**/*.{ts,tsx}', './src/modules/**/*.{ts,tsx}'],
+  theme: {
+    extend: {},
+  },
+  plugins: [],
+};

+ 452 - 14
dashboard/yarn.lock

@@ -57,6 +57,13 @@
   dependencies:
   dependencies:
     regenerator-runtime "^0.13.4"
     regenerator-runtime "^0.13.4"
 
 
+"@babel/runtime@^7.10.0", "@babel/runtime@^7.15.4":
+  version "7.17.9"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72"
+  integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==
+  dependencies:
+    regenerator-runtime "^0.13.4"
+
 "@babel/types@^7.16.7":
 "@babel/types@^7.16.7":
   version "7.17.0"
   version "7.17.0"
   resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.17.0.tgz#a826e368bccb6b3d84acd76acad5c0d87342390b"
   resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.17.0.tgz#a826e368bccb6b3d84acd76acad5c0d87342390b"
@@ -892,6 +899,11 @@
   resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.0.8.tgz#be3e914e84eacf16dbebd311c0d0b44aa1174c64"
   resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.0.8.tgz#be3e914e84eacf16dbebd311c0d0b44aa1174c64"
   integrity sha512-ZK5v4bJwgXldAUA8r3q9YKfCwOqoHTK/ZqRjSeRXQrBXWouoPnS4MQtgC4AXGiiBuUu5wxrRgTlv0ktmM4P1Aw==
   integrity sha512-ZK5v4bJwgXldAUA8r3q9YKfCwOqoHTK/ZqRjSeRXQrBXWouoPnS4MQtgC4AXGiiBuUu5wxrRgTlv0ktmM4P1Aw==
 
 
+"@types/json-schema@^7.0.9":
+  version "7.0.11"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
+  integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
+
 "@types/json5@^0.0.29":
 "@types/json5@^0.0.29":
   version "0.0.29"
   version "0.0.29"
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
@@ -955,6 +967,21 @@
   resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
   resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
   integrity sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=
   integrity sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=
 
 
+"@typescript-eslint/eslint-plugin@^5.18.0":
+  version "5.18.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.18.0.tgz#950df411cec65f90d75d6320a03b2c98f6c3af7d"
+  integrity sha512-tzrmdGMJI/uii9/V6lurMo4/o+dMTKDH82LkNjhJ3adCW22YQydoRs5MwTiqxGF9CSYxPxQ7EYb4jLNlIs+E+A==
+  dependencies:
+    "@typescript-eslint/scope-manager" "5.18.0"
+    "@typescript-eslint/type-utils" "5.18.0"
+    "@typescript-eslint/utils" "5.18.0"
+    debug "^4.3.2"
+    functional-red-black-tree "^1.0.1"
+    ignore "^5.1.8"
+    regexpp "^3.2.0"
+    semver "^7.3.5"
+    tsutils "^3.21.0"
+
 "@typescript-eslint/parser@5.10.1":
 "@typescript-eslint/parser@5.10.1":
   version "5.10.1"
   version "5.10.1"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.10.1.tgz#4ce9633cc33fc70bc13786cb793c1a76fe5ad6bd"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.10.1.tgz#4ce9633cc33fc70bc13786cb793c1a76fe5ad6bd"
@@ -973,11 +1000,33 @@
     "@typescript-eslint/types" "5.10.1"
     "@typescript-eslint/types" "5.10.1"
     "@typescript-eslint/visitor-keys" "5.10.1"
     "@typescript-eslint/visitor-keys" "5.10.1"
 
 
+"@typescript-eslint/scope-manager@5.18.0":
+  version "5.18.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.18.0.tgz#a7d7b49b973ba8cebf2a3710eefd457ef2fb5505"
+  integrity sha512-C0CZML6NyRDj+ZbMqh9FnPscg2PrzSaVQg3IpTmpe0NURMVBXlghGZgMYqBw07YW73i0MCqSDqv2SbywnCS8jQ==
+  dependencies:
+    "@typescript-eslint/types" "5.18.0"
+    "@typescript-eslint/visitor-keys" "5.18.0"
+
+"@typescript-eslint/type-utils@5.18.0":
+  version "5.18.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.18.0.tgz#62dbfc8478abf36ba94a90ddf10be3cc8e471c74"
+  integrity sha512-vcn9/6J5D6jtHxpEJrgK8FhaM8r6J1/ZiNu70ZUJN554Y3D9t3iovi6u7JF8l/e7FcBIxeuTEidZDR70UuCIfA==
+  dependencies:
+    "@typescript-eslint/utils" "5.18.0"
+    debug "^4.3.2"
+    tsutils "^3.21.0"
+
 "@typescript-eslint/types@5.10.1":
 "@typescript-eslint/types@5.10.1":
   version "5.10.1"
   version "5.10.1"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.10.1.tgz#dca9bd4cb8c067fc85304a31f38ec4766ba2d1ea"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.10.1.tgz#dca9bd4cb8c067fc85304a31f38ec4766ba2d1ea"
   integrity sha512-ZvxQ2QMy49bIIBpTqFiOenucqUyjTQ0WNLhBM6X1fh1NNlYAC6Kxsx8bRTY3jdYsYg44a0Z/uEgQkohbR0H87Q==
   integrity sha512-ZvxQ2QMy49bIIBpTqFiOenucqUyjTQ0WNLhBM6X1fh1NNlYAC6Kxsx8bRTY3jdYsYg44a0Z/uEgQkohbR0H87Q==
 
 
+"@typescript-eslint/types@5.18.0":
+  version "5.18.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.18.0.tgz#4f0425d85fdb863071680983853c59a62ce9566e"
+  integrity sha512-bhV1+XjM+9bHMTmXi46p1Led5NP6iqQcsOxgx7fvk6gGiV48c6IynY0apQb7693twJDsXiVzNXTflhplmaiJaw==
+
 "@typescript-eslint/typescript-estree@5.10.1":
 "@typescript-eslint/typescript-estree@5.10.1":
   version "5.10.1"
   version "5.10.1"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.10.1.tgz#b268e67be0553f8790ba3fe87113282977adda15"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.10.1.tgz#b268e67be0553f8790ba3fe87113282977adda15"
@@ -991,6 +1040,31 @@
     semver "^7.3.5"
     semver "^7.3.5"
     tsutils "^3.21.0"
     tsutils "^3.21.0"
 
 
+"@typescript-eslint/typescript-estree@5.18.0":
+  version "5.18.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.18.0.tgz#6498e5ee69a32e82b6e18689e2f72e4060986474"
+  integrity sha512-wa+2VAhOPpZs1bVij9e5gyVu60ReMi/KuOx4LKjGx2Y3XTNUDJgQ+5f77D49pHtqef/klglf+mibuHs9TrPxdQ==
+  dependencies:
+    "@typescript-eslint/types" "5.18.0"
+    "@typescript-eslint/visitor-keys" "5.18.0"
+    debug "^4.3.2"
+    globby "^11.0.4"
+    is-glob "^4.0.3"
+    semver "^7.3.5"
+    tsutils "^3.21.0"
+
+"@typescript-eslint/utils@5.18.0":
+  version "5.18.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.18.0.tgz#27fc84cf95c1a96def0aae31684cb43a37e76855"
+  integrity sha512-+hFGWUMMri7OFY26TsOlGa+zgjEy1ssEipxpLjtl4wSll8zy85x0GrUSju/FHdKfVorZPYJLkF3I4XPtnCTewA==
+  dependencies:
+    "@types/json-schema" "^7.0.9"
+    "@typescript-eslint/scope-manager" "5.18.0"
+    "@typescript-eslint/types" "5.18.0"
+    "@typescript-eslint/typescript-estree" "5.18.0"
+    eslint-scope "^5.1.1"
+    eslint-utils "^3.0.0"
+
 "@typescript-eslint/visitor-keys@5.10.1":
 "@typescript-eslint/visitor-keys@5.10.1":
   version "5.10.1"
   version "5.10.1"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.10.1.tgz#29102de692f59d7d34ecc457ed59ab5fc558010b"
   resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.10.1.tgz#29102de692f59d7d34ecc457ed59ab5fc558010b"
@@ -999,11 +1073,38 @@
     "@typescript-eslint/types" "5.10.1"
     "@typescript-eslint/types" "5.10.1"
     eslint-visitor-keys "^3.0.0"
     eslint-visitor-keys "^3.0.0"
 
 
+"@typescript-eslint/visitor-keys@5.18.0":
+  version "5.18.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.18.0.tgz#c7c07709823804171d569017f3b031ced7253e60"
+  integrity sha512-Hf+t+dJsjAKpKSkg3EHvbtEpFFb/1CiOHnvI8bjHgOD4/wAw3gKrA0i94LrbekypiZVanJu3McWJg7rWDMzRTg==
+  dependencies:
+    "@typescript-eslint/types" "5.18.0"
+    eslint-visitor-keys "^3.0.0"
+
 acorn-jsx@^5.3.1:
 acorn-jsx@^5.3.1:
   version "5.3.2"
   version "5.3.2"
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
 
+acorn-node@^1.6.1:
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8"
+  integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==
+  dependencies:
+    acorn "^7.0.0"
+    acorn-walk "^7.0.0"
+    xtend "^4.0.2"
+
+acorn-walk@^7.0.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
+  integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
+
+acorn@^7.0.0:
+  version "7.4.1"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
+  integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
+
 acorn@^8.7.0:
 acorn@^8.7.0:
   version "8.7.0"
   version "8.7.0"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf"
@@ -1038,6 +1139,19 @@ ansi-styles@^4.1.0:
   dependencies:
   dependencies:
     color-convert "^2.0.1"
     color-convert "^2.0.1"
 
 
+anymatch@~3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
+  integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
+  dependencies:
+    normalize-path "^3.0.0"
+    picomatch "^2.0.4"
+
+arg@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.1.tgz#eb0c9a8f77786cad2af8ff2b862899842d7b6adb"
+  integrity sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==
+
 argparse@^2.0.1:
 argparse@^2.0.1:
   version "2.0.1"
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
@@ -1097,11 +1211,30 @@ ast-types-flow@^0.0.7:
   resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad"
   resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad"
   integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0=
   integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0=
 
 
+autoprefixer@^10.4.4:
+  version "10.4.4"
+  resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.4.tgz#3e85a245b32da876a893d3ac2ea19f01e7ea5a1e"
+  integrity sha512-Tm8JxsB286VweiZ5F0anmbyGiNI3v3wGv3mz9W+cxEDYB/6jbnj6GM9H9mK3wIL8ftgl+C07Lcwb8PG5PCCPzA==
+  dependencies:
+    browserslist "^4.20.2"
+    caniuse-lite "^1.0.30001317"
+    fraction.js "^4.2.0"
+    normalize-range "^0.1.2"
+    picocolors "^1.0.0"
+    postcss-value-parser "^4.2.0"
+
 axe-core@^4.3.5:
 axe-core@^4.3.5:
   version "4.4.1"
   version "4.4.1"
   resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.1.tgz#7dbdc25989298f9ad006645cd396782443757413"
   resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.1.tgz#7dbdc25989298f9ad006645cd396782443757413"
   integrity sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==
   integrity sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==
 
 
+axios@^0.26.1:
+  version "0.26.1"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9"
+  integrity sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==
+  dependencies:
+    follow-redirects "^1.14.8"
+
 axobject-query@^2.2.0:
 axobject-query@^2.2.0:
   version "2.2.0"
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
   resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
@@ -1121,6 +1254,11 @@ balanced-match@^1.0.0:
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
 
+binary-extensions@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
+  integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+
 brace-expansion@^1.1.7:
 brace-expansion@^1.1.7:
   version "1.1.11"
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -1129,13 +1267,24 @@ brace-expansion@^1.1.7:
     balanced-match "^1.0.0"
     balanced-match "^1.0.0"
     concat-map "0.0.1"
     concat-map "0.0.1"
 
 
-braces@^3.0.2:
+braces@^3.0.2, braces@~3.0.2:
   version "3.0.2"
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
   integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
   integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
   dependencies:
   dependencies:
     fill-range "^7.0.1"
     fill-range "^7.0.1"
 
 
+browserslist@^4.20.2:
+  version "4.20.2"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.20.2.tgz#567b41508757ecd904dab4d1c646c612cd3d4f88"
+  integrity sha512-CQOBCqp/9pDvDbx3xfMi+86pr4KXIf2FDkTTdeuYw8OxS9t898LA1Khq57gtufFILXpfgsSx5woNgsBgvGjpsA==
+  dependencies:
+    caniuse-lite "^1.0.30001317"
+    electron-to-chromium "^1.4.84"
+    escalade "^3.1.1"
+    node-releases "^2.0.2"
+    picocolors "^1.0.0"
+
 call-bind@^1.0.0, call-bind@^1.0.2:
 call-bind@^1.0.0, call-bind@^1.0.2:
   version "1.0.2"
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
   resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
@@ -1149,11 +1298,21 @@ callsites@^3.0.0:
   resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
   resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
   integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
   integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
 
 
+camelcase-css@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
+  integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
+
 caniuse-lite@^1.0.30001283:
 caniuse-lite@^1.0.30001283:
   version "1.0.30001323"
   version "1.0.30001323"
   resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001323.tgz#a451ff80dec7033016843f532efda18f02eec011"
   resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001323.tgz#a451ff80dec7033016843f532efda18f02eec011"
   integrity sha512-e4BF2RlCVELKx8+RmklSEIVub1TWrmdhvA5kEUueummz1XyySW0DVk+3x9HyhU9MuWTa2BhqLgEuEmUwASAdCA==
   integrity sha512-e4BF2RlCVELKx8+RmklSEIVub1TWrmdhvA5kEUueummz1XyySW0DVk+3x9HyhU9MuWTa2BhqLgEuEmUwASAdCA==
 
 
+caniuse-lite@^1.0.30001317:
+  version "1.0.30001327"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001327.tgz#c1546d7d7bb66506f0ccdad6a7d07fc6d668c858"
+  integrity sha512-1/Cg4jlD9qjZzhbzkzEaAC2JHsP0WrOc8Rd/3a3LuajGzGWR/hD7TVyvq99VqmTy99eVh8Zkmdq213OgvgXx7w==
+
 chalk@^2.0.0:
 chalk@^2.0.0:
   version "2.4.2"
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@@ -1163,7 +1322,7 @@ chalk@^2.0.0:
     escape-string-regexp "^1.0.5"
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
     supports-color "^5.3.0"
 
 
-chalk@^4.0.0:
+chalk@^4.0.0, chalk@^4.1.2:
   version "4.1.2"
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
   integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
   integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -1171,6 +1330,26 @@ chalk@^4.0.0:
     ansi-styles "^4.1.0"
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
     supports-color "^7.1.0"
 
 
+chokidar@^3.5.3:
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
+  integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
+  dependencies:
+    anymatch "~3.1.2"
+    braces "~3.0.2"
+    glob-parent "~5.1.2"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.6.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
+
+clsx@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
+  integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==
+
 color-convert@^1.9.0:
 color-convert@^1.9.0:
   version "1.9.3"
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -1190,7 +1369,7 @@ color-name@1.1.3:
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
   integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
   integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
 
 
-color-name@~1.1.4:
+color-name@^1.1.4, color-name@~1.1.4:
   version "1.1.4"
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
@@ -1205,6 +1384,11 @@ concat-map@0.0.1:
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
   integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
 
 
+confusing-browser-globals@^1.0.10:
+  version "1.0.11"
+  resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81"
+  integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==
+
 convert-source-map@^1.5.0:
 convert-source-map@^1.5.0:
   version "1.8.0"
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
@@ -1235,6 +1419,17 @@ cosmiconfig@^6.0.0:
     path-type "^4.0.0"
     path-type "^4.0.0"
     yaml "^1.7.2"
     yaml "^1.7.2"
 
 
+cosmiconfig@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.1.tgz#714d756522cace867867ccb4474c5d01bbae5d6d"
+  integrity sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==
+  dependencies:
+    "@types/parse-json" "^4.0.0"
+    import-fresh "^3.2.1"
+    parse-json "^5.0.0"
+    path-type "^4.0.0"
+    yaml "^1.10.0"
+
 cross-spawn@^7.0.2:
 cross-spawn@^7.0.2:
   version "7.0.3"
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -1251,6 +1446,11 @@ css-box-model@1.2.1:
   dependencies:
   dependencies:
     tiny-invariant "^1.0.6"
     tiny-invariant "^1.0.6"
 
 
+cssesc@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
+  integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
+
 csstype@3.0.9:
 csstype@3.0.9:
   version "3.0.9"
   version "3.0.9"
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.9.tgz#6410af31b26bd0520933d02cbc64fce9ce3fbf0b"
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.9.tgz#6410af31b26bd0520933d02cbc64fce9ce3fbf0b"
@@ -1299,11 +1499,30 @@ define-properties@^1.1.3:
   dependencies:
   dependencies:
     object-keys "^1.0.12"
     object-keys "^1.0.12"
 
 
+defined@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
+  integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=
+
 detect-node-es@^1.1.0:
 detect-node-es@^1.1.0:
   version "1.1.0"
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
   resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
   integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
   integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
 
 
+detective@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b"
+  integrity sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==
+  dependencies:
+    acorn-node "^1.6.1"
+    defined "^1.0.0"
+    minimist "^1.1.1"
+
+didyoumean@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
+  integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
+
 dir-glob@^3.0.1:
 dir-glob@^3.0.1:
   version "3.0.1"
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
   resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@@ -1311,6 +1530,11 @@ dir-glob@^3.0.1:
   dependencies:
   dependencies:
     path-type "^4.0.0"
     path-type "^4.0.0"
 
 
+dlv@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
+  integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
+
 doctrine@^2.1.0:
 doctrine@^2.1.0:
   version "2.1.0"
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
@@ -1325,6 +1549,11 @@ doctrine@^3.0.0:
   dependencies:
   dependencies:
     esutils "^2.0.2"
     esutils "^2.0.2"
 
 
+electron-to-chromium@^1.4.84:
+  version "1.4.106"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.106.tgz#e7a3bfa9d745dd9b9e597616cb17283cc349781a"
+  integrity sha512-ZYfpVLULm67K7CaaGP7DmjyeMY4naxsbTy+syVVxT6QHI1Ww8XbJjmr9fDckrhq44WzCrcC5kH3zGpdusxwwqg==
+
 emoji-regex@^9.2.2:
 emoji-regex@^9.2.2:
   version "9.2.2"
   version "9.2.2"
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
@@ -1372,6 +1601,11 @@ es-to-primitive@^1.2.1:
     is-date-object "^1.0.1"
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
     is-symbol "^1.0.2"
 
 
+escalade@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
+  integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
+
 escape-string-regexp@^1.0.5:
 escape-string-regexp@^1.0.5:
   version "1.0.5"
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
@@ -1382,6 +1616,23 @@ escape-string-regexp@^4.0.0:
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
   integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
   integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
 
 
+eslint-config-airbnb-base@^15.0.0:
+  version "15.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz#6b09add90ac79c2f8d723a2580e07f3925afd236"
+  integrity sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==
+  dependencies:
+    confusing-browser-globals "^1.0.10"
+    object.assign "^4.1.2"
+    object.entries "^1.1.5"
+    semver "^6.3.0"
+
+eslint-config-airbnb-typescript@^17.0.0:
+  version "17.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-17.0.0.tgz#360dbcf810b26bbcf2ff716198465775f1c49a07"
+  integrity sha512-elNiuzD0kPAPTXjFWg+lE24nMdHMtuxgYoD30OyMD6yrW1AhFZPAg27VX7d3tzOErw+dgJTNWfRSDqEcXb4V0g==
+  dependencies:
+    eslint-config-airbnb-base "^15.0.0"
+
 eslint-config-next@12.1.4:
 eslint-config-next@12.1.4:
   version "12.1.4"
   version "12.1.4"
   resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-12.1.4.tgz#939ea2ff33034763300bf1e62482cea91212d274"
   resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-12.1.4.tgz#939ea2ff33034763300bf1e62482cea91212d274"
@@ -1494,6 +1745,14 @@ eslint-plugin-react@7.29.1:
     semver "^6.3.0"
     semver "^6.3.0"
     string.prototype.matchall "^4.0.6"
     string.prototype.matchall "^4.0.6"
 
 
+eslint-scope@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
+  integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
+  dependencies:
+    esrecurse "^4.3.0"
+    estraverse "^4.1.1"
+
 eslint-scope@^7.1.1:
 eslint-scope@^7.1.1:
   version "7.1.1"
   version "7.1.1"
   resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642"
   resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642"
@@ -1583,6 +1842,11 @@ esrecurse@^4.3.0:
   dependencies:
   dependencies:
     estraverse "^5.2.0"
     estraverse "^5.2.0"
 
 
+estraverse@^4.1.1:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
+  integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
+
 estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0:
 estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0:
   version "5.3.0"
   version "5.3.0"
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
@@ -1598,7 +1862,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
 
-fast-glob@^3.2.9:
+fast-glob@^3.2.11, fast-glob@^3.2.9:
   version "3.2.11"
   version "3.2.11"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9"
   integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==
   integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==
@@ -1640,6 +1904,13 @@ fill-range@^7.0.1:
   dependencies:
   dependencies:
     to-regex-range "^5.0.1"
     to-regex-range "^5.0.1"
 
 
+final-form@^4.20.6:
+  version "4.20.6"
+  resolved "https://registry.yarnpkg.com/final-form/-/final-form-4.20.6.tgz#da42f3741db068c0c875e18950a2c4a9a148c63e"
+  integrity sha512-fCdwIj49KOaFfDRlXB57Eo+GghIMZQWrA9TakQI3C9uQxHwaFHXqZSNRlUdfnQmNNeySwGOaGPZCvjy58hyv4w==
+  dependencies:
+    "@babel/runtime" "^7.10.0"
+
 find-root@^1.1.0:
 find-root@^1.1.0:
   version "1.1.0"
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4"
   resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4"
@@ -1672,6 +1943,16 @@ focus-lock@^0.9.1:
   dependencies:
   dependencies:
     tslib "^2.0.3"
     tslib "^2.0.3"
 
 
+follow-redirects@^1.14.8:
+  version "1.14.9"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7"
+  integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==
+
+fraction.js@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950"
+  integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==
+
 framer-motion@^6:
 framer-motion@^6:
   version "6.2.8"
   version "6.2.8"
   resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-6.2.8.tgz#02abb529191af7e2df444185fe27e932215b715d"
   resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-6.2.8.tgz#02abb529191af7e2df444185fe27e932215b715d"
@@ -1704,6 +1985,11 @@ fs.realpath@^1.0.0:
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
 
+fsevents@~2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+  integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
 function-bind@^1.1.1:
 function-bind@^1.1.1:
   version "1.1.1"
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -1736,14 +2022,14 @@ get-symbol-description@^1.0.0:
     call-bind "^1.0.2"
     call-bind "^1.0.2"
     get-intrinsic "^1.1.1"
     get-intrinsic "^1.1.1"
 
 
-glob-parent@^5.1.2:
+glob-parent@^5.1.2, glob-parent@~5.1.2:
   version "5.1.2"
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
   integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
   integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
   dependencies:
   dependencies:
     is-glob "^4.0.1"
     is-glob "^4.0.1"
 
 
-glob-parent@^6.0.1:
+glob-parent@^6.0.1, glob-parent@^6.0.2:
   version "6.0.2"
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
   integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
   integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
@@ -1839,11 +2125,16 @@ hoist-non-react-statics@^3.3.1:
   dependencies:
   dependencies:
     react-is "^16.7.0"
     react-is "^16.7.0"
 
 
-ignore@^5.2.0:
+ignore@^5.1.8, ignore@^5.2.0:
   version "5.2.0"
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
   integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
   integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
 
 
+immer@^9.0.12:
+  version "9.0.12"
+  resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20"
+  integrity sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==
+
 import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
 import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
   version "3.3.0"
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@@ -1898,6 +2189,13 @@ is-bigint@^1.0.1:
   dependencies:
   dependencies:
     has-bigints "^1.0.1"
     has-bigints "^1.0.1"
 
 
+is-binary-path@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+  dependencies:
+    binary-extensions "^2.0.0"
+
 is-boolean-object@^1.1.0:
 is-boolean-object@^1.1.0:
   version "1.1.2"
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719"
   resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719"
@@ -1930,7 +2228,7 @@ is-extglob@^2.1.1:
   resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
   resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
 
-is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3:
+is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
   version "4.0.3"
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
   resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
   integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
   integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@@ -2055,6 +2353,11 @@ levn@^0.4.1:
     prelude-ls "^1.2.1"
     prelude-ls "^1.2.1"
     type-check "~0.4.0"
     type-check "~0.4.0"
 
 
+lilconfig@^2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.5.tgz#19e57fd06ccc3848fd1891655b5a447092225b25"
+  integrity sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==
+
 lines-and-columns@^1.1.6:
 lines-and-columns@^1.1.6:
   version "1.2.4"
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
   resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
@@ -2112,7 +2415,7 @@ minimatch@^3.0.4, minimatch@^3.1.2:
   dependencies:
   dependencies:
     brace-expansion "^1.1.7"
     brace-expansion "^1.1.7"
 
 
-minimist@^1.2.0, minimist@^1.2.6:
+minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.6:
   version "1.2.6"
   version "1.2.6"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
   integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
   integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
@@ -2132,7 +2435,7 @@ ms@^2.1.1:
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
   integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
   integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
 
 
-nanoid@^3.1.30:
+nanoid@^3.1.30, nanoid@^3.3.1:
   version "3.3.2"
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.2.tgz#c89622fafb4381cd221421c69ec58547a1eec557"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.2.tgz#c89622fafb4381cd221421c69ec58547a1eec557"
   integrity sha512-CuHBogktKwpm5g2sRgv83jEy2ijFzBwMoYA60orPDR7ynsLijJDqgsi4RDGj3OJpy3Ieb+LYwiRmIOGyytgITA==
   integrity sha512-CuHBogktKwpm5g2sRgv83jEy2ijFzBwMoYA60orPDR7ynsLijJDqgsi4RDGj3OJpy3Ieb+LYwiRmIOGyytgITA==
@@ -2165,11 +2468,31 @@ next@12.1.4:
     "@next/swc-win32-ia32-msvc" "12.1.4"
     "@next/swc-win32-ia32-msvc" "12.1.4"
     "@next/swc-win32-x64-msvc" "12.1.4"
     "@next/swc-win32-x64-msvc" "12.1.4"
 
 
+node-releases@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01"
+  integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==
+
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+normalize-range@^0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
+  integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
+
 object-assign@^4.1.1:
 object-assign@^4.1.1:
   version "4.1.1"
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
   integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
 
 
+object-hash@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
+  integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
+
 object-inspect@^1.12.0, object-inspect@^1.9.0:
 object-inspect@^1.12.0, object-inspect@^1.9.0:
   version "1.12.0"
   version "1.12.0"
   resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0"
   resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0"
@@ -2310,7 +2633,7 @@ picocolors@^1.0.0:
   resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
   resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
   integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
   integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
 
 
-picomatch@^2.3.1:
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
   version "2.3.1"
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
   integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
   integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
@@ -2325,6 +2648,41 @@ popmotion@11.0.3:
     style-value-types "5.0.0"
     style-value-types "5.0.0"
     tslib "^2.1.0"
     tslib "^2.1.0"
 
 
+postcss-js@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.0.tgz#31db79889531b80dc7bc9b0ad283e418dce0ac00"
+  integrity sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==
+  dependencies:
+    camelcase-css "^2.0.1"
+
+postcss-load-config@^3.1.0:
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855"
+  integrity sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==
+  dependencies:
+    lilconfig "^2.0.5"
+    yaml "^1.10.2"
+
+postcss-nested@5.0.6:
+  version "5.0.6"
+  resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-5.0.6.tgz#466343f7fc8d3d46af3e7dba3fcd47d052a945bc"
+  integrity sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==
+  dependencies:
+    postcss-selector-parser "^6.0.6"
+
+postcss-selector-parser@^6.0.6, postcss-selector-parser@^6.0.9:
+  version "6.0.10"
+  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
+  integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
+  dependencies:
+    cssesc "^3.0.0"
+    util-deprecate "^1.0.2"
+
+postcss-value-parser@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
+  integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
+
 postcss@8.4.5:
 postcss@8.4.5:
   version "8.4.5"
   version "8.4.5"
   resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.5.tgz#bae665764dfd4c6fcc24dc0fdf7e7aa00cc77f95"
   resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.5.tgz#bae665764dfd4c6fcc24dc0fdf7e7aa00cc77f95"
@@ -2334,6 +2692,15 @@ postcss@8.4.5:
     picocolors "^1.0.0"
     picocolors "^1.0.0"
     source-map-js "^1.0.1"
     source-map-js "^1.0.1"
 
 
+postcss@^8.4.12, postcss@^8.4.6:
+  version "8.4.12"
+  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.12.tgz#1e7de78733b28970fa4743f7da6f3763648b1905"
+  integrity sha512-lg6eITwYe9v6Hr5CncVbK70SoioNQIq81nsaG86ev5hAidQvmOeETBqs7jm43K2F5/Ley3ytDtriImV6TpNiSg==
+  dependencies:
+    nanoid "^3.3.1"
+    picocolors "^1.0.0"
+    source-map-js "^1.0.2"
+
 prelude-ls@^1.2.1:
 prelude-ls@^1.2.1:
   version "1.2.1"
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@@ -2358,6 +2725,11 @@ queue-microtask@^1.2.2:
   resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
   resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
   integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
   integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
 
 
+quick-lru@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
+  integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
+
 react-clientside-effect@^1.2.5:
 react-clientside-effect@^1.2.5:
   version "1.2.5"
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.5.tgz#e2c4dc3c9ee109f642fac4f5b6e9bf5bcd2219a3"
   resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.5.tgz#e2c4dc3c9ee109f642fac4f5b6e9bf5bcd2219a3"
@@ -2378,6 +2750,13 @@ react-fast-compare@3.2.0:
   resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
   resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
   integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
   integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
 
 
+react-final-form@^6.5.9:
+  version "6.5.9"
+  resolved "https://registry.yarnpkg.com/react-final-form/-/react-final-form-6.5.9.tgz#644797d4c122801b37b58a76c87761547411190b"
+  integrity sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==
+  dependencies:
+    "@babel/runtime" "^7.15.4"
+
 react-focus-lock@2.5.2:
 react-focus-lock@2.5.2:
   version "2.5.2"
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.5.2.tgz#f1e4db5e25cd8789351f2bd5ebe91e9dcb9c2922"
   resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.5.2.tgz#f1e4db5e25cd8789351f2bd5ebe91e9dcb9c2922"
@@ -2390,6 +2769,11 @@ react-focus-lock@2.5.2:
     use-callback-ref "^1.2.5"
     use-callback-ref "^1.2.5"
     use-sidecar "^1.0.5"
     use-sidecar "^1.0.5"
 
 
+react-icons@^4.3.1:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.3.1.tgz#2fa92aebbbc71f43d2db2ed1aed07361124e91ca"
+  integrity sha512-cB10MXLTs3gVuXimblAdI71jrJx8njrJZmNMEMC+sQu5B/BIOmlsAjskdqpn81y8UBVEGuHODd7/ci5DvoSzTQ==
+
 react-is@^16.13.1, react-is@^16.7.0:
 react-is@^16.13.1, react-is@^16.7.0:
   version "16.13.1"
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@@ -2430,6 +2814,13 @@ react@18.0.0:
   dependencies:
   dependencies:
     loose-envify "^1.1.0"
     loose-envify "^1.1.0"
 
 
+readdirp@~3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+  integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+  dependencies:
+    picomatch "^2.2.1"
+
 regenerator-runtime@^0.13.4:
 regenerator-runtime@^0.13.4:
   version "0.13.9"
   version "0.13.9"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
@@ -2453,7 +2844,7 @@ resolve-from@^4.0.0:
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
   integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
   integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
 
 
-resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.20.0:
+resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.20.0, resolve@^1.22.0:
   version "1.22.0"
   version "1.22.0"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
   integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
   integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
@@ -2539,7 +2930,7 @@ slash@^3.0.0:
   resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
   resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
   integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
   integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
 
 
-source-map-js@^1.0.1:
+source-map-js@^1.0.1, source-map-js@^1.0.2:
   version "1.0.2"
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
   resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
   integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
   integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
@@ -2633,11 +3024,43 @@ supports-preserve-symlinks-flag@^1.0.0:
   resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
   resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
 
+swr@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/swr/-/swr-1.3.0.tgz#c6531866a35b4db37b38b72c45a63171faf9f4e8"
+  integrity sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw==
+
 systeminformation@^5.11.9:
 systeminformation@^5.11.9:
   version "5.11.9"
   version "5.11.9"
   resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.11.9.tgz#95f2334e739dd224178948a2afaced7d9abfdf9d"
   resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.11.9.tgz#95f2334e739dd224178948a2afaced7d9abfdf9d"
   integrity sha512-eeMtL9UJFR/LYG+2rpeAgZ0Va4ojlNQTkYiQH/xbbPwDjDMsaetj3Pkc+C1aH5G8mav6HvDY8kI4Vl4noksSkA==
   integrity sha512-eeMtL9UJFR/LYG+2rpeAgZ0Va4ojlNQTkYiQH/xbbPwDjDMsaetj3Pkc+C1aH5G8mav6HvDY8kI4Vl4noksSkA==
 
 
+tailwindcss@^3.0.23:
+  version "3.0.23"
+  resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.0.23.tgz#c620521d53a289650872a66adfcb4129d2200d10"
+  integrity sha512-+OZOV9ubyQ6oI2BXEhzw4HrqvgcARY38xv3zKcjnWtMIZstEsXdI9xftd1iB7+RbOnj2HOEzkA0OyB5BaSxPQA==
+  dependencies:
+    arg "^5.0.1"
+    chalk "^4.1.2"
+    chokidar "^3.5.3"
+    color-name "^1.1.4"
+    cosmiconfig "^7.0.1"
+    detective "^5.2.0"
+    didyoumean "^1.2.2"
+    dlv "^1.1.3"
+    fast-glob "^3.2.11"
+    glob-parent "^6.0.2"
+    is-glob "^4.0.3"
+    normalize-path "^3.0.0"
+    object-hash "^2.2.0"
+    postcss "^8.4.6"
+    postcss-js "^4.0.0"
+    postcss-load-config "^3.1.0"
+    postcss-nested "5.0.6"
+    postcss-selector-parser "^6.0.9"
+    postcss-value-parser "^4.2.0"
+    quick-lru "^5.1.1"
+    resolve "^1.22.0"
+
 text-table@^0.2.0:
 text-table@^0.2.0:
   version "0.2.0"
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
   resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@@ -2739,6 +3162,11 @@ use-sidecar@^1.0.1, use-sidecar@^1.0.5:
     detect-node-es "^1.1.0"
     detect-node-es "^1.1.0"
     tslib "^1.9.3"
     tslib "^1.9.3"
 
 
+util-deprecate@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+
 v8-compile-cache@^2.0.3:
 v8-compile-cache@^2.0.3:
   version "2.3.0"
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
@@ -2784,12 +3212,22 @@ wrappy@1:
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
 
+xtend@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
+  integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
+
 yallist@^4.0.0:
 yallist@^4.0.0:
   version "4.0.0"
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
 
-yaml@^1.7.2:
+yaml@^1.10.0, yaml@^1.10.2, yaml@^1.7.2:
   version "1.10.2"
   version "1.10.2"
   resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
   resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
   integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
   integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
+
+zustand@^3.7.2:
+  version "3.7.2"
+  resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.7.2.tgz#7b44c4f4a5bfd7a8296a3957b13e1c346f42514d"
+  integrity sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==

+ 32 - 26
docker-compose.yml

@@ -1,4 +1,4 @@
-version: '3.7'
+version: "3.7"
 
 
 services:
 services:
   # gluetun:
   # gluetun:
@@ -18,42 +18,48 @@ services:
   #     - 8080:8080
   #     - 8080:8080
   #   networks:
   #   networks:
   #     - tipi_main_network
   #     - tipi_main_network
-  
-  reverse-proxy:
-    container_name: reverse-proxy
-    image: traefik:v2.6
-    restart: always
-    ports:
-      - 80:80
-      - 443:443
-      - 8080:8080
-    security_opt:
-      - no-new-privileges:true
-    volumes:
-      - /var/run/docker.sock:/var/run/docker.sock:ro
-      - ${PWD}/traefik:/root/.config
-    networks:
-      - tipi_main_network
+
+  # reverse-proxy:
+  #   container_name: reverse-proxy
+  #   image: traefik:v2.6
+  #   restart: always
+  #   ports:
+  #     - 80:80
+  #     - 443:443
+  #     - 8080:8080
+  #   security_opt:
+  #     - no-new-privileges:true
+  #   volumes:
+  #     - /var/run/docker.sock:/var/run/docker.sock:ro
+  #     - ${PWD}/traefik:/root/.config
+  #   networks:
+  #     - tipi_main_network
 
 
   dashboard:
   dashboard:
     build:
     build:
       context: ./dashboard
       context: ./dashboard
       dockerfile: Dockerfile
       dockerfile: Dockerfile
+      args:
+        INTERNAL_IP_ARG: ${INTERNAL_IP}
     container_name: dashboard
     container_name: dashboard
     volumes:
     volumes:
       - ${PWD}/state:/app/state
       - ${PWD}/state:/app/state
-      - ${PWD}/config:/app/config:ro
     ports:
     ports:
       - 3000:3000
       - 3000:3000
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
-    labels:
-        traefik.enable: true
-        traefik.http.routers.dashboard.rule: Host(`tipi.local`)
-        # traefik.http.routers.dashboard.tls: true
-        traefik.http.routers.dashboard.entrypoints: webinsecure
-        traefik.http.routers.dashboard.service: dashboard
-        traefik.http.services.dashboard.loadbalancer.server.port: 3000
+    # labels:
+    #   traefik.enable: true
+    #   traefik.http.routers.dashboard.rule: Host(`tipi.local`)
+    #   # traefik.http.routers.dashboard.tls: true
+    #   traefik.http.routers.dashboard.entrypoints: webinsecure
+    #   traefik.http.routers.dashboard.service: dashboard
+    #   traefik.http.services.dashboard.loadbalancer.server.port: 3000
 
 
 networks:
 networks:
-    tipi_main_network:
+  tipi_main_network:
+    driver: bridge
+    ipam:
+      driver: default
+      config:
+        - subnet: 10.21.21.0/24

+ 14 - 8
scripts/app.sh

@@ -87,12 +87,17 @@ compose() {
 
 
   # Vars to use in compose file
   # Vars to use in compose file
   export APP_DATA_DIR="${app_data_dir}"
   export APP_DATA_DIR="${app_data_dir}"
-  export APP_PASSWORD="password"
   export APP_DIR="${app_dir}"
   export APP_DIR="${app_dir}"
 
 
+  # TODO: Fix for dynamic detection
+  export DEVICE_IP="192.168.2.132"
+  export ROOT_FOLDER="${ROOT_FOLDER}"
+
+  # Docker-compose does not support multiple env files
+  # --env-file "${env_file}" \
+
   docker-compose \
   docker-compose \
-    --env-file "${env_file}" \
-    --env-file "${app_dir}/.env" \
+    --env-file "${ROOT_FOLDER}/app-data/${app}/app.env" \
     --project-name "${app}" \
     --project-name "${app}" \
     --file "${app_compose_file}" \
     --file "${app_compose_file}" \
     --file "${common_compose_file}" \
     --file "${common_compose_file}" \
@@ -103,24 +108,25 @@ compose() {
 if [[ "$command" = "install" ]]; then
 if [[ "$command" = "install" ]]; then
   compose "${app}" pull
   compose "${app}" pull
 
 
+  # Copy default data dir to app data dir if it exists
+  if [[ -d "${ROOT_FOLDER}/apps/${app}/data" ]]; then
+    cp -r "${ROOT_FOLDER}/apps/${app}/data" "${app_data_dir}/data"
+  fi
+
   compose "${app}" up -d
   compose "${app}" up -d
   exit
   exit
 fi
 fi
 
 
 # Removes images and destroys all data for an app
 # Removes images and destroys all data for an app
 if [[ "$command" = "uninstall" ]]; then
 if [[ "$command" = "uninstall" ]]; then
-
   echo "Removing images for app ${app}..."
   echo "Removing images for app ${app}..."
-  compose "${app}" down --rmi all --remove-orphans
+  compose "${app}" down --remove-orphans
 
 
   echo "Deleting app data for app ${app}..."
   echo "Deleting app data for app ${app}..."
   if [[ -d "${app_data_dir}" ]]; then
   if [[ -d "${app_data_dir}" ]]; then
     rm -rf "${app_data_dir}"
     rm -rf "${app_data_dir}"
   fi
   fi
 
 
-  echo "Removing app ${app} from DB..."
-  update_installed_apps remove "${app}"
-
   echo "Successfully uninstalled app ${app}"
   echo "Successfully uninstalled app ${app}"
   exit
   exit
 fi
 fi

+ 8 - 39
scripts/configure.sh

@@ -1,14 +1,7 @@
-ROOT_FOLDER="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
+#!/usr/bin/env bash
+set -e  # Exit immediately if a command exits with a non-zero status.
 
 
-# Constants
-NGINX_PORT="80"
-# Apps
-APP_PI_HOLE_PORT="8081"
-APP_WG_EASY_PORT="8082"
-APP_NEXTCLOUD_PORT="8082"
-APP_ANONADDY_PORT="8083"
-APP_SIMPLETORRENT_PORT="8084"
-APP_FRESHRSS_PORT="8085"
+ROOT_FOLDER="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
 
 
 echo
 echo
 echo "======================================"
 echo "======================================"
@@ -21,43 +14,19 @@ echo "=============== TIPI ================="
 echo "======================================"
 echo "======================================"
 echo
 echo
 
 
-# Store paths to intermediary config files
-ENV_FILE="./templates/.env"
-
-# Remove intermediary config files
-[[ -f "$ENV_FILE" ]] && rm -f "$ENV_FILE"
-
-# Copy template configs to intermediary configs
-[[ -f "./templates/.env-sample" ]] && cp "./templates/.env-sample" "$ENV_FILE"
-
 # Install ansible if not installed
 # Install ansible if not installed
 if ! command -v ansible-playbook > /dev/null; then
 if ! command -v ansible-playbook > /dev/null; then
   echo "Installing Ansible..."
   echo "Installing Ansible..."
-  sudo apt-get install -y software-properties-common
-  sudo apt-add-repository -y ppa:ansible/ansible
   sudo apt-get update
   sudo apt-get update
-  sudo apt-get install -y ansible
+  sudo apt-get install python3 python3-pip -y
+  sudo pip3 install ansible
 fi
 fi
 
 
 ansible-playbook ansible/setup.yml -i ansible/hosts -K
 ansible-playbook ansible/setup.yml -i ansible/hosts -K
 
 
-echo "Generating config files..."
-for template in "${ENV_FILE}"; do
-  sed -i "s/<nginx-port>/${NGINX_PORT}/g" "${template}"
-  # Apps
-  sed -i "s/<app-pi-hole-port>/${APP_PI_HOLE_PORT}/g" "${template}"
-  sed -i "s/<app-wgeasy-port>/${APP_WG_EASY_PORT}/g" "${template}"
-  sed -i "s/<app-nextcloud-port>/${APP_NEXTCLOUD_PORT}/g" "${template}"
-  sed -i "s/<app-anonaddy-port>/${APP_ANONADDY_PORT}/g" "${template}"
-  sed -i "s/<app-simpletorrent-port>/${APP_SIMPLETORRENT_PORT}/g" "${template}"
-  sed -i "s/<app-freshrss-port>/${APP_FRESHRSS_PORT}/g" "${template}"
-done
-
-mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
-
-echo "Configuring permissions..."
-echo
-find "$ROOT_FOLDER" -path "$ROOT_FOLDER/app-data" -prune -o -exec chown 1000:1000 {} + || true
+# echo "Configuring permissions..."
+# echo
+# find "$ROOT_FOLDER" -path "$ROOT_FOLDER/app-data" -prune -o -exec chown 1000:1000 {} + || true
 
 
 # Create configured status
 # Create configured status
 touch "${ROOT_FOLDER}/state/configured"
 touch "${ROOT_FOLDER}/state/configured"

+ 64 - 3
scripts/start.sh

@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
+set -e  # Exit immediately if a command exits with a non-zero status.
 
 
 # use greadlink instead of readlink on osx
 # use greadlink instead of readlink on osx
 if [[ "$(uname)" == "Darwin" ]]; then
 if [[ "$(uname)" == "Darwin" ]]; then
@@ -9,7 +10,11 @@ fi
 
 
 ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
 ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
 STATE_FOLDER="${ROOT_FOLDER}/state"
 STATE_FOLDER="${ROOT_FOLDER}/state"
-DOMAIN=local
+
+INTERNAL_IP="$(hostname -I | awk '{print $1}')"
+PUID="$(id -u)"
+PGID="$(id -g)"
+TZ="$(cat /etc/timezone | sed 's/\//\\\//g' || echo "Europe/Berlin")"
 
 
 if [[ $UID != 0 ]]; then
 if [[ $UID != 0 ]]; then
     echo "Tipi must be started as root"
     echo "Tipi must be started as root"
@@ -23,13 +28,40 @@ if [[ ! -f "${STATE_FOLDER}/configured" ]]; then
   "${ROOT_FOLDER}/scripts/configure.sh"
   "${ROOT_FOLDER}/scripts/configure.sh"
 fi
 fi
 
 
-ansible-playbook ansible/start.yml -i ansible/hosts -K
+# Copy the app state if it isn't here
+if [[ ! -f "${STATE_FOLDER}/apps.json" ]]; then
+  cp "${ROOT_FOLDER}/templates/apps-sample.json" "${STATE_FOLDER}/apps.json"
+fi
 
 
 export DOCKER_CLIENT_TIMEOUT=240
 export DOCKER_CLIENT_TIMEOUT=240
 export COMPOSE_HTTP_TIMEOUT=240
 export COMPOSE_HTTP_TIMEOUT=240
 
 
+echo "Generating config files..."
+# Remove current .env file
+[[ -f "${ROOT_FOLDER}/.env" ]] && rm -f "${ROOT_FOLDER}/.env"
+
+# Store paths to intermediary config files
+ENV_FILE="$ROOT_FOLDER/templates/.env"
+
+# Remove intermediary config files
+[[ -f "$ENV_FILE" ]] && rm -f "$ENV_FILE"
+
+# Copy template configs to intermediary configs
+[[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
+
+for template in "${ENV_FILE}"; do
+  sed -i "s/<internal_ip>/${INTERNAL_IP}/g" "${template}"
+  sed -i "s/<puid>/${PUID}/g" "${template}"
+  sed -i "s/<pgid>/${PGID}/g" "${template}"
+  sed -i "s/<tz>/${TZ}/g" "${template}"
+done
+
+mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
+
+ansible-playbook ansible/start.yml -i ansible/hosts -K
+
 # Run docker-compose
 # Run docker-compose
-docker-compose up --detach --remove-orphans --build || {
+docker-compose --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
   echo "Failed to start containers"
   echo "Failed to start containers"
   exit 1
   exit 1
 }
 }
@@ -49,3 +81,32 @@ for app in "${apps_to_start[@]}"; do
     "${ROOT_FOLDER}/scripts/app.sh" start $app
     "${ROOT_FOLDER}/scripts/app.sh" start $app
 done
 done
 
 
+echo "Tipi is now running"
+echo ""
+cat << "EOF"
+       _,.
+     ,` -.)
+    '( _/'-\\-.               
+   /,|`--._,-^|            ,     
+   \_| |`-._/||          ,'|       
+     |  `-, / |         /  /      
+     |     || |        /  /       
+      `r-._||/   __   /  /  
+  __,-<_     )`-/  `./  /
+ '  \   `---'   \   /  / 
+     |           |./  /  
+     /           //  /     
+ \_/' \         |/  /         
+  |    |   _,^-'/  /              
+  |    , ``  (\/  /_        
+   \,.->._    \X-=/^         
+   (  /   `-._//^`  
+    `Y-.____(__}              
+     |     {__)           
+           ()`     
+EOF
+echo ""
+echo "Visit http://${INTERNAL_IP}:3000 to view the dashboard"
+echo ""
+
+

+ 0 - 6
state/apps.json

@@ -1,6 +0,0 @@
-{
-  "installed": "",
-  "environment": {
-    "anonaddy": {}
-  }
-}

+ 5 - 3
system-api/.eslintrc.cjs

@@ -1,16 +1,18 @@
 module.exports = {
 module.exports = {
   env: { node: true },
   env: { node: true },
-  extends: ['airbnb-base', 'eslint:recommended', 'plugin:import/typescript'],
+  extends: ['airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript'],
   parser: '@typescript-eslint/parser',
   parser: '@typescript-eslint/parser',
   parserOptions: {
   parserOptions: {
+    project: './tsconfig.json',
+    tsconfigRootDir: __dirname,
     ecmaVersion: 'latest',
     ecmaVersion: 'latest',
     sourceType: 'module',
     sourceType: 'module',
   },
   },
-  plugins: ['@typescript-eslint', 'import'],
+  plugins: ['@typescript-eslint', 'import', 'react'],
   rules: {
   rules: {
     'arrow-body-style': 0,
     'arrow-body-style': 0,
     'no-restricted-exports': 0,
     'no-restricted-exports': 0,
-    'max-len': [{ code: 200 }],
+    'max-len': [1, { code: 200 }],
     'import/extensions': ['error', 'ignorePackages', { js: 'never', jsx: 'never', ts: 'never', tsx: 'never' }],
     'import/extensions': ['error', 'ignorePackages', { js: 'never', jsx: 'never', ts: 'never', tsx: 'never' }],
   },
   },
 };
 };

+ 436 - 28
system-api/package-lock.json

@@ -10,16 +10,22 @@
       "license": "ISC",
       "license": "ISC",
       "dependencies": {
       "dependencies": {
         "compression": "^1.7.4",
         "compression": "^1.7.4",
+        "cors": "^2.8.5",
         "dotenv": "^16.0.0",
         "dotenv": "^16.0.0",
         "express": "^4.17.3",
         "express": "^4.17.3",
         "helmet": "^5.0.2",
         "helmet": "^5.0.2",
+        "internal-ip": "^7.0.0",
         "node-port-scanner": "^3.0.1",
         "node-port-scanner": "^3.0.1",
+        "p-iteration": "^1.1.8",
         "public-ip": "^5.0.0",
         "public-ip": "^5.0.0",
-        "systeminformation": "^5.11.9"
+        "systeminformation": "^5.11.9",
+        "tcp-port-used": "^1.0.2"
       },
       },
       "devDependencies": {
       "devDependencies": {
         "@types/compression": "^1.7.2",
         "@types/compression": "^1.7.2",
+        "@types/cors": "^2.8.12",
         "@types/express": "^4.17.13",
         "@types/express": "^4.17.13",
+        "@types/tcp-port-used": "^1.0.1",
         "@types/validator": "^13.7.2",
         "@types/validator": "^13.7.2",
         "concurrently": "^7.1.0",
         "concurrently": "^7.1.0",
         "esbuild": "^0.14.32",
         "esbuild": "^0.14.32",
@@ -27,8 +33,10 @@
         "eslint-config-airbnb-typescript": "^17.0.0",
         "eslint-config-airbnb-typescript": "^17.0.0",
         "eslint-config-hardcore": "^24.5.0",
         "eslint-config-hardcore": "^24.5.0",
         "eslint-config-prettier": "^8.5.0",
         "eslint-config-prettier": "^8.5.0",
+        "eslint-config-react": "^1.1.7",
         "eslint-plugin-import": "^2.26.0",
         "eslint-plugin-import": "^2.26.0",
         "eslint-plugin-prettier": "^4.0.0",
         "eslint-plugin-prettier": "^4.0.0",
+        "eslint-plugin-react": "^7.29.4",
         "eslint-plugin-unicorn": "^42.0.0",
         "eslint-plugin-unicorn": "^42.0.0",
         "nodemon": "^2.0.15",
         "nodemon": "^2.0.15",
         "prettier": "2.6.2"
         "prettier": "2.6.2"
@@ -3176,6 +3184,12 @@
         "@types/node": "*"
         "@types/node": "*"
       }
       }
     },
     },
+    "node_modules/@types/cors": {
+      "version": "2.8.12",
+      "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz",
+      "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==",
+      "dev": true
+    },
     "node_modules/@types/debug": {
     "node_modules/@types/debug": {
       "version": "4.1.7",
       "version": "4.1.7",
       "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
       "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
@@ -3336,6 +3350,12 @@
         "@types/node": "*"
         "@types/node": "*"
       }
       }
     },
     },
+    "node_modules/@types/tcp-port-used": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@types/tcp-port-used/-/tcp-port-used-1.0.1.tgz",
+      "integrity": "sha512-6pwWTx8oUtWvsiZUCrhrK/53MzKVLnuNSSaZILPy3uMes9QnTrLMar9BDlJArbMOjDcjb3QXFk6Rz8qmmuySZw==",
+      "dev": true
+    },
     "node_modules/@types/unist": {
     "node_modules/@types/unist": {
       "version": "2.0.6",
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
       "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
@@ -4841,6 +4861,18 @@
         "url": "https://opencollective.com/core-js"
         "url": "https://opencollective.com/core-js"
       }
       }
     },
     },
+    "node_modules/cors": {
+      "version": "2.8.5",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+      "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
     "node_modules/cosmiconfig": {
     "node_modules/cosmiconfig": {
       "version": "7.0.0",
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz",
       "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz",
@@ -4909,7 +4941,6 @@
       "version": "7.0.3",
       "version": "7.0.3",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
       "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
       "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
-      "dev": true,
       "dependencies": {
       "dependencies": {
         "path-key": "^3.1.0",
         "path-key": "^3.1.0",
         "shebang-command": "^2.0.0",
         "shebang-command": "^2.0.0",
@@ -5081,8 +5112,7 @@
     "node_modules/deep-is": {
     "node_modules/deep-is": {
       "version": "0.1.4",
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
-      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
-      "dev": true
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
     },
     },
     "node_modules/deepmerge": {
     "node_modules/deepmerge": {
       "version": "4.2.2",
       "version": "4.2.2",
@@ -5093,6 +5123,17 @@
         "node": ">=0.10.0"
         "node": ">=0.10.0"
       }
       }
     },
     },
+    "node_modules/default-gateway": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz",
+      "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==",
+      "dependencies": {
+        "execa": "^5.0.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
     "node_modules/defer-to-connect": {
     "node_modules/defer-to-connect": {
       "version": "2.0.1",
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
       "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -5921,6 +5962,12 @@
         "eslint": ">=7.0.0"
         "eslint": ">=7.0.0"
       }
       }
     },
     },
+    "node_modules/eslint-config-react": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/eslint-config-react/-/eslint-config-react-1.1.7.tgz",
+      "integrity": "sha1-oJGND8R9DpvRYaRzCAIdqF0lhbM=",
+      "dev": true
+    },
     "node_modules/eslint-etc": {
     "node_modules/eslint-etc": {
       "version": "5.1.0",
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/eslint-etc/-/eslint-etc-5.1.0.tgz",
       "resolved": "https://registry.npmjs.org/eslint-etc/-/eslint-etc-5.1.0.tgz",
@@ -7095,6 +7142,28 @@
       "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
       "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/execa": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+      "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+      "dependencies": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^6.0.0",
+        "human-signals": "^2.1.0",
+        "is-stream": "^2.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^4.0.1",
+        "onetime": "^5.1.2",
+        "signal-exit": "^3.0.3",
+        "strip-final-newline": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/execa?sponsor=1"
+      }
+    },
     "node_modules/execall": {
     "node_modules/execall": {
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/execall/-/execall-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/execall/-/execall-2.0.0.tgz",
@@ -7956,6 +8025,14 @@
         "node": ">=10.19.0"
         "node": ">=10.19.0"
       }
       }
     },
     },
+    "node_modules/human-signals": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+      "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+      "engines": {
+        "node": ">=10.17.0"
+      }
+    },
     "node_modules/iconv-lite": {
     "node_modules/iconv-lite": {
       "version": "0.4.24",
       "version": "0.4.24",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -8090,6 +8167,31 @@
         "node": ">=10"
         "node": ">=10"
       }
       }
     },
     },
+    "node_modules/internal-ip": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-7.0.0.tgz",
+      "integrity": "sha512-qE4TeD4brqC45Vq/+VASeMiS1KRyfBkR6HT2sh9pZVVCzSjPkaCEfKFU+dL0PRv7NHJtvoKN2r82G6wTfzorkw==",
+      "dependencies": {
+        "default-gateway": "^6.0.3",
+        "ipaddr.js": "^2.0.1",
+        "is-ip": "^3.1.0",
+        "p-event": "^4.2.0"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/internal-ip?sponsor=1"
+      }
+    },
+    "node_modules/internal-ip/node_modules/ipaddr.js": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz",
+      "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==",
+      "engines": {
+        "node": ">= 10"
+      }
+    },
     "node_modules/internal-slot": {
     "node_modules/internal-slot": {
       "version": "1.0.3",
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
       "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
@@ -8514,6 +8616,17 @@
         "url": "https://github.com/sponsors/ljharb"
         "url": "https://github.com/sponsors/ljharb"
       }
       }
     },
     },
+    "node_modules/is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/is-string": {
     "node_modules/is-string": {
       "version": "1.0.7",
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
       "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
@@ -8562,6 +8675,11 @@
         "node": ">=0.10.0"
         "node": ">=0.10.0"
       }
       }
     },
     },
+    "node_modules/is-url": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
+      "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="
+    },
     "node_modules/is-weakref": {
     "node_modules/is-weakref": {
       "version": "1.0.2",
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
       "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
@@ -8580,11 +8698,23 @@
       "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==",
       "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/is2": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/is2/-/is2-2.0.7.tgz",
+      "integrity": "sha512-4vBQoURAXC6hnLFxD4VW7uc04XiwTTl/8ydYJxKvPwkWQrSjInkuM5VZVg6BGr1/natq69zDuvO9lGpLClJqvA==",
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "ip-regex": "^4.1.0",
+        "is-url": "^1.2.4"
+      },
+      "engines": {
+        "node": ">=v0.10.0"
+      }
+    },
     "node_modules/isexe": {
     "node_modules/isexe": {
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
-      "dev": true
+      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
     },
     },
     "node_modules/isomorphic-git": {
     "node_modules/isomorphic-git": {
       "version": "1.17.0",
       "version": "1.17.0",
@@ -8938,6 +9068,7 @@
       "version": "7.8.0",
       "version": "7.8.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.8.0.tgz",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.8.0.tgz",
       "integrity": "sha512-AmXqneQZL3KZMIgBpaPTeI6pfwh+xQ2vutMsyqOu1TBdEXFZgpG/80wuJ531w2ZN7TI0/oc8CPxzh/DKQudZqg==",
       "integrity": "sha512-AmXqneQZL3KZMIgBpaPTeI6pfwh+xQ2vutMsyqOu1TBdEXFZgpG/80wuJ531w2ZN7TI0/oc8CPxzh/DKQudZqg==",
+      "deprecated": "Please update to latest patch version to fix memory leak https://github.com/isaacs/node-lru-cache/issues/227",
       "dev": true,
       "dev": true,
       "engines": {
       "engines": {
         "node": ">=12"
         "node": ">=12"
@@ -9310,6 +9441,11 @@
       "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
       "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
       "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
     },
     },
+    "node_modules/merge-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
+    },
     "node_modules/merge2": {
     "node_modules/merge2": {
       "version": "1.4.1",
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
       "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -9853,6 +9989,14 @@
         "node": ">= 0.6"
         "node": ">= 0.6"
       }
       }
     },
     },
+    "node_modules/mimic-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+      "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/mimic-response": {
     "node_modules/mimic-response": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
@@ -10126,6 +10270,17 @@
         "url": "https://github.com/sponsors/sindresorhus"
         "url": "https://github.com/sponsors/sindresorhus"
       }
       }
     },
     },
+    "node_modules/npm-run-path": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+      "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+      "dependencies": {
+        "path-key": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/obj-props": {
     "node_modules/obj-props": {
       "version": "1.4.0",
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/obj-props/-/obj-props-1.4.0.tgz",
       "resolved": "https://registry.npmjs.org/obj-props/-/obj-props-1.4.0.tgz",
@@ -10139,7 +10294,6 @@
       "version": "4.1.1",
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
       "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
       "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
-      "dev": true,
       "engines": {
       "engines": {
         "node": ">=0.10.0"
         "node": ">=0.10.0"
       }
       }
@@ -10268,6 +10422,20 @@
         "wrappy": "1"
         "wrappy": "1"
       }
       }
     },
     },
+    "node_modules/onetime": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+      "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+      "dependencies": {
+        "mimic-fn": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/optionator": {
     "node_modules/optionator": {
       "version": "0.9.1",
       "version": "0.9.1",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
@@ -10293,6 +10461,36 @@
         "node": ">=12.20"
         "node": ">=12.20"
       }
       }
     },
     },
+    "node_modules/p-event": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz",
+      "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==",
+      "dependencies": {
+        "p-timeout": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-finally": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+      "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/p-iteration": {
+      "version": "1.1.8",
+      "resolved": "https://registry.npmjs.org/p-iteration/-/p-iteration-1.1.8.tgz",
+      "integrity": "sha512-IMFBSDIYcPNnW7uWYGrBqmvTiq7W0uB0fJn6shQZs7dlF3OvrHOre+JT9ikSZ7gZS3vWqclVgoQSvToJrns7uQ==",
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
     "node_modules/p-limit": {
     "node_modules/p-limit": {
       "version": "1.3.0",
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
@@ -10317,6 +10515,17 @@
         "node": ">=4"
         "node": ">=4"
       }
       }
     },
     },
+    "node_modules/p-timeout": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+      "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+      "dependencies": {
+        "p-finally": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/p-try": {
     "node_modules/p-try": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
@@ -10630,7 +10839,6 @@
       "version": "3.1.1",
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
       "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
       "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
-      "dev": true,
       "engines": {
       "engines": {
         "node": ">=8"
         "node": ">=8"
       }
       }
@@ -12314,7 +12522,6 @@
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
       "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
       "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
-      "dev": true,
       "dependencies": {
       "dependencies": {
         "shebang-regex": "^3.0.0"
         "shebang-regex": "^3.0.0"
       },
       },
@@ -12326,7 +12533,6 @@
       "version": "3.0.0",
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
       "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
       "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
-      "dev": true,
       "engines": {
       "engines": {
         "node": ">=8"
         "node": ">=8"
       }
       }
@@ -12348,8 +12554,7 @@
     "node_modules/signal-exit": {
     "node_modules/signal-exit": {
       "version": "3.0.7",
       "version": "3.0.7",
       "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
       "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
-      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
-      "dev": true
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
     },
     },
     "node_modules/simple-concat": {
     "node_modules/simple-concat": {
       "version": "1.0.1",
       "version": "1.0.1",
@@ -12645,6 +12850,14 @@
         "node": ">=4"
         "node": ">=4"
       }
       }
     },
     },
+    "node_modules/strip-final-newline": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+      "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/strip-indent": {
     "node_modules/strip-indent": {
       "version": "3.0.0",
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -13146,6 +13359,36 @@
       "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
       "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/tcp-port-used": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.2.tgz",
+      "integrity": "sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==",
+      "dependencies": {
+        "debug": "4.3.1",
+        "is2": "^2.0.6"
+      }
+    },
+    "node_modules/tcp-port-used/node_modules/debug": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+      "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tcp-port-used/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
     "node_modules/text-table": {
     "node_modules/text-table": {
       "version": "0.2.0",
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -13915,7 +14158,6 @@
       "version": "2.0.2",
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
       "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
       "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
-      "dev": true,
       "dependencies": {
       "dependencies": {
         "isexe": "^2.0.0"
         "isexe": "^2.0.0"
       },
       },
@@ -16327,6 +16569,12 @@
         "@types/node": "*"
         "@types/node": "*"
       }
       }
     },
     },
+    "@types/cors": {
+      "version": "2.8.12",
+      "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz",
+      "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==",
+      "dev": true
+    },
     "@types/debug": {
     "@types/debug": {
       "version": "4.1.7",
       "version": "4.1.7",
       "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
       "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
@@ -16487,6 +16735,12 @@
         "@types/node": "*"
         "@types/node": "*"
       }
       }
     },
     },
+    "@types/tcp-port-used": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@types/tcp-port-used/-/tcp-port-used-1.0.1.tgz",
+      "integrity": "sha512-6pwWTx8oUtWvsiZUCrhrK/53MzKVLnuNSSaZILPy3uMes9QnTrLMar9BDlJArbMOjDcjb3QXFk6Rz8qmmuySZw==",
+      "dev": true
+    },
     "@types/unist": {
     "@types/unist": {
       "version": "2.0.6",
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
       "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
@@ -17557,6 +17811,15 @@
       "integrity": "sha512-12VZfFIu+wyVbBebyHmRTuEE/tZrB4tJToWcwAMcsp3h4+sHR+fMJWbKpYiCRWlhFBq+KNyO8rIV9rTkeVmznQ==",
       "integrity": "sha512-12VZfFIu+wyVbBebyHmRTuEE/tZrB4tJToWcwAMcsp3h4+sHR+fMJWbKpYiCRWlhFBq+KNyO8rIV9rTkeVmznQ==",
       "dev": true
       "dev": true
     },
     },
+    "cors": {
+      "version": "2.8.5",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+      "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+      "requires": {
+        "object-assign": "^4",
+        "vary": "^1"
+      }
+    },
     "cosmiconfig": {
     "cosmiconfig": {
       "version": "7.0.0",
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz",
       "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz",
@@ -17613,7 +17876,6 @@
       "version": "7.0.3",
       "version": "7.0.3",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
       "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
       "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
-      "dev": true,
       "requires": {
       "requires": {
         "path-key": "^3.1.0",
         "path-key": "^3.1.0",
         "shebang-command": "^2.0.0",
         "shebang-command": "^2.0.0",
@@ -17733,8 +17995,7 @@
     "deep-is": {
     "deep-is": {
       "version": "0.1.4",
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
-      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
-      "dev": true
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
     },
     },
     "deepmerge": {
     "deepmerge": {
       "version": "4.2.2",
       "version": "4.2.2",
@@ -17742,6 +18003,14 @@
       "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
       "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
       "dev": true
       "dev": true
     },
     },
+    "default-gateway": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz",
+      "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==",
+      "requires": {
+        "execa": "^5.0.0"
+      }
+    },
     "defer-to-connect": {
     "defer-to-connect": {
       "version": "2.0.1",
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
       "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -18320,6 +18589,12 @@
       "dev": true,
       "dev": true,
       "requires": {}
       "requires": {}
     },
     },
+    "eslint-config-react": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/eslint-config-react/-/eslint-config-react-1.1.7.tgz",
+      "integrity": "sha1-oJGND8R9DpvRYaRzCAIdqF0lhbM=",
+      "dev": true
+    },
     "eslint-etc": {
     "eslint-etc": {
       "version": "5.1.0",
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/eslint-etc/-/eslint-etc-5.1.0.tgz",
       "resolved": "https://registry.npmjs.org/eslint-etc/-/eslint-etc-5.1.0.tgz",
@@ -19125,6 +19400,22 @@
       "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
       "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
       "dev": true
       "dev": true
     },
     },
+    "execa": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+      "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+      "requires": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^6.0.0",
+        "human-signals": "^2.1.0",
+        "is-stream": "^2.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^4.0.1",
+        "onetime": "^5.1.2",
+        "signal-exit": "^3.0.3",
+        "strip-final-newline": "^2.0.0"
+      }
+    },
     "execall": {
     "execall": {
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/execall/-/execall-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/execall/-/execall-2.0.0.tgz",
@@ -19769,6 +20060,11 @@
         "resolve-alpn": "^1.2.0"
         "resolve-alpn": "^1.2.0"
       }
       }
     },
     },
+    "human-signals": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+      "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="
+    },
     "iconv-lite": {
     "iconv-lite": {
       "version": "0.4.24",
       "version": "0.4.24",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -19861,6 +20157,24 @@
       "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==",
       "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==",
       "dev": true
       "dev": true
     },
     },
+    "internal-ip": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-7.0.0.tgz",
+      "integrity": "sha512-qE4TeD4brqC45Vq/+VASeMiS1KRyfBkR6HT2sh9pZVVCzSjPkaCEfKFU+dL0PRv7NHJtvoKN2r82G6wTfzorkw==",
+      "requires": {
+        "default-gateway": "^6.0.3",
+        "ipaddr.js": "^2.0.1",
+        "is-ip": "^3.1.0",
+        "p-event": "^4.2.0"
+      },
+      "dependencies": {
+        "ipaddr.js": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz",
+          "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng=="
+        }
+      }
+    },
     "internal-slot": {
     "internal-slot": {
       "version": "1.0.3",
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
       "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
@@ -20157,6 +20471,11 @@
         "call-bind": "^1.0.2"
         "call-bind": "^1.0.2"
       }
       }
     },
     },
+    "is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="
+    },
     "is-string": {
     "is-string": {
       "version": "1.0.7",
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
       "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
@@ -20190,6 +20509,11 @@
         "unc-path-regex": "^0.1.2"
         "unc-path-regex": "^0.1.2"
       }
       }
     },
     },
+    "is-url": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
+      "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="
+    },
     "is-weakref": {
     "is-weakref": {
       "version": "1.0.2",
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
       "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
@@ -20205,11 +20529,20 @@
       "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==",
       "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==",
       "dev": true
       "dev": true
     },
     },
+    "is2": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/is2/-/is2-2.0.7.tgz",
+      "integrity": "sha512-4vBQoURAXC6hnLFxD4VW7uc04XiwTTl/8ydYJxKvPwkWQrSjInkuM5VZVg6BGr1/natq69zDuvO9lGpLClJqvA==",
+      "requires": {
+        "deep-is": "^0.1.3",
+        "ip-regex": "^4.1.0",
+        "is-url": "^1.2.4"
+      }
+    },
     "isexe": {
     "isexe": {
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
-      "dev": true
+      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
     },
     },
     "isomorphic-git": {
     "isomorphic-git": {
       "version": "1.17.0",
       "version": "1.17.0",
@@ -20762,6 +21095,11 @@
       "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
       "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
       "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
     },
     },
+    "merge-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
+    },
     "merge2": {
     "merge2": {
       "version": "1.4.1",
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
       "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -21058,6 +21396,11 @@
         "mime-db": "1.52.0"
         "mime-db": "1.52.0"
       }
       }
     },
     },
+    "mimic-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+      "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
+    },
     "mimic-response": {
     "mimic-response": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
@@ -21273,6 +21616,14 @@
       "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
       "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
       "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="
       "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="
     },
     },
+    "npm-run-path": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+      "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+      "requires": {
+        "path-key": "^3.0.0"
+      }
+    },
     "obj-props": {
     "obj-props": {
       "version": "1.4.0",
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/obj-props/-/obj-props-1.4.0.tgz",
       "resolved": "https://registry.npmjs.org/obj-props/-/obj-props-1.4.0.tgz",
@@ -21282,8 +21633,7 @@
     "object-assign": {
     "object-assign": {
       "version": "4.1.1",
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
-      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
-      "dev": true
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
     },
     },
     "object-inspect": {
     "object-inspect": {
       "version": "1.12.0",
       "version": "1.12.0",
@@ -21373,6 +21723,14 @@
         "wrappy": "1"
         "wrappy": "1"
       }
       }
     },
     },
+    "onetime": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+      "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+      "requires": {
+        "mimic-fn": "^2.1.0"
+      }
+    },
     "optionator": {
     "optionator": {
       "version": "0.9.1",
       "version": "0.9.1",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
@@ -21392,6 +21750,24 @@
       "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
       "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="
       "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="
     },
     },
+    "p-event": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz",
+      "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==",
+      "requires": {
+        "p-timeout": "^3.1.0"
+      }
+    },
+    "p-finally": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+      "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
+    },
+    "p-iteration": {
+      "version": "1.1.8",
+      "resolved": "https://registry.npmjs.org/p-iteration/-/p-iteration-1.1.8.tgz",
+      "integrity": "sha512-IMFBSDIYcPNnW7uWYGrBqmvTiq7W0uB0fJn6shQZs7dlF3OvrHOre+JT9ikSZ7gZS3vWqclVgoQSvToJrns7uQ=="
+    },
     "p-limit": {
     "p-limit": {
       "version": "1.3.0",
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
@@ -21410,6 +21786,14 @@
         "p-limit": "^1.1.0"
         "p-limit": "^1.1.0"
       }
       }
     },
     },
+    "p-timeout": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+      "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+      "requires": {
+        "p-finally": "^1.0.0"
+      }
+    },
     "p-try": {
     "p-try": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
@@ -21669,8 +22053,7 @@
     "path-key": {
     "path-key": {
       "version": "3.1.1",
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
-      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
-      "dev": true
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
     },
     },
     "path-parse": {
     "path-parse": {
       "version": "1.0.7",
       "version": "1.0.7",
@@ -22948,7 +23331,6 @@
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
       "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
       "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
-      "dev": true,
       "requires": {
       "requires": {
         "shebang-regex": "^3.0.0"
         "shebang-regex": "^3.0.0"
       }
       }
@@ -22956,8 +23338,7 @@
     "shebang-regex": {
     "shebang-regex": {
       "version": "3.0.0",
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
-      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
-      "dev": true
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
     },
     },
     "side-channel": {
     "side-channel": {
       "version": "1.0.4",
       "version": "1.0.4",
@@ -22973,8 +23354,7 @@
     "signal-exit": {
     "signal-exit": {
       "version": "3.0.7",
       "version": "3.0.7",
       "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
       "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
-      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
-      "dev": true
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
     },
     },
     "simple-concat": {
     "simple-concat": {
       "version": "1.0.1",
       "version": "1.0.1",
@@ -23192,6 +23572,11 @@
       "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
       "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
       "dev": true
       "dev": true
     },
     },
+    "strip-final-newline": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+      "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="
+    },
     "strip-indent": {
     "strip-indent": {
       "version": "3.0.0",
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -23557,6 +23942,30 @@
         }
         }
       }
       }
     },
     },
+    "tcp-port-used": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.2.tgz",
+      "integrity": "sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==",
+      "requires": {
+        "debug": "4.3.1",
+        "is2": "^2.0.6"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        }
+      }
+    },
     "text-table": {
     "text-table": {
       "version": "0.2.0",
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -24144,7 +24553,6 @@
       "version": "2.0.2",
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
       "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
       "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
-      "dev": true,
       "requires": {
       "requires": {
         "isexe": "^2.0.0"
         "isexe": "^2.0.0"
       }
       }

+ 9 - 1
system-api/package.json

@@ -17,16 +17,22 @@
   "license": "ISC",
   "license": "ISC",
   "dependencies": {
   "dependencies": {
     "compression": "^1.7.4",
     "compression": "^1.7.4",
+    "cors": "^2.8.5",
     "dotenv": "^16.0.0",
     "dotenv": "^16.0.0",
     "express": "^4.17.3",
     "express": "^4.17.3",
     "helmet": "^5.0.2",
     "helmet": "^5.0.2",
+    "internal-ip": "^7.0.0",
     "node-port-scanner": "^3.0.1",
     "node-port-scanner": "^3.0.1",
+    "p-iteration": "^1.1.8",
     "public-ip": "^5.0.0",
     "public-ip": "^5.0.0",
-    "systeminformation": "^5.11.9"
+    "systeminformation": "^5.11.9",
+    "tcp-port-used": "^1.0.2"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/compression": "^1.7.2",
     "@types/compression": "^1.7.2",
+    "@types/cors": "^2.8.12",
     "@types/express": "^4.17.13",
     "@types/express": "^4.17.13",
+    "@types/tcp-port-used": "^1.0.1",
     "@types/validator": "^13.7.2",
     "@types/validator": "^13.7.2",
     "concurrently": "^7.1.0",
     "concurrently": "^7.1.0",
     "esbuild": "^0.14.32",
     "esbuild": "^0.14.32",
@@ -34,8 +40,10 @@
     "eslint-config-airbnb-typescript": "^17.0.0",
     "eslint-config-airbnb-typescript": "^17.0.0",
     "eslint-config-hardcore": "^24.5.0",
     "eslint-config-hardcore": "^24.5.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-config-prettier": "^8.5.0",
+    "eslint-config-react": "^1.1.7",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-prettier": "^4.0.0",
     "eslint-plugin-prettier": "^4.0.0",
+    "eslint-plugin-react": "^7.29.4",
     "eslint-plugin-unicorn": "^42.0.0",
     "eslint-plugin-unicorn": "^42.0.0",
     "nodemon": "^2.0.15",
     "nodemon": "^2.0.15",
     "prettier": "2.6.2"
     "prettier": "2.6.2"

+ 1 - 0
system-api/src/config/apps.ts

@@ -0,0 +1 @@
+export const appNames = ['nextcloud', 'freshrss', 'anonaddy', 'filerun', 'wg-easy', 'radarr', 'transmission', 'jellyfin', 'pihole', 'busybox'];

+ 21 - 2
system-api/src/config/types.ts

@@ -1,15 +1,34 @@
+export enum FieldTypes {
+  text = 'text',
+  password = 'password',
+  email = 'email',
+  number = 'number',
+  fqdn = 'fqdn',
+}
+
 interface FormField {
 interface FormField {
-  type: string;
+  type: FieldTypes;
   label: string;
   label: string;
   max?: number;
   max?: number;
   min?: number;
   min?: number;
   required?: boolean;
   required?: boolean;
-  env_variable?: string;
+  env_variable: string;
 }
 }
 
 
 export interface AppConfig {
 export interface AppConfig {
+  id: string;
+  port: number;
   name: string;
   name: string;
+  requirements?: {
+    ports?: number[];
+  };
   description: string;
   description: string;
   version: string;
   version: string;
+  image: string;
   form_fields: Record<string, FormField>;
   form_fields: Record<string, FormField>;
+  short_desc: string;
+  author: string;
+  source: string;
+  installed: boolean;
+  status: 'running' | 'stopped';
 }
 }

+ 124 - 84
system-api/src/modules/apps/apps.controller.ts

@@ -1,6 +1,9 @@
-import { Request, Response } from 'express';
+import { NextFunction, Request, Response } from 'express';
+import si from 'systeminformation';
+import { appNames } from '../../config/apps';
 import { AppConfig } from '../../config/types';
 import { AppConfig } from '../../config/types';
-import { createFolder, fileExists, readJsonFile, writeFile, copyFile, runScript, deleteFolder } from '../fs/fs.helpers';
+import { createFolder, fileExists, readJsonFile, writeFile, readFile } from '../fs/fs.helpers';
+import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, getInitalFormValues, runAppScript } from './apps.helpers';
 
 
 type AppsState = { installed: string };
 type AppsState = { installed: string };
 
 
@@ -9,14 +12,9 @@ const getStateFile = (): AppsState => {
 };
 };
 
 
 const generateEnvFile = (appName: string, form: Record<string, string>) => {
 const generateEnvFile = (appName: string, form: Record<string, string>) => {
-  const appExists = fileExists(`/app-data/${appName}`);
-
-  if (!appExists) {
-    throw new Error(`App ${appName} not installed`);
-  }
-
   const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
   const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
-  let envFile = '';
+  const baseEnvFile = readFile('/.env').toString();
+  let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
 
 
   Object.keys(configFile.form_fields).forEach((key) => {
   Object.keys(configFile.form_fields).forEach((key) => {
     const value = form[key];
     const value = form[key];
@@ -29,151 +27,191 @@ const generateEnvFile = (appName: string, form: Record<string, string>) => {
     }
     }
   });
   });
 
 
-  writeFile(`/app-data/${appName}/.env`, envFile);
+  writeFile(`/app-data/${appName}/app.env`, envFile);
 };
 };
 
 
-const installApp = (req: Request, res: Response) => {
+const uninstallApp = async (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
-    const { appName, form } = req.body;
+    const { id: appName } = req.params;
 
 
     if (!appName) {
     if (!appName) {
       throw new Error('App name is required');
       throw new Error('App name is required');
     }
     }
 
 
-    const appExists = fileExists(`/app-data/${appName}`);
+    checkAppExists(appName);
+    ensureAppState(appName, false);
 
 
-    if (appExists) {
-      throw new Error(`App ${appName} already installed`);
-    }
+    // Run script
+    await runAppScript(['uninstall', appName]);
 
 
-    // Create app folder
-    createFolder(`/app-data/${appName}`);
-    // Copy default app files from app-data folder
-    copyFile(`/apps/${appName}/data`, `/app-data/${appName}/data`);
+    res.status(200).json({ message: 'App uninstalled successfully' });
+  } catch (e) {
+    next(e);
+  }
+};
 
 
-    // Create env file
-    generateEnvFile(appName, form);
-    const state = getStateFile();
-    state.installed += ` ${appName}`;
-    writeFile('/state/apps.json', JSON.stringify(state));
+const stopApp = async (req: Request, res: Response, next: NextFunction) => {
+  try {
+    const { id: appName } = req.params;
 
 
+    if (!appName) {
+      throw new Error('App name is required');
+    }
+
+    checkAppExists(appName);
     // Run script
     // Run script
-    runScript('/scripts/app.sh', ['install', appName]);
+    await runAppScript(['stop', appName]);
 
 
-    res.status(200).json({ message: 'App installed successfully' });
+    res.status(200).json({ message: 'App stopped successfully' });
   } catch (e) {
   } catch (e) {
-    res.status(500).send(e);
+    next(e);
   }
   }
 };
 };
 
 
-const uninstallApp = (req: Request, res: Response) => {
+const updateAppConfig = async (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
-    const { appName } = req.body;
+    const { id: appName } = req.params;
+    const { form } = req.body;
 
 
     if (!appName) {
     if (!appName) {
       throw new Error('App name is required');
       throw new Error('App name is required');
     }
     }
 
 
-    const appExists = fileExists(`/app-data/${appName}`);
+    checkAppExists(appName);
+    generateEnvFile(appName, form);
+
+    res.status(200).json({ message: 'App updated successfully' });
+  } catch (e) {
+    next(e);
+  }
+};
+
+const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunction) => {
+  try {
+    const { id } = req.params;
 
 
-    if (!appExists) {
-      throw new Error(`App ${appName} not installed`);
+    if (!id) {
+      throw new Error('App name is required');
     }
     }
 
 
-    // Delete app folder
-    deleteFolder(`/app-data/${appName}`);
+    const dockerContainers = await si.dockerContainers();
+    const configFile: AppConfig = readJsonFile(`/apps/${id}/config.json`);
 
 
-    // Remove app from apps.json
     const state = getStateFile();
     const state = getStateFile();
-    state.installed = state.installed.replace(` ${appName}`, '');
-    writeFile('/state/apps.json', JSON.stringify(state));
+    const installed: string[] = state.installed.split(' ').filter(Boolean);
+    configFile.installed = installed.includes(id);
+    configFile.status = (dockerContainers.find((container) => container.name === `${id}`)?.state as 'running') || 'stopped';
 
 
-    // Run script
-    runScript('/scripts/app.sh', ['uninstall', appName]);
+    res.status(200).json(configFile);
+  } catch (e) {
+    next(e);
+  }
+};
 
 
-    res.status(200).json({ message: 'App uninstalled successfully' });
+const listApps = async (req: Request, res: Response, next: NextFunction) => {
+  try {
+    const apps = appNames
+      .map((app) => {
+        try {
+          return readJsonFile(`/apps/${app}/config.json`);
+        } catch {
+          return null;
+        }
+      })
+      .filter(Boolean);
+
+    const dockerContainers = await si.dockerContainers();
+
+    const state = getStateFile();
+    const installed: string[] = state.installed.split(' ').filter(Boolean);
+
+    apps.forEach((app) => {
+      app.installed = installed.includes(app.id);
+      app.status = dockerContainers.find((container) => container.name === `${app.id}`)?.state || 'stopped';
+    });
+
+    res.status(200).json(apps);
   } catch (e) {
   } catch (e) {
-    res.status(500).send(e);
+    next(e);
   }
   }
 };
 };
 
 
-const stopApp = (req: Request, res: Response) => {
+const startApp = async (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
-    const { appName } = req.body;
+    const { id: appName } = req.params;
 
 
     if (!appName) {
     if (!appName) {
       throw new Error('App name is required');
       throw new Error('App name is required');
     }
     }
 
 
-    const appExists = fileExists(`/app-data/${appName}`);
-
-    if (!appExists) {
-      throw new Error(`App ${appName} not installed`);
-    }
+    checkAppExists(appName);
+    checkEnvFile(appName);
 
 
     // Run script
     // Run script
-    runScript('/scripts/app.sh', ['stop', appName]);
+    await runAppScript(['start', appName]);
 
 
-    res.status(200).json({ message: 'App stopped successfully' });
+    ensureAppState(appName, true);
+
+    res.status(200).json({ message: 'App started successfully' });
   } catch (e) {
   } catch (e) {
-    res.status(500).end(e);
+    next(e);
   }
   }
 };
 };
 
 
-const updateAppConfig = (req: Request, res: Response) => {
+const installApp = async (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
-    const { appName, form } = req.body;
+    const { id } = req.params;
+    const { form } = req.body;
 
 
-    if (!appName) {
+    if (!id) {
       throw new Error('App name is required');
       throw new Error('App name is required');
     }
     }
 
 
-    const appExists = fileExists(`/app-data/${appName}`);
+    const appIsAvailable = appNames.includes(id);
 
 
-    if (!appExists) {
-      throw new Error(`App ${appName} not installed`);
+    if (!appIsAvailable) {
+      throw new Error(`App ${id} not available`);
     }
     }
 
 
-    generateEnvFile(appName, form);
+    const appExists = fileExists(`/app-data/${id}`);
 
 
-    // Run script
-    runScript('/scripts/app.sh', ['stop', appName]);
-    runScript('/scripts/app.sh', ['start', appName]);
+    if (appExists) {
+      await startApp(req, res, next);
+    } else {
+      const appIsValid = await checkAppRequirements(id);
 
 
-    res.status(200).json({ message: 'App updated successfully' });
-  } catch (e) {
-    res.status(500).end(e);
-  }
-};
+      if (!appIsValid) {
+        throw new Error(`App ${id} requirements not met`);
+      }
 
 
-const installedApps = (req: Request, res: Response) => {
-  try {
-    const apps = readJsonFile('/state/apps.json');
-    const appNames = apps.installed.split(' ');
+      // Create app folder
+      createFolder(`/app-data/${id}`);
 
 
-    if (appNames.length === 0) {
-      res.status(204).json([]);
-    } else {
-      res.status(200).json(appNames);
+      // Create env file
+      generateEnvFile(id, form);
+      ensureAppState(id, true);
+
+      // Run script
+      await runAppScript(['install', id]);
+
+      res.status(200).json({ message: 'App installed successfully' });
     }
     }
   } catch (e) {
   } catch (e) {
-    res.status(500).end(e);
+    next(e);
   }
   }
 };
 };
 
 
-const getAppInfo = (req: Request, res: Response<AppConfig>) => {
+const initalFormValues = (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
-    const { appName } = req.body;
+    const { id } = req.params;
 
 
-    if (!appName) {
+    if (!id) {
       throw new Error('App name is required');
       throw new Error('App name is required');
     }
     }
 
 
-    const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
-
-    res.status(200).json(configFile);
+    res.status(200).json(getInitalFormValues(id));
   } catch (e) {
   } catch (e) {
-    res.status(500).end(e);
+    next(e);
   }
   }
 };
 };
 
 
@@ -182,8 +220,10 @@ const AppController = {
   installApp,
   installApp,
   stopApp,
   stopApp,
   updateAppConfig,
   updateAppConfig,
-  installedApps,
   getAppInfo,
   getAppInfo,
+  listApps,
+  startApp,
+  initalFormValues,
 };
 };
 
 
 export default AppController;
 export default AppController;

+ 101 - 0
system-api/src/modules/apps/apps.helpers.ts

@@ -0,0 +1,101 @@
+import portUsed from 'tcp-port-used';
+import p from 'p-iteration';
+import { AppConfig } from '../../config/types';
+import { fileExists, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
+import { internalIpV4 } from 'internal-ip';
+
+export const checkAppRequirements = async (appName: string) => {
+  let valid = true;
+  const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
+
+  if (configFile.requirements?.ports) {
+    await p.forEachSeries(configFile.requirements.ports, async (port: number) => {
+      const ip = await internalIpV4();
+      const used = await portUsed.check(port, ip);
+
+      if (used) valid = false;
+    });
+  }
+
+  return valid;
+};
+
+export const getEnvMap = (appName: string): Map<string, string> => {
+  const envFile = readFile(`/app-data/${appName}/app.env`).toString();
+  const envVars = envFile.split('\n');
+  const envVarsMap = new Map<string, string>();
+
+  envVars.forEach((envVar) => {
+    const [key, value] = envVar.split('=');
+    envVarsMap.set(key, value);
+  });
+
+  return envVarsMap;
+};
+
+export const checkEnvFile = (appName: string) => {
+  const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
+  const envMap = getEnvMap(appName);
+
+  Object.keys(configFile.form_fields).forEach((key) => {
+    const envVar = configFile.form_fields[key].env_variable;
+    const envVarValue = envMap.get(envVar);
+
+    if (!envVarValue && configFile.form_fields[key].required) {
+      throw new Error('New info needed. App config needs to be updated');
+    }
+  });
+};
+
+export const getInitalFormValues = (appName: string): Record<string, string> => {
+  const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
+  const envMap = getEnvMap(appName);
+  const formValues: Record<string, string> = {};
+
+  Object.keys(configFile.form_fields).forEach((key) => {
+    const envVar = configFile.form_fields[key].env_variable;
+    const envVarValue = envMap.get(envVar);
+
+    if (envVarValue) {
+      formValues[key] = envVarValue;
+    }
+  });
+
+  return formValues;
+};
+
+export const checkAppExists = (appName: string) => {
+  const appExists = fileExists(`/app-data/${appName}`);
+
+  if (!appExists) {
+    throw new Error(`App ${appName} not installed`);
+  }
+};
+
+export const runAppScript = (params: string[]): Promise<void> => {
+  return new Promise((resolve, reject) => {
+    runScript('/scripts/app.sh', params, (err: string) => {
+      if (err) {
+        reject(err);
+      }
+
+      resolve();
+    });
+  });
+};
+
+export const ensureAppState = (appName: string, installed: boolean) => {
+  const state = readJsonFile('/state/apps.json');
+
+  if (installed) {
+    if (state.installed.indexOf(appName) === -1) {
+      state.installed += ` ${appName}`;
+      writeFile('/state/apps.json', JSON.stringify(state));
+    }
+  } else {
+    if (state.installed.indexOf(appName) !== -1) {
+      state.installed = state.installed.replace(` ${appName}`, '');
+      writeFile('/state/apps.json', JSON.stringify(state));
+    }
+  }
+};

+ 8 - 3
system-api/src/modules/apps/apps.routes.ts

@@ -3,8 +3,13 @@ import AppController from './apps.controller';
 
 
 const router = Router();
 const router = Router();
 
 
-router.route('/install').post(AppController.installApp);
-router.route('/uninstall').post(AppController.uninstallApp);
-router.route('/list').get(AppController.installedApps);
+router.route('/install/:id').post(AppController.installApp);
+router.route('/update/:id').post(AppController.updateAppConfig);
+router.route('/uninstall/:id').get(AppController.uninstallApp);
+router.route('/stop/:id').get(AppController.stopApp);
+router.route('/start/:id').get(AppController.startApp);
+router.route('/list').get(AppController.listApps);
+router.route('/info/:id').get(AppController.getAppInfo);
+router.route('/form/:id').get(AppController.initalFormValues);
 
 
 export default router;
 export default router;

+ 3 - 1
system-api/src/modules/fs/fs.helpers.ts

@@ -9,6 +9,8 @@ export const readJsonFile = (path: string): any => {
   return JSON.parse(rawFile);
   return JSON.parse(rawFile);
 };
 };
 
 
+export const readFile = (path: string): string => fs.readFileSync(getAbsolutePath(path)).toString();
+
 export const fileExists = (path: string): boolean => fs.existsSync(getAbsolutePath(path));
 export const fileExists = (path: string): boolean => fs.existsSync(getAbsolutePath(path));
 
 
 export const writeFile = (path: string, data: any) => fs.writeFileSync(getAbsolutePath(path), data);
 export const writeFile = (path: string, data: any) => fs.writeFileSync(getAbsolutePath(path), data);
@@ -18,4 +20,4 @@ export const deleteFolder = (path: string) => fs.rmSync(getAbsolutePath(path), {
 
 
 export const copyFile = (source: string, destination: string) => fs.copyFileSync(getAbsolutePath(source), getAbsolutePath(destination));
 export const copyFile = (source: string, destination: string) => fs.copyFileSync(getAbsolutePath(source), getAbsolutePath(destination));
 
 
-export const runScript = (path: string, args: string[]) => childProcess.spawnSync(getAbsolutePath(path), args, {});
+export const runScript = (path: string, args: string[], callback?: any) => childProcess.execFile(getAbsolutePath(path), args, {}, callback);

部分文件因为文件数量过多而无法显示