Browse Source

Merge pull request #11 from meienberger/develop

Release 0.0.1
Nicolas Meienberger 3 years ago
parent
commit
2bd5d1e28e
100 changed files with 1683 additions and 291 deletions
  1. 9 1
      .gitignore
  2. 2 0
      README.md
  3. 2 3
      ansible/host_vars/tipi.yml
  4. 0 5
      ansible/setup.yml
  5. 6 0
      ansible/stop.yml
  6. 26 1
      ansible/tasks/common/docker.yml
  7. 8 12
      ansible/tasks/common/essential.yml
  8. 4 4
      ansible/tasks/common/system-api.yml
  9. 9 0
      ansible/tasks/common/teardown.yml
  10. 2 2
      ansible/templates/avahi/tipi.service
  11. 45 0
      apps/anonaddy/config.json
  12. 14 11
      apps/anonaddy/docker-compose.yml
  13. 0 1
      apps/docker-compose.common.yml
  14. 12 0
      apps/filebrowser/config.json
  15. 0 0
      apps/filebrowser/data/filebrowser.db
  16. 8 0
      apps/filebrowser/data/settings.json
  17. 15 0
      apps/filebrowser/docker-compose.yml
  18. 11 0
      apps/filerun/config.json
  19. 38 0
      apps/filerun/docker-compose.yml
  20. 11 0
      apps/freshrss/config.json
  21. 10 10
      apps/freshrss/docker-compose.yml
  22. 11 0
      apps/invidious/config.json
  23. 45 0
      apps/invidious/docker-compose.yml
  24. 13 0
      apps/jackett/config.json
  25. 20 0
      apps/jackett/docker-compose.yml
  26. 11 0
      apps/jellyfin/config.json
  27. 0 0
      apps/jellyfin/data/config/.gitkeep
  28. 18 0
      apps/jellyfin/docker-compose.yml
  29. 13 0
      apps/joplin/README.md
  30. 12 0
      apps/joplin/config.json
  31. 38 0
      apps/joplin/docker-compose.yml
  32. 12 0
      apps/n8n/config.json
  33. 36 0
      apps/n8n/docker-compose.yml
  34. 8 2
      apps/nextcloud/config.json
  35. 15 19
      apps/nextcloud/docker-compose.yml
  36. 0 24
      apps/pi-hole/data/unbound/unbound.conf
  37. 0 34
      apps/pi-hole/docker-compose.yml
  38. 23 0
      apps/pihole/config.json
  39. 6 0
      apps/pihole/data/unbound/a-records.conf
  40. 92 0
      apps/pihole/data/unbound/root.hints
  41. 9 0
      apps/pihole/data/unbound/root.key
  42. 136 0
      apps/pihole/data/unbound/unbound.conf
  43. 39 0
      apps/pihole/docker-compose.yml
  44. 20 0
      apps/radarr/config.json
  45. 20 0
      apps/radarr/docker-compose.yml
  46. 11 0
      apps/simple-torrent/config.json
  47. 2 2
      apps/simple-torrent/docker-compose.yml
  48. 13 0
      apps/sonarr/config.json
  49. 20 0
      apps/sonarr/docker-compose.yml
  50. 12 0
      apps/syncthing/config.json
  51. 0 0
      apps/syncthing/data/.gitkeep
  52. 21 0
      apps/syncthing/docker-compose.yml
  53. 12 0
      apps/tailscale/config.json
  54. 14 0
      apps/tailscale/docker-compose.yml
  55. 0 6
      apps/test/Dockerfile
  56. 0 8
      apps/test/docker-compose.yml
  57. 31 0
      apps/transmission/config.json
  58. 0 0
      apps/transmission/data/config/.gitkeep
  59. 24 0
      apps/transmission/docker-compose.yml
  60. 29 0
      apps/ttyd/config.json
  61. 0 0
      apps/ttyd/docker-compose.yml
  62. 35 0
      apps/wg-easy/config.json
  63. 0 0
      apps/wg-easy/data/.gitkeep
  64. 34 27
      apps/wg-easy/docker-compose.yml
  65. 2 0
      dashboard/.eslintignore
  66. 17 0
      dashboard/.eslintrc.js
  67. 0 3
      dashboard/.eslintrc.json
  68. 6 0
      dashboard/.prettierrc.js
  69. 3 0
      dashboard/Dockerfile
  70. 12 0
      dashboard/Dockerfile.dev
  71. 7 2
      dashboard/next.config.js
  72. 17 1
      dashboard/package.json
  73. 6 0
      dashboard/postcss.config.js
  74. BIN
      dashboard/public/android-chrome-192x192.png
  75. BIN
      dashboard/public/android-chrome-512x512.png
  76. BIN
      dashboard/public/apple-touch-icon.png
  77. 9 0
      dashboard/public/browserconfig.xml
  78. BIN
      dashboard/public/favicon-16x16.png
  79. BIN
      dashboard/public/favicon-32x32.png
  80. BIN
      dashboard/public/favicon.ico
  81. 0 4
      dashboard/public/logo.svg
  82. BIN
      dashboard/public/mstile-150x150.png
  83. 20 0
      dashboard/public/safari-pinned-tab.svg
  84. 19 0
      dashboard/public/site.webmanifest
  85. BIN
      dashboard/public/tipi.png
  86. 23 0
      dashboard/src/components/AppTile/AppStatus.tsx
  87. 32 0
      dashboard/src/components/AppTile/index.tsx
  88. 25 0
      dashboard/src/components/Form/FormInput.tsx
  89. 28 0
      dashboard/src/components/Form/validators.ts
  90. 10 15
      dashboard/src/components/Layout/Header.tsx
  91. 62 25
      dashboard/src/components/Layout/Layout.tsx
  92. 66 6
      dashboard/src/components/Layout/Menu.tsx
  93. 8 17
      dashboard/src/components/Layout/MenuDrawer.tsx
  94. 1 1
      dashboard/src/components/Layout/index.ts
  95. 12 0
      dashboard/src/components/LoadingScreen.tsx
  96. 43 45
      dashboard/src/constants/apps.ts
  97. 32 0
      dashboard/src/core/api.ts
  98. 9 0
      dashboard/src/core/fetcher.ts
  99. 57 0
      dashboard/src/core/types.ts
  100. 70 0
      dashboard/src/modules/Apps/components/AppActions.tsx

+ 9 - 1
.gitignore

@@ -13,4 +13,12 @@ state/*
 tipi.config.json
 
 # Commit empty directories
-!nignx/.gitkeep
+!nignx/.gitkeep
+
+media/data/movies/*
+media/data/tv/*
+!media/data/movies/.gitkeep
+!media/data/tv/.gitkeep
+
+media/torrents/*
+!media/torrents/.gitkeep

+ 2 - 0
README.md

@@ -3,3 +3,5 @@
 ![RunsOn](https://img.shields.io/badge/Ubuntu-Supported-green?logo=ubuntu&style=flat-square)
 ![RunsOn](https://img.shields.io/badge/Arch-Not%20Supported-red?logo=archlinux&style=flat-square)
 ![RunsOn](https://img.shields.io/badge/Fedora-Not%20Supported-red?logo=fedora&style=flat-square)
+
+![Preview](https://raw.githubusercontent.com/meienberger/runtipi/develop/screenshots/1.png)

+ 2 - 3
ansible/host_vars/tipi.yml

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

+ 0 - 5
ansible/setup.yml

@@ -7,10 +7,5 @@
     - import_tasks: ./tasks/common/zsh.yml
     - import_tasks: ./tasks/common/docker.yml
     - import_tasks: ./tasks/network/avahi.yml
-    # - import_tasks: tasks/zsh.yml
-    # - import_tasks: tasks/nginx.yml
-    # - import_tasks: tasks/pi-hole.yml
-    # - import_tasks: tasks/pi-vpn.yml
-    # - import_tasks: tasks/nextcloud.yml
     # - name: Reboot machine
     #   reboot:

+ 6 - 0
ansible/stop.yml

@@ -0,0 +1,6 @@
+---
+- hosts: tipi
+  become: yes
+
+  tasks:
+    - import_tasks: ./tasks/common/teardown.yml

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

@@ -1,8 +1,33 @@
 - name: Install docker
   package:
-    name: docker
+    name:
+      - docker
+      - ca-certificates
+      - curl
+      - gnupg
+      - lsb-release
     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
   package:
     name:

+ 8 - 12
ansible/tasks/common/essential.yml

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

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

@@ -1,6 +1,6 @@
 - name: Install "pm2" package globally.
   community.general.npm:
-    name: yarn
+    name: pm2
     global: yes
 
 - name: Run pm2 first time
@@ -29,15 +29,15 @@
 
 - name: Check if app is already running
   become_user: "{{ username }}"
-  shell: pm2 list
+  shell: pm2 status system-api
   register: pm2_result
 
 - name: Start app
   become_user: "{{ username }}"
   shell: cd {{ playbook_dir }}/../system-api && pm2 start npm --name "system-api" -- start
-  when: pm2_result.stdout.find("system-api") == -1
+  when: pm2_result.stdout.find("online") == -1
 
 - name: Reload app
   become_user: "{{ username }}"
   shell: pm2 reload system-api
-  when: pm2_result.stdout.find("system-api") != -1
+  when: pm2_result.stdout.find("online") != -1

+ 9 - 0
ansible/tasks/common/teardown.yml

@@ -0,0 +1,9 @@
+- name: Check if app is already running
+  become_user: "{{ username }}"
+  shell: pm2 list
+  register: pm2_result
+
+- name: Stop app
+  become_user: "{{ username }}"
+  shell: pm2 stop "system-api"
+  when: pm2_result.stdout.find("system-api") != -1

+ 2 - 2
ansible/templates/avahi/tipi.service

@@ -7,10 +7,10 @@
     <port>80</port>
   </service>
 </service-group>
-<service-group>
+<!-- <service-group>
   <name replace-wildcards="yes">%h</name>
   <service>
     <type>_http._tcp</type>
     <port>443</port>
   </service>
-</service-group>
+</service-group> -->

+ 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
     ports:
       - 25:25
-      - ${APP_ANONADDY_PORT}:8000
+      - ${APP_PORT}:8000
     depends_on:
       - db
       - redis
     volumes:
       - "${APP_DATA_DIR}/data:/data"
     environment:
+      TZ: ${TZ}
       DB_HOST: db-anonaddy
       DB_PASSWORD: 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
     networks:
       - 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:
     #     traefik.enable: true
     #     traefik.http.routers.anonaddy.rule: PathPrefix(`/anonaddy`)

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

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

+ 12 - 0
apps/filebrowser/config.json

@@ -0,0 +1,12 @@
+{
+  "name": "File Browser",
+  "port": 8096,
+  "id": "filebrowser",
+  "description": "Reliable and Performant File Management Desktop Sync and File Sharing",
+  "short_desc": "Access your homeserver files from your browser",
+  "author": "",
+  "website": "https://filebrowser.org/",
+  "source": "https://github.com/filebrowser/filebrowser",
+  "image": "https://avatars.githubusercontent.com/u/35781395?s=200&v=4",
+  "form_fields": {}
+}

+ 0 - 0
ansible/tasks/install_app.yml → apps/filebrowser/data/filebrowser.db


+ 8 - 0
apps/filebrowser/data/settings.json

@@ -0,0 +1,8 @@
+{
+  "port": 80,
+  "baseURL": "",
+  "address": "",
+  "log": "stdout",
+  "database": "/database/filebrowser.db",
+  "root": "/srv"
+}

+ 15 - 0
apps/filebrowser/docker-compose.yml

@@ -0,0 +1,15 @@
+services:
+  filebrowser:
+    container_name: filebrowser
+    image: filebrowser/filebrowser:s6
+    ports:
+      - ${APP_PORT}:80
+    environment:
+      - PUID=1000
+      - PGID=1000
+    volumes:
+      - ${ROOT_FOLDER}:/srv
+      - ${APP_DATA_DIR}/data/filebrowser.db:/database/filebrowser.db
+      - ${APP_DATA_DIR}/data/settings.json:/config/settings.json
+    networks:
+      - 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
+    user: 1000:1000
+    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: 1000
+      APACHE_RUN_GROUP: 1000
+      APACHE_RUN_USER_ID: 33
+      APACHE_RUN_GROUP_ID: 33
+    depends_on:
+      - db
+    links:
+      - db:db
+    ports:
+      - ${APP_PORT}:80
+    volumes:
+      - ${ROOT_FOLDER}/app-data/medias:/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
     restart: unless-stopped
     ports:
-      - "${APP_FRESHRSS_PORT}:80"
+      - ${APP_PORT}:80
     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:
       CRON_MIN: '*/20'
       TZ: $TZ
     networks:
       - 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/invidious/config.json

@@ -0,0 +1,11 @@
+{
+  "name": "Invidious",
+  "port": 8095,
+  "id": "invidious",
+  "description": "",
+  "short_desc": "",
+  "author": "",
+  "source": "https://github.com/iv-org/invidious",
+  "image": "https://raw.githubusercontent.com/iv-org/invidious/master/assets/invidious-colored-vector.svg",
+  "form_fields": {}
+}

+ 45 - 0
apps/invidious/docker-compose.yml

@@ -0,0 +1,45 @@
+version: "3"
+services:
+  invidious:
+    user: 1000:1000
+    container_name: invidious
+    image: quay.io/invidious/invidious:latest-arm64
+    # image: quay.io/invidious/invidious:latest-arm64 # ARM64/AArch64 devices
+    restart: unless-stopped
+    ports:
+      - "${APP_PORT}:3000"
+    environment:
+      # Please read the following file for a comprehensive list of all available
+      # configuration options and their associated syntax:
+      # https://github.com/iv-org/invidious/blob/master/config/config.example.yml
+      INVIDIOUS_CONFIG: |
+        db:
+          dbname: invidious
+          user: tipi
+          password: tipi
+          host: invidious-db
+          port: 5432
+        check_tables: true
+    healthcheck:
+      test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1
+      interval: 30s
+      timeout: 5s
+      retries: 2
+    depends_on:
+      - invidious-db
+
+  invidious-db:
+    user: 1000:1000
+    container_name: invidious-db
+    image: docker.io/library/postgres:14
+    restart: unless-stopped
+    volumes:
+      - ${APP_DATA_DIR}/data/postgres:/var/lib/postgresql/data
+      - ${APP_DATA_DIR}/data/sql:/config/sql
+      - ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
+    environment:
+      POSTGRES_DB: invidious
+      POSTGRES_USER: tipi
+      POSTGRES_PASSWORD: tipi
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]

+ 13 - 0
apps/jackett/config.json

@@ -0,0 +1,13 @@
+{
+  "name": "Jackett",
+  "port": 8097,
+  "id": "jackett",
+  "description": "Jackett works as a proxy server: it translates queries from apps (Sonarr, Radarr, SickRage, CouchPotato, Mylar3, Lidarr, DuckieTV, qBittorrent, Nefarious etc.) into tracker-site-specific http queries, parses the html or json response, and then sends results back to the requesting software. This allows for getting recent uploads (like RSS) and performing searches.",
+  "short_desc": "API Support for your favorite torrent trackers ",
+  "author": "",
+  "source": "https://github.com/Jackett/Jackett",
+  "image": "https://avatars.githubusercontent.com/u/15383019?s=200&v=4",
+  "form_fields": {
+    
+  }
+}

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

@@ -0,0 +1,20 @@
+version: "3.7"
+services:
+  jackett:
+    image: lscr.io/linuxserver/jackett
+    container_name: jackett
+    environment:
+      - PUID=1000
+      - PGID=1000
+      - TZ=${TZ}
+      - AUTO_UPDATE=true
+    dns:
+      - ${DNS_IP}
+    volumes:
+      - ${APP_DATA_DIR}/data:/config
+      - ${ROOT_FOLDER}/media/torrents:/downloads
+    ports:
+      - ${APP_PORT}:9117
+    restart: unless-stopped
+    networks:
+      - tipi_main_network

+ 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


+ 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
+      - ${ROOT_FOLDER}/media/data:/data/media
+    environment:
+      - PUID=1000
+      - PGID=1000
+      - TZ=${TZ}
+    restart: "unless-stopped"
+    ports:
+      - ${APP_PORT}:8096
+    networks:
+      - tipi_main_network

+ 13 - 0
apps/joplin/README.md

@@ -0,0 +1,13 @@
+Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in Markdown format.
+Notes exported from Evernote can be imported into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.
+
+The notes can be securely synchronised using end-to-end encryption with various cloud services including Nextcloud, Dropbox, OneDrive and Joplin Cloud.
+
+Full text search is available on all platforms to quickly find the information you need. The app can be customised using plugins and themes, and you can also easily create your own.
+
+The application is available for Windows, Linux, macOS, Android and iOS. A Web Clipper, to save web pages and screenshots from your browser, is also available for Firefox and Chrome.
+
+## Credentials
+
+Username: admin@localhost
+Password: admin

+ 12 - 0
apps/joplin/config.json

@@ -0,0 +1,12 @@
+{
+  "name": "Joplin Server",
+  "port": 8099,
+  "id": "joplin",
+  "description": "",
+  "short_desc": "Note taking and to-do application with synchronisation",
+  "author": "https://github.com/laurent22",
+  "source": "https://github.com/laurent22/joplin",
+  "website": "https://joplinapp.org",
+  "image": "https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png",
+  "form_fields": {}
+}

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

@@ -0,0 +1,38 @@
+version: "3.7"
+
+services:
+  db-joplin:
+    container_name: db-joplin
+    image: postgres:14.2
+    volumes:
+      - ${APP_DATA_DIR}/data/postgres:/var/lib/postgresql/data
+    restart: unless-stopped
+    environment:
+      - POSTGRES_PASSWORD=tipi
+      - POSTGRES_USER=tipi
+      - POSTGRES_DB=joplin
+    networks:
+      - tipi_main_network
+
+  joplin:
+    container_name: joplin
+    image: florider89/joplin-server:2.7.4
+    restart: unless-stopped
+    depends_on:
+      - db-joplin
+    ports:
+      - ${APP_PORT}:22300
+    dns:
+      - ${DNS_IP}
+    environment:
+      - APP_PORT=22300
+      - APP_BASE_URL=http://${INTERNAL_IP}:${APP_PORT}
+      - DB_CLIENT=pg
+      - POSTGRES_PASSWORD=tipi
+      - POSTGRES_USER=tipi
+      - POSTGRES_DATABASE=joplin
+      - POSTGRES_PORT=5432
+      - POSTGRES_HOST=db-joplin
+      - MAX_TIME_DRIFT=0
+    networks:
+      - tipi_main_network

+ 12 - 0
apps/n8n/config.json

@@ -0,0 +1,12 @@
+{
+  "name": "n8n",
+  "port": 8094,
+  "id": "n8n",
+  "description": "n8n is an extendable workflow automation tool. With a fair-code distribution model, n8n will always have visible source code, be available to self-host, and allow you to add your own custom functions, logic and apps. n8n's node-based approach makes it highly versatile, enabling you to connect anything to everything.",
+  "short_desc": "Workflow Automation Tool. Alternative to Zapier",
+  "author": "n8n.io",
+  "source": "https://github.com/n8n-io/n8n",
+  "website": "https://n8n.io/",
+  "image": "https://avatars.githubusercontent.com/u/45487711?s=200&v=4",
+  "form_fields": {}
+}

+ 36 - 0
apps/n8n/docker-compose.yml

@@ -0,0 +1,36 @@
+version: "3.7"
+
+services:
+  db-n8n:
+    container_name: db-n8n
+    image: postgres:14.2
+    restart: on-failure
+    volumes:
+      - ${APP_DATA_DIR}/data/db:/var/lib/postgresql/data
+    environment:
+      - POSTGRES_PASSWORD=tipi
+      - POSTGRES_USER=tipi
+      - POSTGRES_DB=n8n
+    networks:
+      - tipi_main_network
+
+  n8n:
+    container_name: n8n
+    image: n8nio/n8n:0.174.0
+    restart: unless-stopped
+    ports:
+      - ${APP_PORT}:5678
+    volumes:
+      - ${APP_DATA_DIR}/data/n8n:/home/node/.n8n
+    command: /bin/sh -c "sleep 5; n8n start"
+    environment:
+      - DB-TYPE=postgresdb
+      - DB_POSTGRESDB_DATABASE=n8n
+      - DB_POSTGRESDB_HOST=db-n8n
+      - DB_POSTGRESDB_PORT=5432
+      - DB_POSTGRESDB_USER=tipi
+      - DB_POSTGRESDB_PASSWORD=tipi
+    depends_on:
+      - db-n8n
+    networks:
+      - tipi_main_network

+ 8 - 2
apps/nextcloud/config.json

@@ -1,6 +1,12 @@
 {
   "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.",
+  "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": {
     "username": {
       "type": "text",
@@ -8,7 +14,7 @@
       "max": 50,
       "min": 3,
       "required": true,
-      "env_variable": "NEXTCLOUD_USERNAME"
+      "env_variable": "NEXTCLOUD_ADMIN_USER"
     },
     "password": {
       "type": "password",
@@ -16,7 +22,7 @@
       "max": 50,
       "min": 3,
       "required": true,
-      "env_variable": "NEXTCLOUD_PASSWORD"
+      "env_variable": "NEXTCLOUD_ADMIN_PASSWORD"
     }
   }
 }

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

@@ -3,24 +3,21 @@ version: "3.7"
 services:
   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
     volumes:
-      - ${APP_DATA_DIR}/data/db:/var/lib/mysql
+      - ${APP_DATA_DIR}/data/db:/var/lib/postgresql/data
     environment:
-      - MYSQL_ROOT_PASSWORD=password
-      - MYSQL_PASSWORD=password
-      - MYSQL_DATABASE=nextcloud
-      - MYSQL_USER=nextcloud
+      - POSTGRES_PASSWORD=tipi
+      - POSTGRES_USER=tipi
+      - POSTGRES_DB=nextcloud
     networks:
       - tipi_main_network
 
   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
     volumes:
       - "${APP_DATA_DIR}/data/redis:/data"
@@ -28,7 +25,7 @@ services:
       - tipi_main_network
 
   cron:
-    image: nextcloud:22.0.0-apache
+    image: nextcloud:23.0.3-apache
     restart: on-failure
     volumes:
       - ${APP_DATA_DIR}/data/nextcloud:/var/www/html
@@ -40,23 +37,22 @@ services:
       - tipi_main_network
 
   nextcloud:
-    user: root
     container_name: nextcloud
-    image: nextcloud:22.1.1-apache
+    image: nextcloud:23.0.3-apache
     restart: unless-stopped
     ports:
-      - ${APP_NEXTCLOUD_PORT}:80
+      - ${APP_PORT}:80
     volumes:
       - ${APP_DATA_DIR}/data/nextcloud:/var/www/html
     environment:
-      - MYSQL_HOST=db-nextcloud
+      - POSTGRES_HOST=db-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_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD}
-      - NEXTCLOUD_TRUSTED_DOMAINS=tipi.local
+      - NEXTCLOUD_TRUSTED_DOMAINS=${DEVICE_IP}:${APP_PORT}
     depends_on:
       - db-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"
+        }
+    }
+}

+ 6 - 0
apps/pihole/data/unbound/a-records.conf

@@ -0,0 +1,6 @@
+
+# A Record
+	#local-data: "somecomputer.local. A 192.168.1.1"
+
+# PTR Record
+	#local-data-ptr: "192.168.1.1 somecomputer.local."

+ 92 - 0
apps/pihole/data/unbound/root.hints

@@ -0,0 +1,92 @@
+;       This file holds the information on root name servers needed to 
+;       initialize cache of Internet domain name servers
+;       (e.g. reference this file in the "cache  .  <file>"
+;       configuration file of BIND domain name servers). 
+; 
+;       This file is made available by InterNIC 
+;       under anonymous FTP as
+;           file                /domain/named.cache 
+;           on server           FTP.INTERNIC.NET
+;       -OR-                    RS.INTERNIC.NET
+;
+;       last update:     December 07, 2021
+;       related version of root zone:     2021120701
+; 
+; FORMERLY NS.INTERNIC.NET 
+;
+.                        3600000      NS    A.ROOT-SERVERS.NET.
+A.ROOT-SERVERS.NET.      3600000      A     198.41.0.4
+A.ROOT-SERVERS.NET.      3600000      AAAA  2001:503:ba3e::2:30
+; 
+; FORMERLY NS1.ISI.EDU 
+;
+.                        3600000      NS    B.ROOT-SERVERS.NET.
+B.ROOT-SERVERS.NET.      3600000      A     199.9.14.201
+B.ROOT-SERVERS.NET.      3600000      AAAA  2001:500:200::b
+; 
+; FORMERLY C.PSI.NET 
+;
+.                        3600000      NS    C.ROOT-SERVERS.NET.
+C.ROOT-SERVERS.NET.      3600000      A     192.33.4.12
+C.ROOT-SERVERS.NET.      3600000      AAAA  2001:500:2::c
+; 
+; FORMERLY TERP.UMD.EDU 
+;
+.                        3600000      NS    D.ROOT-SERVERS.NET.
+D.ROOT-SERVERS.NET.      3600000      A     199.7.91.13
+D.ROOT-SERVERS.NET.      3600000      AAAA  2001:500:2d::d
+; 
+; FORMERLY NS.NASA.GOV
+;
+.                        3600000      NS    E.ROOT-SERVERS.NET.
+E.ROOT-SERVERS.NET.      3600000      A     192.203.230.10
+E.ROOT-SERVERS.NET.      3600000      AAAA  2001:500:a8::e
+; 
+; FORMERLY NS.ISC.ORG
+;
+.                        3600000      NS    F.ROOT-SERVERS.NET.
+F.ROOT-SERVERS.NET.      3600000      A     192.5.5.241
+F.ROOT-SERVERS.NET.      3600000      AAAA  2001:500:2f::f
+; 
+; FORMERLY NS.NIC.DDN.MIL
+;
+.                        3600000      NS    G.ROOT-SERVERS.NET.
+G.ROOT-SERVERS.NET.      3600000      A     192.112.36.4
+G.ROOT-SERVERS.NET.      3600000      AAAA  2001:500:12::d0d
+; 
+; FORMERLY AOS.ARL.ARMY.MIL
+;
+.                        3600000      NS    H.ROOT-SERVERS.NET.
+H.ROOT-SERVERS.NET.      3600000      A     198.97.190.53
+H.ROOT-SERVERS.NET.      3600000      AAAA  2001:500:1::53
+; 
+; FORMERLY NIC.NORDU.NET
+;
+.                        3600000      NS    I.ROOT-SERVERS.NET.
+I.ROOT-SERVERS.NET.      3600000      A     192.36.148.17
+I.ROOT-SERVERS.NET.      3600000      AAAA  2001:7fe::53
+; 
+; OPERATED BY VERISIGN, INC.
+;
+.                        3600000      NS    J.ROOT-SERVERS.NET.
+J.ROOT-SERVERS.NET.      3600000      A     192.58.128.30
+J.ROOT-SERVERS.NET.      3600000      AAAA  2001:503:c27::2:30
+; 
+; OPERATED BY RIPE NCC
+;
+.                        3600000      NS    K.ROOT-SERVERS.NET.
+K.ROOT-SERVERS.NET.      3600000      A     193.0.14.129
+K.ROOT-SERVERS.NET.      3600000      AAAA  2001:7fd::1
+; 
+; OPERATED BY ICANN
+;
+.                        3600000      NS    L.ROOT-SERVERS.NET.
+L.ROOT-SERVERS.NET.      3600000      A     199.7.83.42
+L.ROOT-SERVERS.NET.      3600000      AAAA  2001:500:9f::42
+; 
+; OPERATED BY WIDE
+;
+.                        3600000      NS    M.ROOT-SERVERS.NET.
+M.ROOT-SERVERS.NET.      3600000      A     202.12.27.33
+M.ROOT-SERVERS.NET.      3600000      AAAA  2001:dc3::35
+; End of file

+ 9 - 0
apps/pihole/data/unbound/root.key

@@ -0,0 +1,9 @@
+; autotrust trust anchor file
+;;id: . 1
+;;last_queried: 1650921300 ;;Mon Apr 25 21:15:00 2022
+;;last_success: 1650921300 ;;Mon Apr 25 21:15:00 2022
+;;next_probe_time: 1650962281 ;;Tue Apr 26 08:38:01 2022
+;;query_failed: 0
+;;query_interval: 43200
+;;retry_time: 8640
+.	86400	IN	DNSKEY	257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU= ;{id = 20326 (ksk), size = 2048b} ;;state=2 [  VALID  ] ;;count=0 ;;lastchange=1650921210 ;;Mon Apr 25 21:13:30 2022

+ 136 - 0
apps/pihole/data/unbound/unbound.conf

@@ -0,0 +1,136 @@
+# https://linux.die.net/man/5/unbound.conf
+# https://docs.pi-hole.net/guides/unbound/
+
+server:
+    # Enable or disable whether the unbound server forks into the background
+    # as a daemon. Default is yes.
+    do-daemonize: no
+
+    # If given, after binding the port the user privileges are dropped.
+    # Default is "unbound". If you give username: "" no user change is performed.
+    username: ""
+
+    # No need to chroot as this container has been stripped of all other binaries.
+    chroot: ""
+
+    # If "" is given, logging goes to stderr, or nowhere once daemonized.
+    logfile: ""
+
+    # The process id is written to the file. Not required since we are running
+    # in a container with one process.
+    pidfile: ""
+
+    # The verbosity number, level 0 means no verbosity, only errors.
+    # Level 1 gives operational information.
+    # Level 2 gives detailed operational information.
+    # Level 3 gives query level information, output per query.
+    # Level 4 gives algorithm level information.
+    # Level 5 logs client identification for cache misses.
+    # Default is level 1. The verbosity can also be increased from the commandline.
+    verbosity: 1
+
+    # Listen on all ipv4 interfaces, answer queries from the local subnet.
+    interface: 0.0.0.0
+
+    # The port number, default 53, on which the server responds to queries.
+    port: 53
+
+    do-ip4: yes
+    do-udp: yes
+    do-tcp: yes
+    do-ip6: no
+
+    # You want to leave this to no unless you have *native* IPv6. With 6to4 and
+    # Terredo tunnels your web browser should favor IPv4 for the same reasons
+    prefer-ip6: no
+
+    # Trust glue only if it is within the server's authority
+    harden-glue: yes
+
+    # Require DNSSEC data for trust-anchored zones, if such data is absent, the zone becomes BOGUS
+    harden-dnssec-stripped: yes
+
+    # Don't use Capitalization randomization as it known to cause DNSSEC issues sometimes
+    # see https://discourse.pi-hole.net/t/unbound-stubby-or-dnscrypt-proxy/9378 for further details
+    use-caps-for-id: no
+
+    # Reduce EDNS reassembly buffer size (see also https://docs.pi-hole.net/guides/dns/unbound/ )
+    # IP fragmentation is unreliable on the Internet today, and can cause
+    # transmission failures when large DNS messages are sent via UDP. Even
+    # when fragmentation does work, it may not be secure; it is theoretically
+    # possible to spoof parts of a fragmented DNS message, without easy
+    # detection at the receiving end. Recently, there was an excellent study
+    # >>> Defragmenting DNS - Determining the optimal maximum UDP response size for DNS <<<
+    # by Axel Koolhaas, and Tjeerd Slokker (https://indico.dns-oarc.net/event/36/contributions/776/)
+    # in collaboration with NLnet Labs explored DNS using real world data from the
+    # the RIPE Atlas probes and the researchers suggested different values for
+    # IPv4 and IPv6 and in different scenarios. They advise that servers should
+    # be configured to limit DNS messages sent over UDP to a size that will not
+    # trigger fragmentation on typical network links. DNS servers can switch
+    # from UDP to TCP when a DNS response is too big to fit in this limited
+    # buffer size. This value has also been suggested in DNS Flag Day 2020.
+    edns-buffer-size: 1232
+
+    # Perform prefetching of close to expired message cache entries
+    # This only applies to domains that have been frequently queried
+    prefetch: yes
+
+    # One thread should be sufficient, can be increased on beefy machines.
+    # In reality for most users running on small networks or on a single machine,
+    # it should be unnecessary to seek performance enhancement by increasing num-threads above 1.
+    num-threads: 1
+
+    # Ensure kernel buffer is large enough to not lose messages in traffic spikes
+    # (requires CAP_NET_ADMIN or privileged)
+    # so-rcvbuf: 1m
+
+    # The netblock is given as an IP4 or IP6 address with /size appended for a
+    # classless network block. The action can be deny, refuse, allow or allow_snoop.
+    access-control: 127.0.0.1/32 allow
+    access-control: 192.168.0.0/16 allow
+    access-control: 172.16.0.0/12 allow
+    access-control: 10.0.0.0/8 allow
+    access-control: 100.64.0.0/10 allow
+    access-control: 10.21.21.0/24 allow
+
+    # Ensure privacy of local IP ranges
+    private-address: 192.168.0.0/16
+    private-address: 169.254.0.0/16
+    private-address: 172.16.0.0/12
+    private-address: 10.0.0.0/8
+    private-address: fd00::/8
+    private-address: fe80::/10
+
+    # Read  the  root  hints from this file. Default is nothing, using built in
+    # hints for the IN class. The file has the format of  zone files,  with  root
+    # nameserver  names  and  addresses  only. The default may become outdated,
+    # when servers change,  therefore  it is good practice to use a root-hints
+    # file.  get one from https://www.internic.net/domain/named.root
+    root-hints: /etc/unbound/root.hints
+
+    # File with trust anchor for one zone, which is tracked with RFC5011 probes.
+    # The probes are several times per month, thus the machine must be online frequently.
+    # The initial file can be one with contents as described in trust-anchor-file.
+    # The file is written to when the anchor is updated, so the unbound user must
+    # have write permission.
+    auto-trust-anchor-file: /etc/unbound/root.key
+
+    # Number of ports to open. This number of file descriptors can be opened per thread.
+    # Must be at least 1. Default depends on compile options. Larger numbers need extra
+    # resources from the operating system. For performance a very large value is best,
+    # use libevent to make this possible.
+    outgoing-range: 8192
+
+    # The number of queries that every thread will service simultaneously. If more queries
+    # arrive that need servicing, and no queries can be jostled out (see jostle-timeout),
+    # then the queries are dropped. This forces the client to resend after a timeout;
+    # allowing the server time to work on the existing queries. Default depends on
+    # compile options, 512 or 1024.
+    num-queries-per-thread: 4096
+
+    include: /etc/unbound/a-records.conf
+
+    # forward-zone:
+    #     name: "."
+    #     forward-addr: 194.242.2.3@853 # Mullvad primary
+    #     forward-addr: 193.19.108.3@853 # Mullvad secondary

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

@@ -0,0 +1,39 @@
+version: "3.7"
+
+services:
+  unbound:
+    image: "klutchell/unbound"
+    container_name: unbound
+    restart: unless-stopped
+    volumes:
+      - "${APP_DATA_DIR}/data/unbound:/etc/unbound"
+    networks:
+      tipi_main_network:
+        ipv4_address: 10.21.21.200
+
+  pihole:
+    depends_on: [unbound]
+    container_name: pihole
+    image: pihole/pihole:latest
+    restart: unless-stopped
+    hostname: pihole
+    dns:
+      - 127.0.0.1
+      - 10.21.21.200 # Points to unbound
+    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_: 10.21.21.200 # Points to unbound
+      FTLCONF_REPLY_ADDR4: 10.21.21.201
+    cap_add:
+      - NET_ADMIN
+    networks:
+      tipi_main_network:
+        ipv4_address: 10.21.21.201

+ 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"
+    }
+  }
+}

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

@@ -0,0 +1,20 @@
+version: "3.7"
+services:
+  radarr:
+    image: lscr.io/linuxserver/radarr
+    container_name: radarr
+    environment:
+      - PUID=1000
+      - PGID=1000
+      - TZ=${TZ}
+    dns:
+      - ${DNS_IP}
+    volumes:
+      - ${APP_DATA_DIR}/data:/config
+      - ${ROOT_FOLDER}/media/data/movies:/movies #optional
+      - ${ROOT_FOLDER}/media/torrents:/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
     restart: on-failure
     ports:
-      - "${APP_SIMPLETORRENT_PORT}:${APP_SIMPLETORRENT_PORT}"
+      - ${APP_PORT}:${APP_PORT}
     command: >
-      --port=${APP_SIMPLETORRENT_PORT}
+      --port=${APP_PORT}
       --config-path /config/simple-torrent.json
     volumes:
       - ${APP_DATA_DIR}/data/torrents:/torrents

+ 13 - 0
apps/sonarr/config.json

@@ -0,0 +1,13 @@
+{
+  "name": "Sonarr",
+  "port": 8098,
+  "id": "sonarr",
+  "description": "",
+  "short_desc": "",
+  "author": "",
+  "source": "",
+  "image": "https://avatars.githubusercontent.com/u/1082903?s=200&v=4",
+  "form_fields": {
+
+  }
+}

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

@@ -0,0 +1,20 @@
+version: "3.7"
+services:
+  radarr:
+    image: lscr.io/linuxserver/sonarr
+    container_name: sonarr
+    environment:
+      - PUID=1000
+      - PGID=1000
+      - TZ=${TZ}
+    dns:
+      - ${DNS_IP}
+    volumes:
+      - ${APP_DATA_DIR}/data:/config
+      - ${ROOT_FOLDER}/media/data/tv:/tv #optional
+      - ${ROOT_FOLDER}/media/torrents:/downloads #optional
+    ports:
+      - ${APP_PORT}:8989
+    restart: unless-stopped
+    networks:
+      - tipi_main_network

+ 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/dnsmasq/.gitkeep → apps/syncthing/data/.gitkeep


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

@@ -0,0 +1,21 @@
+version: "3.7"
+
+services:
+  syncthing:
+    container_name: syncthing
+    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

+ 12 - 0
apps/tailscale/config.json

@@ -0,0 +1,12 @@
+{
+  "name": "Tailscale",
+  "port": 8093,
+  "id": "tailscale",
+  "description": "",
+  "short_desc": "",
+  "author": "",
+  "source": "https://github.com/tailscale/tailscale",
+  "website": "https://tailscale.com/",
+  "image": "https://avatars.githubusercontent.com/u/48932923?s=200&v=4",
+  "form_fields": {}
+}

+ 14 - 0
apps/tailscale/docker-compose.yml

@@ -0,0 +1,14 @@
+version: "2"
+
+services:
+  tailscale:
+    container_name: tailscale
+    network_mode: "host" # TODO: Find a way to remove this
+    image: tailscale/tailscale:v1.24.0
+    privileged: true
+    restart: on-failure
+    stop_grace_period: 1m
+    command: "sh -c 'tailscale web --listen 0.0.0.0:${APP_PORT} & exec tailscaled --tun=userspace-networking'"
+    volumes:
+      - /var/lib:/var/lib
+      - /dev/net/tun:/dev/net/tun

+ 0 - 6
apps/test/Dockerfile

@@ -1,6 +0,0 @@
-FROM ubuntu:latest
-
-# Install curl
-RUN apt-get update && apt-get install -y curl
-
-ENTRYPOINT ["tail", "-f", "/dev/null"]

+ 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/pi-hole/data/pihole/.gitkeep → apps/transmission/data/config/.gitkeep


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

@@ -0,0 +1,24 @@
+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
+      - ${ROOT_FOLDER}/media/torrents:/downloads
+    ports:
+      - ${APP_PORT}:9091
+      - 51413:51413
+      - 51413:51413/udp
+    restart: unless-stopped
+    networks:
+      - tipi_main_network

+ 29 - 0
apps/ttyd/config.json

@@ -0,0 +1,29 @@
+{
+    "name": "TTYD - Web terminal",
+    "port": 8092,
+    "id": "ttyd",
+    "description": "",
+    "short_desc": "A utility that allows you to access a command line from your web browser",
+    "author": "",
+    "source": "",
+    "image": "",
+    "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/ttyd/docker-compose.yml


+ 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


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

@@ -1,29 +1,36 @@
-version: '3.7'
+version: "3.7"
 services:
   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
+    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
+    dns:
+      - "${WIREGUARD_DNS:-8.8.8.8}"
+    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

+ 2 - 0
dashboard/.eslintignore

@@ -0,0 +1,2 @@
+*.config.js
+.eslintrc.js

+ 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 ./ ./
 
+ARG INTERNAL_IP_ARG
+ENV INTERNAL_IP $INTERNAL_IP_ARG
+
 RUN yarn build
 
 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} */
+const { NODE_ENV, INTERNAL_IP } = process.env;
+
 const nextConfig = {
   reactStrictMode: true,
-}
+  env: {
+    INTERNAL_IP: NODE_ENV === 'development' ? 'localhost' : INTERNAL_IP,
+  },
+};
 
-module.exports = nextConfig
+module.exports = nextConfig;

+ 17 - 1
dashboard/package.json

@@ -12,20 +12,36 @@
     "@chakra-ui/react": "^1.8.7",
     "@emotion/react": "^11",
     "@emotion/styled": "^11",
+    "@fontsource/open-sans": "^4.5.8",
+    "axios": "^0.26.1",
+    "clsx": "^1.1.1",
+    "final-form": "^4.20.6",
     "framer-motion": "^6",
+    "immer": "^9.0.12",
+    "js-cookie": "^3.0.1",
     "next": "12.1.4",
     "react": "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",
-    "validator": "^13.7.0"
+    "validator": "^13.7.0",
+    "zustand": "^3.7.2"
   },
   "devDependencies": {
+    "@types/js-cookie": "^3.0.2",
     "@types/node": "17.0.23",
     "@types/react": "17.0.43",
     "@types/react-dom": "17.0.14",
     "@types/validator": "^13.7.2",
+    "@typescript-eslint/eslint-plugin": "^5.18.0",
+    "autoprefixer": "^10.4.4",
     "eslint": "8.12.0",
+    "eslint-config-airbnb-typescript": "^17.0.0",
     "eslint-config-next": "12.1.4",
+    "postcss": "^8.4.12",
+    "tailwindcss": "^3.0.23",
     "typescript": "4.6.3"
   }
 }

+ 6 - 0
dashboard/postcss.config.js

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

BIN
dashboard/public/android-chrome-192x192.png


BIN
dashboard/public/android-chrome-512x512.png


BIN
dashboard/public/apple-touch-icon.png


+ 9 - 0
dashboard/public/browserconfig.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig>
+    <msapplication>
+        <tile>
+            <square150x150logo src="/mstile-150x150.png"/>
+            <TileColor>#da532c</TileColor>
+        </tile>
+    </msapplication>
+</browserconfig>

BIN
dashboard/public/favicon-16x16.png


BIN
dashboard/public/favicon-32x32.png


BIN
dashboard/public/favicon.ico


+ 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>

BIN
dashboard/public/mstile-150x150.png


+ 20 - 0
dashboard/public/safari-pinned-tab.svg

@@ -0,0 +1,20 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
+ preserveAspectRatio="xMidYMid meet">
+<metadata>
+Created by potrace 1.14, written by Peter Selinger 2001-2017
+</metadata>
+<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M2263 5057 c-67 -34 -125 -65 -128 -68 -3 -4 53 -126 125 -271 l132
+-263 -122 -275 c-66 -151 -130 -295 -142 -320 -11 -25 -37 -83 -58 -130 -21
+-47 -347 -706 -725 -1465 -378 -759 -786 -1578 -906 -1820 l-219 -440 2337 -3
+c1285 -1 2338 0 2340 2 2 2 -397 809 -888 1792 -706 1417 -931 1879 -1085
+2224 l-194 434 134 267 133 267 -135 66 c-103 51 -137 64 -143 54 -5 -7 -41
+-79 -81 -160 -40 -81 -75 -147 -78 -148 -3 0 -27 44 -54 98 -101 203 -111 222
+-116 221 -3 0 -60 -29 -127 -62z"/>
+</g>
+</svg>

+ 19 - 0
dashboard/public/site.webmanifest

@@ -0,0 +1,19 @@
+{
+    "name": "",
+    "short_name": "",
+    "icons": [
+        {
+            "src": "/android-chrome-192x192.png",
+            "sizes": "192x192",
+            "type": "image/png"
+        },
+        {
+            "src": "/android-chrome-512x512.png",
+            "sizes": "512x512",
+            "type": "image/png"
+        }
+    ],
+    "theme_color": "#ffffff",
+    "background_color": "#ffffff",
+    "display": "standalone"
+}

BIN
dashboard/public/tipi.png


+ 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;

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

@@ -0,0 +1,32 @@
+import { Box, SlideFade, Image, useColorModeValue } 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 }) => {
+  const bg = useColorModeValue('white', '#1a202c');
+
+  return (
+    <Link href={`/apps/${app.id}`} passHref>
+      <SlideFade in className="flex flex-1" offsetY="20px">
+        <Box minWidth={400} bg={bg} className="flex flex-1 border-2 drop-shadow-sm rounded-lg p-3 items-center cursor-pointer group hover:drop-shadow-md transition-all">
+          <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;

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

@@ -0,0 +1,25 @@
+import React from 'react';
+import { Input } from '@chakra-ui/react';
+import clsx from 'clsx';
+
+interface IProps {
+  placeholder?: string;
+  error?: string;
+  type?: Parameters<typeof Input>[0]['type'];
+  label?: string;
+  className?: string;
+  isInvalid?: boolean;
+  size?: Parameters<typeof Input>[0]['size'];
+}
+
+const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, ...rest }) => {
+  return (
+    <div className={clsx('transition-all', className)}>
+      {label && <label>{label}</label>}
+      <Input type={type} placeholder={placeholder} isInvalid={isInvalid} size={size} {...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 {
   onClickMenu: () => void;
 }
 
 const Header: React.FC<IProps> = ({ onClickMenu }) => {
-  const buttonVisibility = useBreakpointValue<"visible" | "hidden">({
-    base: "visible",
-    md: "hidden",
-  });
-
   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 h-12 md:h-0">
+      <Flex className="items-center border-b-2 bg-graycool px-5 flex-1 py-2">
+        <div onClick={onClickMenu} className="visible md:invisible absolute cursor-pointer py-2">
+          <FiMenu color="black" />
+        </div>
         <Flex justifyContent="center" flex="1">
           <Link href="/" passHref>
-            <Img src="/logo.svg" alt="Tipi" width={100} height={60} />
+            <img src="/tipi.png" alt="Tipi Logo" width={30} height={30} />
           </Link>
         </Flex>
       </Flex>

+ 62 - 25
dashboard/src/components/Layout/Layout.tsx

@@ -1,33 +1,70 @@
-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";
-
-const Layout: React.FC = ({ children }) => {
-  const menuWidth = useBreakpointValue({ base: 0, md: 200 });
-  const { isOpen, onOpen, onClose } = useDisclosure();
+import { Flex, useDisclosure, Spinner, Breadcrumb, BreadcrumbItem, useColorModeValue, Box } from '@chakra-ui/react';
+import Head from 'next/head';
+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';
+
+interface IProps {
+  loading?: boolean;
+  breadcrumbs?: { name: string; href: string; current?: boolean }[];
+}
+
+const Layout: React.FC<IProps> = ({ children, loading, breadcrumbs }) => {
+  const { isOpen, onClose, onOpen } = useDisclosure();
+  const menubg = useColorModeValue('#F1F3F4', '#202736');
+  const bg = useColorModeValue('white', '#1a202c');
+
+  const renderContent = () => {
+    if (loading) {
+      return (
+        <Flex className="justify-center flex-1">
+          <Spinner />
+        </Flex>
+      );
+    }
+
+    return children;
+  };
+
+  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 (
-    <Flex height="100vh" bg="green.500" direction="column">
-      <MenuDrawer isOpen={isOpen} onClose={onClose}>
-        <Menu />
-      </MenuDrawer>
-      <Header onClickMenu={onOpen} />
-      <Flex flex="1">
-        <Flex width={menuWidth} bg="blue.500">
+    <>
+      <Head>
+        <title>Tipi</title>
+      </Head>
+
+      <Flex height="100vh" direction="column">
+        <MenuDrawer isOpen={isOpen} onClose={onClose}>
           <Menu />
-        </Flex>
-        <Flex flex="1" padding={5} bg="yellow.300">
-          {children}
+        </MenuDrawer>
+        <Header onClickMenu={onOpen} />
+        <Flex flex={1}>
+          <Flex height="100vh" bg={menubg} className="sticky top-0 invisible md:visible w-0 md:w-64">
+            <Menu />
+          </Flex>
+          <Box bg={bg} className="flex-1 px-4 py-4 md:px-10 md:py-8">
+            {renderBreadcrumbs()}
+            {renderContent()}
+          </Box>
         </Flex>
       </Flex>
-    </Flex>
+    </>
   );
 };
 

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

@@ -1,11 +1,71 @@
-import React from "react";
+import { AiOutlineDashboard, AiOutlineSetting, AiOutlineAppstore } from 'react-icons/ai';
+import { FaRegMoon } from 'react-icons/fa';
+import { FiLogOut } from 'react-icons/fi';
+import Package from '../../../package.json';
+import { Box, Divider, Flex, List, ListItem, Switch, useColorMode } from '@chakra-ui/react';
+import React from 'react';
+import Link from 'next/link';
+import clsx from 'clsx';
+import { useRouter } from 'next/router';
+import { IconType } from 'react-icons';
+import { useAuthStore } from '../../state/authStore';
+
+const SideMenu: React.FC = () => {
+  const router = useRouter();
+  const { colorMode, setColorMode } = useColorMode();
+  const { logout } = useAuthStore();
+  const path = router.pathname.split('/')[1];
+
+  const renderMenuItem = (title: string, name: string, Icon: IconType) => {
+    const selected = path === name;
+
+    const itemClass = clsx('mx-3 border-transparent rounded-lg p-3 transition-colors border-1', {
+      'drop-shadow-sm border-gray-200': selected && colorMode === 'light',
+      'bg-white': selected && colorMode === 'light',
+    });
+
+    return (
+      <Link href={`/${name}`} passHref>
+        <div className={itemClass}>
+          <ListItem className={'flex items-center cursor-pointer hover:font-bold'}>
+            <Icon size={20} className={clsx('mr-3', { 'text-red-600': selected && colorMode === 'light', 'text-red-200': selected && colorMode === 'dark' })} />
+            <p className={clsx({ 'font-bold': selected, 'text-red-600': selected && colorMode === 'light', 'text-red-200': selected && colorMode === 'dark' })}>{title}</p>
+          </ListItem>
+        </div>
+      </Link>
+    );
+  };
+
+  const handleChangeColorMode = (checked: boolean) => {
+    setColorMode(checked ? 'dark' : 'light');
+  };
 
-const Menu: React.FC = () => {
   return (
-    <div>
-      <h1>Menu</h1>
-    </div>
+    <Box className="flex-1 flex flex-col p-0 md:p-4">
+      <img className="self-center mb-5 logo mt-0 md:mt-5" src="/tipi.png" width={512} height={512} />
+      <List spacing={3} className="pt-5">
+        {renderMenuItem('Dashboard', '', AiOutlineDashboard)}
+        {renderMenuItem('Apps', 'apps', AiOutlineAppstore)}
+        {renderMenuItem('Settings', 'settings', AiOutlineSetting)}
+      </List>
+      <Divider className="my-3" />
+      <Flex flex="1" />
+      <List>
+        <div className="mx-3">
+          <ListItem onClick={logout} className="cursor-pointer hover:font-bold flex items-center mb-5">
+            <FiLogOut size={20} className="mr-3" />
+            <p className="flex-1">Log out</p>
+          </ListItem>
+          <ListItem className="flex items-center">
+            <FaRegMoon size={20} className="mr-3" />
+            <p className="flex-1">Dark mode</p>
+            <Switch checked={colorMode === 'dark'} onChange={(event) => handleChangeColorMode(event.target.checked)} />
+          </ListItem>
+        </div>
+      </List>
+      <div className="pb-1 text-center text-sm text-gray-400 mt-5">Tipi version {Package.version}</div>
+    </Box>
   );
 };
 
-export default Menu;
+export default SideMenu;

+ 8 - 17
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, DrawerHeader, DrawerOverlay, useColorModeValue } from '@chakra-ui/react';
+import React from 'react';
 
 interface IProps {
   isOpen: boolean;
@@ -15,16 +7,15 @@ interface IProps {
 }
 
 const MenuDrawer: React.FC<IProps> = ({ children, isOpen, onClose }) => {
+  const menubg = useColorModeValue('#F1F3F4', '#202736');
+
   return (
-    <Drawer isOpen={isOpen} placement="left" onClose={onClose}>
+    <Drawer size="xs" isOpen={isOpen} placement="left" onClose={onClose}>
       <DrawerOverlay />
-      <DrawerContent>
+      <DrawerContent bg={menubg}>
         <DrawerCloseButton />
-        <DrawerHeader>Create your account</DrawerHeader>
-        <DrawerBody>{children}</DrawerBody>
-        <DrawerFooter>
-          <div>Github</div>
-        </DrawerFooter>
+        <DrawerHeader>My Tipi</DrawerHeader>
+        <DrawerBody display="flex">{children}</DrawerBody>
       </DrawerContent>
     </Drawer>
   );

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

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

+ 12 - 0
dashboard/src/components/LoadingScreen.tsx

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

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

@@ -1,4 +1,4 @@
-import validator from "validator";
+import validator from 'validator';
 
 interface IFormField {
   name: string;
@@ -20,81 +20,79 @@ interface 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: {
     fields: [
       {
-        name: "API Key",
-        type: "text",
-        placeholder: "API Key",
+        name: 'API Key',
+        type: 'text',
+        placeholder: 'API Key',
         required: true,
         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,
         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,
       },
       {
-        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,
       },
       {
-        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,
         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,
         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,
       },
       {
-        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,
-        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,
         validate: (value: string) => validator.isEmail(value),
       },

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

@@ -0,0 +1,32 @@
+import axios, { Method } from 'axios';
+
+export const BASE_URL = `http://${process.env.INTERNAL_IP}:3001`;
+
+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}`,
+    withCredentials: true,
+  });
+
+  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;

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

@@ -0,0 +1,57 @@
+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;
+  hint?: string;
+  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',
+}
+
+export interface IUser {
+  name: string;
+  email: string;
+}

+ 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} colorScheme="gray" 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;

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