浏览代码

Improve error handling

Nicolas Meienberger 3 年之前
父节点
当前提交
251b0ba9a0
共有 42 个文件被更改,包括 449 次插入14364 次删除
  1. 1 0
      apps/filerun/config.json
  2. 1 1
      apps/filerun/docker-compose.yml
  3. 1 0
      apps/freshrss/config.json
  4. 1 1
      apps/freshrss/docker-compose.yml
  5. 1 0
      apps/jellyfin/config.json
  6. 2 3
      apps/jellyfin/docker-compose.yml
  7. 1 0
      apps/nextcloud/config.json
  8. 1 1
      apps/nextcloud/docker-compose.yml
  9. 20 17
      apps/pi-hole/config.json
  10. 1 1
      apps/pi-hole/docker-compose.yml
  11. 0 11
      apps/plex/config.json
  12. 0 20
      apps/plex/docker-compose.yml
  13. 19 1
      apps/radarr/config.json
  14. 2 2
      apps/radarr/docker-compose.yml
  15. 1 0
      apps/simple-torrent/config.json
  16. 2 2
      apps/simple-torrent/docker-compose.yml
  17. 4 0
      apps/transmission/config.json
  18. 1 1
      apps/transmission/docker-compose.yml
  19. 4 0
      apps/wg-easy/config.json
  20. 1 1
      apps/wg-easy/docker-compose.yml
  21. 12 14
      dashboard/src/core/api.ts
  22. 4 0
      dashboard/src/core/types.ts
  23. 14 8
      dashboard/src/modules/Apps/components/AppActions.tsx
  24. 4 2
      dashboard/src/modules/Apps/components/InstallForm.tsx
  25. 36 0
      dashboard/src/modules/Apps/components/UpdateModal.tsx
  26. 76 12
      dashboard/src/modules/Apps/containers/AppDetails.tsx
  27. 8 0
      dashboard/src/pages/_app.tsx
  28. 24 30
      dashboard/src/state/appsStore.ts
  29. 19 0
      dashboard/src/state/networkStore.ts
  30. 1 0
      dashboard/src/styles/globals.css
  31. 4 2
      scripts/app.sh
  32. 10 10
      scripts/start.sh
  33. 1 1
      state/apps.json
  34. 1 14132
      system-api/package-lock.json
  35. 5 1
      system-api/package.json
  36. 4 0
      system-api/src/config/types.ts
  37. 53 85
      system-api/src/modules/apps/apps.controller.ts
  38. 85 0
      system-api/src/modules/apps/apps.helpers.ts
  39. 2 0
      system-api/src/modules/apps/apps.routes.ts
  40. 8 0
      system-api/src/modules/network/network.controller.ts
  41. 8 0
      system-api/src/modules/network/network.routes.ts
  42. 6 5
      system-api/src/server.ts

+ 1 - 0
apps/filerun/config.json

@@ -1,5 +1,6 @@
 {
 {
   "name": "FileRun",
   "name": "FileRun",
+  "port": 8087,
   "id": "filerun",
   "id": "filerun",
   "description": "Reliable and Performant File Management Desktop Sync and File Sharing",
   "description": "Reliable and Performant File Management Desktop Sync and File Sharing",
   "short_desc": "Access your homeserver files from your browser",
   "short_desc": "Access your homeserver files from your browser",

+ 1 - 1
apps/filerun/docker-compose.yml

@@ -30,7 +30,7 @@ services:
     links:
     links:
       - db:db
       - db:db
     ports:
     ports:
-      - "${APP_FILERUN_PORT}:80"
+      - ${APP_PORT}:80
     volumes:
     volumes:
       - ${APP_DATA_DIR}/data/html:/var/www/html
       - ${APP_DATA_DIR}/data/html:/var/www/html
       - ${ROOT_FOLDER}/app-data:/user-files
       - ${ROOT_FOLDER}/app-data:/user-files

+ 1 - 0
apps/freshrss/config.json

@@ -1,5 +1,6 @@
 {
 {
   "name": "FreshRSS",
   "name": "FreshRSS",
+  "port": 8086,
   "id": "freshrss",
   "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.",
   "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… ",
   "short_desc": "A free, self-hostable aggregator… ",

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

@@ -6,7 +6,7 @@ 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/freshrss:/var/www/FreshRSS/data
       - ${APP_DATA_DIR}/data/freshrss:/var/www/FreshRSS/data
       - ${APP_DATA_DIR}/data/extensions/:/var/www/FreshRSS/extensions
       - ${APP_DATA_DIR}/data/extensions/:/var/www/FreshRSS/extensions

+ 1 - 0
apps/jellyfin/config.json

@@ -1,5 +1,6 @@
 {
 {
   "name": "Jellyfin",
   "name": "Jellyfin",
+  "port": 8091,
   "id": "jellyfin",
   "id": "jellyfin",
   "description": "",
   "description": "",
   "short_desc": "",
   "short_desc": "",

+ 2 - 3
apps/jellyfin/docker-compose.yml

@@ -4,10 +4,9 @@ services:
   jellyfin:
   jellyfin:
     image: lscr.io/linuxserver/jellyfin
     image: lscr.io/linuxserver/jellyfin
     container_name: jellyfin
     container_name: jellyfin
-    # user: 1000:1000
     volumes:
     volumes:
       - ${APP_DATA_DIR}/data/config:/config
       - ${APP_DATA_DIR}/data/config:/config
-      - ${ROOT_FOLDER}/app-data/transmission/data/downloads/complete:/data/movies
+      - ${APP_DATA_DIR}/data/media:/data/media
     environment:
     environment:
       - PUID=1000
       - PUID=1000
       - PGID=1000
       - PGID=1000
@@ -15,7 +14,7 @@ services:
       # - JELLYFIN_PublishedServerUrl=192.168.0.5 #optional
       # - JELLYFIN_PublishedServerUrl=192.168.0.5 #optional
     restart: "unless-stopped"
     restart: "unless-stopped"
     ports:
     ports:
-      - ${APP_JELLYFIN_PORT}:8096
+      - ${APP_PORT}:8096
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
     # Optional - alternative address used for autodiscovery
     # Optional - alternative address used for autodiscovery

+ 1 - 0
apps/nextcloud/config.json

@@ -1,5 +1,6 @@
 {
 {
   "name": "Nextcloud",
   "name": "Nextcloud",
+  "port": 8083,
   "id": "nextcloud",
   "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",
   "short_desc": "Productivity platform that keeps you in control",

+ 1 - 1
apps/nextcloud/docker-compose.yml

@@ -45,7 +45,7 @@ services:
     image: nextcloud:22.1.1-apache
     image: nextcloud:22.1.1-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
     environment:
     environment:

+ 20 - 17
apps/pi-hole/config.json

@@ -1,20 +1,23 @@
 {
 {
-    "name": "PiHole",
-    "id": "pi-hole",
-    "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"
-      }
+  "name": "PiHole",
+  "port": 8081,
+  "requirements": {
+    "ports": [53]
+  },
+  "id": "pi-hole",
+  "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"
     }
     }
   }
   }
-  
+}

+ 1 - 1
apps/pi-hole/docker-compose.yml

@@ -20,7 +20,7 @@ services:
     ports:
     ports:
       - 53:53/tcp
       - 53:53/tcp
       - 53:53/udp
       - 53:53/udp
-      - ${APP_PI_HOLE_PORT}:80
+      - ${APP_PORT}:80
     volumes:
     volumes:
       - ${APP_DATA_DIR}/data/pihole:/etc/pihole/
       - ${APP_DATA_DIR}/data/pihole:/etc/pihole/
       - ${APP_DATA_DIR}/data/dnsmasq:/etc/dnsmasq.d/
       - ${APP_DATA_DIR}/data/dnsmasq:/etc/dnsmasq.d/

+ 0 - 11
apps/plex/config.json

@@ -1,11 +0,0 @@
-{
-  "name": "Plex",
-  "id": "plex",
-  "description": "",
-  "short_desc": "",
-  "author": "",
-  "source": "",
-  "image": "https://avatars.githubusercontent.com/u/324832?s=200&v=4",
-  "dependencies": ["transmission"],
-  "form_fields": {}
-}

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

@@ -1,20 +0,0 @@
-
-version: "3.7"
-
-services:
-  plex:
-    image: lscr.io/linuxserver/plex
-    container_name: plex
-    network_mode: host
-    environment:
-      - PUID=1000
-      - PGID=1000
-      - VERSION=docker
-      # - PLEX_CLAIM= #optional
-    volumes:
-      - ${APP_DATA_DIR}/data/config:/config
-      - ${APP_DATA_DIR}/data/tv:/tv
-      - ${ROOT_FOLDER}/app-data/transmission/data/downloads/complete:/movies
-    restart: unless-stopped
-    # networks:
-    #   - tipi_main_network

+ 19 - 1
apps/radarr/config.json

@@ -1,5 +1,6 @@
 {
 {
   "name": "Radarr",
   "name": "Radarr",
+  "port": 8088,
   "id": "radarr",
   "id": "radarr",
   "description": "",
   "description": "",
   "short_desc": "",
   "short_desc": "",
@@ -7,5 +8,22 @@
   "source": "",
   "source": "",
   "image": "https://avatars.githubusercontent.com/u/25025331?s=200&v=4",
   "image": "https://avatars.githubusercontent.com/u/25025331?s=200&v=4",
   "dependencies": ["transmission"],
   "dependencies": ["transmission"],
-  "form_fields": {}
+  "form_fields": {
+    "torrent-client": {
+      "type": "text",
+      "label": "Torrent Client",
+      "max": 50,
+      "min": 3,
+      "required": true,
+      "env_variable": "TORRENT_CLIENT"
+    },
+    "test": {
+      "type": "text",
+      "label": "testvar",
+      "max": 50,
+      "min": 3,
+      "required": true,
+      "env_variable": "TEST_VAR"
+    }
+  }
 }
 }

+ 2 - 2
apps/radarr/docker-compose.yml

@@ -27,9 +27,9 @@ services:
     volumes:
     volumes:
       - ${APP_DATA_DIR}/data/config:/config
       - ${APP_DATA_DIR}/data/config:/config
       - ${APP_DATA_DIR}/data/movies:/movies #optional
       - ${APP_DATA_DIR}/data/movies:/movies #optional
-      - ${ROOT_FOLDER}/app-data/transmission/data/downloads:/downloads #optional
+      - ${ROOT_FOLDER}/app-data/${TORRENT_CLIENT}/data/downloads:/downloads #optional
     ports:
     ports:
-      - ${APP_RADARR_PORT}:7878
+      - ${APP_PORT}:7878
     restart: unless-stopped
     restart: unless-stopped
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network

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

@@ -1,5 +1,6 @@
 {
 {
   "name": "Simple Torrent",
   "name": "Simple Torrent",
+  "port": 8085,
   "id": "simple-torrent",
   "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.",
   "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",
   "short_desc": "A self-hosted remote torrent client",

+ 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

+ 4 - 0
apps/transmission/config.json

@@ -1,5 +1,9 @@
 {
 {
   "name": "Transmission",
   "name": "Transmission",
+  "port": 8089,
+  "requirements": {
+    "ports": [51413]
+  },
   "id": "transmission",
   "id": "transmission",
   "description": "",
   "description": "",
   "short_desc": "",
   "short_desc": "",

+ 1 - 1
apps/transmission/docker-compose.yml

@@ -17,7 +17,7 @@ services:
       - ${APP_DATA_DIR}/data/downloads:/downloads
       - ${APP_DATA_DIR}/data/downloads:/downloads
       - ${APP_DATA_DIR}/data/watch:/watch
       - ${APP_DATA_DIR}/data/watch:/watch
     ports:
     ports:
-      - ${APP_TRANSMISSION_PORT}:9091
+      - ${APP_PORT}:9091
       - 51413:51413
       - 51413:51413
       - 51413:51413/udp
       - 51413:51413/udp
     restart: unless-stopped
     restart: unless-stopped

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

@@ -1,5 +1,9 @@
 {
 {
   "name": "Wireguard",
   "name": "Wireguard",
+  "port": 8082,
+  "requirements": {
+    "ports": [51820]
+  },
   "id": "wg-easy",
   "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.",
   "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",
   "short_desc": "VPN server for your homeserver",

+ 1 - 1
apps/wg-easy/docker-compose.yml

@@ -8,7 +8,7 @@ services:
         - ${APP_DATA_DIR}:/etc/wireguard
         - ${APP_DATA_DIR}:/etc/wireguard
       ports:
       ports:
         - 51820:51820
         - 51820:51820
-        - ${APP_WGEASY_PORT}:51821
+        - ${APP_PORT}:51821
       environment:
       environment:
         WG_HOST: '${WIREGUARD_HOST}'
         WG_HOST: '${WIREGUARD_HOST}'
         PASSWORD: '${WIREGUARD_PASSWORD}'
         PASSWORD: '${WIREGUARD_PASSWORD}'

+ 12 - 14
dashboard/src/core/api.ts

@@ -12,22 +12,20 @@ interface IFetchParams {
 const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
 const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
   const { endpoint, method = 'GET', params, data } = fetchParams;
   const { endpoint, method = 'GET', params, data } = fetchParams;
 
 
-  try {
-    const response = await axios.request<T>({
-      method,
-      params,
-      data,
-      url: `${BASE_URL}${endpoint}`,
-    });
-
-    if (response.data) return response.data;
+  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);
+  }
 
 
-    throw new Error(`Network request error. status : ${response.status}`);
-  } catch (error) {
-    console.error('Error during fetch', `params: ${JSON.stringify(fetchParams)}`, error);
+  if (response.data) return response.data;
 
 
-    return Promise.reject(error);
-  }
+  throw new Error(`Network request error. status : ${response.status}`);
 };
 };
 
 
 export default { fetch: api };
 export default { fetch: api };

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

@@ -18,6 +18,10 @@ interface FormField {
 
 
 export interface AppConfig {
 export interface AppConfig {
   id: string;
   id: string;
+  port: number;
+  requirements?: {
+    ports?: number[];
+  };
   name: string;
   name: string;
   description: string;
   description: string;
   version: string;
   version: string;

+ 14 - 8
dashboard/src/modules/Apps/components/AppActions.tsx

@@ -1,6 +1,6 @@
 import { Button } from '@chakra-ui/react';
 import { Button } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
-import { FiExternalLink, FiPause, FiPlay, FiTrash2 } from 'react-icons/fi';
+import { FiExternalLink, FiPause, FiPlay, FiSettings, FiTrash2 } from 'react-icons/fi';
 import { AppConfig, AppStatus } from '../../../core/types';
 import { AppConfig, AppStatus } from '../../../core/types';
 
 
 interface IProps {
 interface IProps {
@@ -9,30 +9,36 @@ interface IProps {
   onUninstall: () => void;
   onUninstall: () => void;
   onStart: () => void;
   onStart: () => void;
   onStop: () => void;
   onStop: () => void;
+  onOpen: () => void;
+  onUpdate: () => void;
 }
 }
 
 
-const AppActions: React.FC<IProps> = ({ app, onInstall, onUninstall, onStart, onStop }) => {
+const AppActions: React.FC<IProps> = ({ app, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate }) => {
   if (app?.installed && app.status === AppStatus.STOPPED) {
   if (app?.installed && app.status === AppStatus.STOPPED) {
     return (
     return (
-      <div>
-        <Button onClick={onStart} width={160} colorScheme="green" className="mt-3">
+      <div className="flex flex-wrap justify-center">
+        <Button onClick={onStart} width={160} colorScheme="green" className="mt-3 mr-2">
           Start
           Start
           <FiPlay className="ml-1" />
           <FiPlay className="ml-1" />
         </Button>
         </Button>
-        <Button onClick={onUninstall} width={160} colorScheme="gray" className="mt-3 ml-2">
+        <Button onClick={onUninstall} width={160} colorScheme="gray" className="mt-3 mr-2">
           Remove
           Remove
           <FiTrash2 className="ml-1" />
           <FiTrash2 className="ml-1" />
         </Button>
         </Button>
+        <Button onClick={onUpdate} width={160} className="mt-3 mr-2">
+          Settings
+          <FiSettings className="ml-1" />
+        </Button>
       </div>
       </div>
     );
     );
   } else if (app?.installed && app.status === AppStatus.RUNNING) {
   } else if (app?.installed && app.status === AppStatus.RUNNING) {
     return (
     return (
       <div>
       <div>
-        <Button onClick={() => alert('open')} width={160} colorScheme="gray" className="mt-3">
+        <Button onClick={onOpen} width={160} colorScheme="gray" className="mt-3 mr-2">
           Open
           Open
           <FiExternalLink className="ml-1" />
           <FiExternalLink className="ml-1" />
         </Button>
         </Button>
-        <Button onClick={onStop} width={160} colorScheme="red" className="mt-3 ml-2">
+        <Button onClick={onStop} width={160} colorScheme="red" className="mt-3">
           Stop
           Stop
           <FiPause className="ml-2" />
           <FiPause className="ml-2" />
         </Button>
         </Button>
@@ -40,7 +46,7 @@ const AppActions: React.FC<IProps> = ({ app, onInstall, onUninstall, onStart, on
     );
     );
   } else if (app.status === AppStatus.INSTALLING || app.status === AppStatus.UNINSTALLING || app.status === AppStatus.STARTING || app.status === AppStatus.STOPPING) {
   } else if (app.status === AppStatus.INSTALLING || app.status === AppStatus.UNINSTALLING || app.status === AppStatus.STARTING || app.status === AppStatus.STOPPING) {
     return (
     return (
-      <div className="flex items-center">
+      <div className="flex items-center flex-col md:flex-row">
         <Button isLoading onClick={() => null} width={160} colorScheme="green" className="mt-3">
         <Button isLoading onClick={() => null} width={160} colorScheme="green" className="mt-3">
           Install
           Install
           <FiPlay className="ml-1" />
           <FiPlay className="ml-1" />

+ 4 - 2
dashboard/src/modules/Apps/components/InstallForm.tsx

@@ -9,9 +9,10 @@ import { objectKeys } from '../../../utils/typescript';
 interface IProps {
 interface IProps {
   formFields: AppConfig['form_fields'];
   formFields: AppConfig['form_fields'];
   onSubmit: (values: Record<string, unknown>) => void;
   onSubmit: (values: Record<string, unknown>) => void;
+  initalValues?: Record<string, string>;
 }
 }
 
 
-const InstallForm: React.FC<IProps> = ({ formFields, onSubmit }) => {
+const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues }) => {
   const fields = objectKeys(formFields).map((key) => ({ ...formFields[key], id: key }));
   const fields = objectKeys(formFields).map((key) => ({ ...formFields[key], id: key }));
 
 
   const renderField = (field: typeof fields[0]) => {
   const renderField = (field: typeof fields[0]) => {
@@ -26,6 +27,7 @@ const InstallForm: React.FC<IProps> = ({ formFields, onSubmit }) => {
 
 
   return (
   return (
     <Form<Record<string, string>>
     <Form<Record<string, string>>
+      initialValues={initalValues}
       onSubmit={onSubmit}
       onSubmit={onSubmit}
       validateOnBlur={true}
       validateOnBlur={true}
       validate={(values) => validateAppConfig(values, fields)}
       validate={(values) => validateAppConfig(values, fields)}
@@ -33,7 +35,7 @@ const InstallForm: React.FC<IProps> = ({ formFields, onSubmit }) => {
         <form className="flex flex-col" onSubmit={handleSubmit}>
         <form className="flex flex-col" onSubmit={handleSubmit}>
           {fields.map(renderField)}
           {fields.map(renderField)}
           <Button isLoading={validating || submitting} className="self-end mb-2" colorScheme="green" type="submit">
           <Button isLoading={validating || submitting} className="self-end mb-2" colorScheme="green" type="submit">
-            Install
+            {initalValues ? 'Update' : 'Install'}
           </Button>
           </Button>
         </form>
         </form>
       )}
       )}

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

+ 76 - 12
dashboard/src/modules/Apps/containers/AppDetails.tsx

@@ -1,41 +1,94 @@
-import { SlideFade, Image, VStack, Flex, Divider, useDisclosure } from '@chakra-ui/react';
+import { SlideFade, Image, VStack, Flex, Divider, useDisclosure, useToast } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
 import { FiExternalLink } from 'react-icons/fi';
 import { FiExternalLink } from 'react-icons/fi';
 import { AppConfig } from '../../../core/types';
 import { AppConfig } from '../../../core/types';
 import { useAppsStore } from '../../../state/appsStore';
 import { useAppsStore } from '../../../state/appsStore';
+import { useNetworkStore } from '../../../state/networkStore';
 import AppActions from '../components/AppActions';
 import AppActions from '../components/AppActions';
 import InstallModal from '../components/InstallModal';
 import InstallModal from '../components/InstallModal';
 import StopModal from '../components/StopModal';
 import StopModal from '../components/StopModal';
 import UninstallModal from '../components/UninstallModal';
 import UninstallModal from '../components/UninstallModal';
+import UpdateModal from '../components/UpdateModal';
 
 
 interface IProps {
 interface IProps {
   app: AppConfig;
   app: AppConfig;
 }
 }
 
 
 const AppDetails: React.FC<IProps> = ({ app }) => {
 const AppDetails: React.FC<IProps> = ({ app }) => {
+  const toast = useToast();
   const installDisclosure = useDisclosure();
   const installDisclosure = useDisclosure();
   const uninstallDisclosure = useDisclosure();
   const uninstallDisclosure = useDisclosure();
   const stopDisclosure = useDisclosure();
   const stopDisclosure = useDisclosure();
+  const updateDisclosure = useDisclosure();
 
 
-  const { install, uninstall, stop, start } = useAppsStore((state) => state);
+  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>) => {
   const handleInstallSubmit = async (values: Record<string, any>) => {
     installDisclosure.onClose();
     installDisclosure.onClose();
-    await install(app.id, values);
+    try {
+      await install(app.id, values);
+    } catch (error) {
+      handleError(error);
+    }
   };
   };
 
 
   const handleUnistallSubmit = async () => {
   const handleUnistallSubmit = async () => {
     uninstallDisclosure.onClose();
     uninstallDisclosure.onClose();
-    await uninstall(app.id);
+    try {
+      await uninstall(app.id);
+    } catch (error) {
+      handleError(error);
+    }
   };
   };
 
 
   const handleStopSubmit = async () => {
   const handleStopSubmit = async () => {
     stopDisclosure.onClose();
     stopDisclosure.onClose();
-    await stop(app.id);
+    try {
+      await stop(app.id);
+    } catch (error) {
+      handleError(error);
+    }
   };
   };
 
 
   const handleStartSubmit = async () => {
   const handleStartSubmit = async () => {
-    await start(app.id);
+    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 (
   return (
@@ -47,15 +100,25 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
             <div className="mt-3 items-center self-center flex flex-col sm:items-start sm:self-start md:mt-0">
             <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>
               <h1 className="font-bold text-2xl">{app?.name}</h1>
               <h2 className="text-center md:text-left">{app?.short_desc}</h2>
               <h2 className="text-center md:text-left">{app?.short_desc}</h2>
-              <a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={app?.source}>
-                <Flex className="mt-2 items-center">
-                  {app?.source} <FiExternalLink className="ml-1" />
-                </Flex>
-              </a>
+              {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>
               <p className="text-xs text-gray-600">By {app?.author}</p>
             </div>
             </div>
             <div className="flex justify-center sm:absolute md:static top-0 right-5 self-center sm:self-auto">
             <div className="flex justify-center sm:absolute md:static top-0 right-5 self-center sm:self-auto">
-              <AppActions onStart={handleStartSubmit} onStop={stopDisclosure.onOpen} onUninstall={uninstallDisclosure.onOpen} onInstall={installDisclosure.onOpen} app={app} />
+              <AppActions
+                onUpdate={updateDisclosure.onOpen}
+                onOpen={handleOpen}
+                onStart={handleStartSubmit}
+                onStop={stopDisclosure.onOpen}
+                onUninstall={uninstallDisclosure.onOpen}
+                onInstall={installDisclosure.onOpen}
+                app={app}
+              />
             </div>
             </div>
           </VStack>
           </VStack>
         </Flex>
         </Flex>
@@ -64,6 +127,7 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
         <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={app} />
         <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={app} />
         <UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.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} />
         <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={app} />
+        <UpdateModal onSubmit={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={app} />
       </div>
       </div>
     </SlideFade>
     </SlideFade>
   );
   );

+ 8 - 0
dashboard/src/pages/_app.tsx

@@ -1,8 +1,16 @@
 import { ChakraProvider } from '@chakra-ui/react';
 import { ChakraProvider } from '@chakra-ui/react';
 import '../styles/globals.css';
 import '../styles/globals.css';
 import type { AppProps } from 'next/app';
 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} />

+ 24 - 30
dashboard/src/state/appsStore.ts

@@ -12,6 +12,7 @@ type AppsStore = {
   getApp: (id: string) => AppConfig | undefined;
   getApp: (id: string) => AppConfig | undefined;
   fetchApp: (id: string) => void;
   fetchApp: (id: string) => void;
   install: (id: string, form: Record<string, string>) => Promise<void>;
   install: (id: string, form: Record<string, string>) => Promise<void>;
+  update: (id: string, form: Record<string, string>) => Promise<void>;
   uninstall: (id: string) => Promise<void>;
   uninstall: (id: string) => Promise<void>;
   stop: (id: string) => Promise<void>;
   stop: (id: string) => Promise<void>;
   start: (id: string) => Promise<void>;
   start: (id: string) => Promise<void>;
@@ -79,54 +80,47 @@ export const useAppsStore = create<AppsStore>((set, get) => ({
   install: async (appId: string, form?: Record<string, string>) => {
   install: async (appId: string, form?: Record<string, string>) => {
     setAppStatus(appId, AppStatus.INSTALLING, set);
     setAppStatus(appId, AppStatus.INSTALLING, set);
 
 
-    try {
-      await api.fetch({
-        endpoint: `/apps/install/${appId}`,
-        method: 'POST',
-        data: { form },
-      });
-    } catch (e) {
-      console.error(e);
-    }
+    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);
     await get().fetchApp(appId);
   },
   },
   uninstall: async (appId: string) => {
   uninstall: async (appId: string) => {
     setAppStatus(appId, AppStatus.UNINSTALLING, set);
     setAppStatus(appId, AppStatus.UNINSTALLING, set);
 
 
-    try {
-      await api.fetch({
-        endpoint: `/apps/uninstall/${appId}`,
-      });
-    } catch (e) {
-      console.error(e);
-    }
+    await api.fetch({
+      endpoint: `/apps/uninstall/${appId}`,
+    });
 
 
     await get().fetchApp(appId);
     await get().fetchApp(appId);
   },
   },
   stop: async (appId: string) => {
   stop: async (appId: string) => {
     setAppStatus(appId, AppStatus.STOPPING, set);
     setAppStatus(appId, AppStatus.STOPPING, set);
 
 
-    try {
-      await api.fetch({
-        endpoint: `/apps/stop/${appId}`,
-      });
-    } catch (e) {
-      console.error(e);
-    }
+    await api.fetch({
+      endpoint: `/apps/stop/${appId}`,
+    });
 
 
     await get().fetchApp(appId);
     await get().fetchApp(appId);
   },
   },
   start: async (appId: string) => {
   start: async (appId: string) => {
     setAppStatus(appId, AppStatus.STARTING, set);
     setAppStatus(appId, AppStatus.STARTING, set);
 
 
-    try {
-      await api.fetch({
-        endpoint: `/apps/start/${appId}`,
-      });
-    } catch (e) {
-      console.error(e);
-    }
+    await api.fetch({
+      endpoint: `/apps/start/${appId}`,
+    });
 
 
     await get().fetchApp(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 });
+  },
+}));

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

@@ -5,6 +5,7 @@
 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;

+ 4 - 2
scripts/app.sh

@@ -106,8 +106,10 @@ compose() {
 if [[ "$command" = "install" ]]; then
 if [[ "$command" = "install" ]]; then
   compose "${app}" pull
   compose "${app}" pull
 
 
-  # Copy default data dir to app data dir
-  cp -r "${ROOT_FOLDER}/apps/${app}/data" "${app_data_dir}/data"
+  # 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

+ 10 - 10
scripts/start.sh

@@ -35,17 +35,17 @@ docker-compose up --detach --remove-orphans --build || {
 }
 }
 
 
 # Get field from json file
 # Get field from json file
-function get_json_field() {
-    local json_file="$1"
-    local field="$2"
+# function get_json_field() {
+#     local json_file="$1"
+#     local field="$2"
 
 
-    echo $(jq -r ".${field}" "${json_file}")
-}
+#     echo $(jq -r ".${field}" "${json_file}")
+# }
 
 
-str=$(get_json_field ${STATE_FOLDER}/apps.json installed)
-apps_to_start=($str)
+# str=$(get_json_field ${STATE_FOLDER}/apps.json installed)
+# apps_to_start=($str)
 
 
-for app in "${apps_to_start[@]}"; do
-    "${ROOT_FOLDER}/scripts/app.sh" start $app
-done
+# for app in "${apps_to_start[@]}"; do
+#     "${ROOT_FOLDER}/scripts/app.sh" start $app
+# done
 
 

+ 1 - 1
state/apps.json

@@ -1 +1 @@
-{"installed":" pi-hole","environment":{"anonaddy":{}}}
+{"installed":" transmission filerun","environment":{"anonaddy":{}}}

文件差异内容过多而无法显示
+ 1 - 14132
system-api/package-lock.json


+ 5 - 1
system-api/package.json

@@ -21,14 +21,18 @@
     "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/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",

+ 4 - 0
system-api/src/config/types.ts

@@ -17,7 +17,11 @@ interface FormField {
 
 
 export interface AppConfig {
 export interface AppConfig {
   id: string;
   id: string;
+  port: number;
   name: string;
   name: string;
+  requirements?: {
+    ports?: number[];
+  };
   description: string;
   description: string;
   version: string;
   version: string;
   image: string;
   image: string;

+ 53 - 85
system-api/src/modules/apps/apps.controller.ts

@@ -2,7 +2,8 @@ import { NextFunction, Request, Response } from 'express';
 import si from 'systeminformation';
 import si from 'systeminformation';
 import { appNames } from '../../config/apps';
 import { appNames } from '../../config/apps';
 import { AppConfig } from '../../config/types';
 import { AppConfig } from '../../config/types';
-import { createFolder, fileExists, readJsonFile, writeFile, runScript, deleteFolder, readFile } from '../fs/fs.helpers';
+import { createFolder, fileExists, readJsonFile, writeFile, readFile } from '../fs/fs.helpers';
+import { checkAppExists, checkAppRequirements, checkEnvFile, getInitalFormValues, runAppScript } from './apps.helpers';
 
 
 type AppsState = { installed: string };
 type AppsState = { installed: string };
 
 
@@ -11,15 +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}`);
-  const baseEnvFile = readFile('/.env');
-
-  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 = `${baseEnvFile}\n`;
+  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];
@@ -35,7 +30,7 @@ const generateEnvFile = (appName: string, form: Record<string, string>) => {
   writeFile(`/app-data/${appName}/app.env`, envFile);
   writeFile(`/app-data/${appName}/app.env`, envFile);
 };
 };
 
 
-const installApp = (req: Request, res: Response, next: NextFunction) => {
+const installApp = async (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
     const { id } = req.params;
     const { id } = req.params;
     const { form } = req.body;
     const { form } = req.body;
@@ -56,10 +51,14 @@ const installApp = (req: Request, res: Response, next: NextFunction) => {
       throw new Error(`App ${id} not available`);
       throw new Error(`App ${id} not available`);
     }
     }
 
 
+    const appIsValid = await checkAppRequirements(id);
+
+    if (!appIsValid) {
+      throw new Error(`App ${id} requirements not met`);
+    }
+
     // Create app folder
     // Create app folder
     createFolder(`/app-data/${id}`);
     createFolder(`/app-data/${id}`);
-    // Copy default app files from app-data folder
-    // copyFile(`/apps/${id}/data/`, `/app-data/${id}/data`);
 
 
     // Create env file
     // Create env file
     generateEnvFile(id, form);
     generateEnvFile(id, form);
@@ -68,19 +67,15 @@ const installApp = (req: Request, res: Response, next: NextFunction) => {
     writeFile('/state/apps.json', JSON.stringify(state));
     writeFile('/state/apps.json', JSON.stringify(state));
 
 
     // Run script
     // Run script
-    runScript('/scripts/app.sh', ['install', id], (err: any) => {
-      if (err) {
-        throw new Error(err);
-      }
+    await runAppScript(['install', id]);
 
 
-      res.status(200).json({ message: 'App installed successfully' });
-    });
+    res.status(200).json({ message: 'App installed successfully' });
   } catch (e) {
   } catch (e) {
     next(e);
     next(e);
   }
   }
 };
 };
 
 
-const uninstallApp = (req: Request, res: Response, next: NextFunction) => {
+const uninstallApp = async (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
     const { id: appName } = req.params;
     const { id: appName } = req.params;
 
 
@@ -88,11 +83,7 @@ const uninstallApp = (req: Request, res: Response, next: NextFunction) => {
       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);
 
 
     // Remove app from apps.json
     // Remove app from apps.json
     const state = getStateFile();
     const state = getStateFile();
@@ -101,19 +92,15 @@ const uninstallApp = (req: Request, res: Response, next: NextFunction) => {
     writeFile('/state/apps.json', JSON.stringify(state));
     writeFile('/state/apps.json', JSON.stringify(state));
 
 
     // Run script
     // Run script
-    runScript('/scripts/app.sh', ['uninstall', appName], (err: any) => {
-      if (err) {
-        throw new Error(err);
-      }
+    await runAppScript(['uninstall', appName]);
 
 
-      res.status(200).json({ message: 'App uninstalled successfully' });
-    });
+    res.status(200).json({ message: 'App uninstalled successfully' });
   } catch (e) {
   } catch (e) {
     next(e);
     next(e);
   }
   }
 };
 };
 
 
-const stopApp = (req: Request, res: Response) => {
+const stopApp = async (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
     const { id: appName } = req.params;
     const { id: appName } = req.params;
 
 
@@ -121,61 +108,35 @@ const stopApp = (req: Request, res: Response) => {
       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);
     // Run script
     // Run script
-    runScript('/scripts/app.sh', ['stop', appName], (err: string) => {
-      if (err) {
-        throw new Error(err);
-      }
+    await runAppScript(['stop', appName]);
 
 
-      res.status(200).json({ message: 'App stopped successfully' });
-    });
+    res.status(200).json({ message: 'App stopped successfully' });
   } catch (e) {
   } catch (e) {
-    res.status(500).end(e);
+    next(e);
   }
   }
 };
 };
 
 
-const updateAppConfig = (req: Request, res: Response) => {
+const updateAppConfig = async (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
-    const { appName, form } = 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}`);
-
-    if (!appExists) {
-      throw new Error(`App ${appName} not installed`);
-    }
-
+    checkAppExists(appName);
     generateEnvFile(appName, form);
     generateEnvFile(appName, form);
 
 
-    // Run script
-    runScript('/scripts/app.sh', ['stop', appName], (err: string) => {
-      if (err) {
-        throw new Error(err);
-      }
-
-      runScript('/scripts/app.sh', ['start', appName], (error: string) => {
-        if (error) {
-          throw new Error(error);
-        }
-
-        res.status(200).json({ message: 'App updated successfully' });
-      });
-    });
+    res.status(200).json({ message: 'App updated successfully' });
   } catch (e) {
   } catch (e) {
-    res.status(500).end(e);
+    next(e);
   }
   }
 };
 };
 
 
-const getAppInfo = async (req: Request, res: Response<AppConfig>) => {
+const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunction) => {
   try {
   try {
     const { id } = req.params;
     const { id } = req.params;
 
 
@@ -193,11 +154,11 @@ const getAppInfo = async (req: Request, res: Response<AppConfig>) => {
 
 
     res.status(200).json(configFile);
     res.status(200).json(configFile);
   } catch (e) {
   } catch (e) {
-    res.status(500).end(e);
+    next(e);
   }
   }
 };
 };
 
 
-const listApps = async (req: Request, res: Response) => {
+const listApps = async (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
     const apps = appNames
     const apps = appNames
       .map((app) => {
       .map((app) => {
@@ -221,11 +182,11 @@ const listApps = async (req: Request, res: Response) => {
 
 
     res.status(200).json(apps);
     res.status(200).json(apps);
   } catch (e) {
   } catch (e) {
-    res.status(500).end(e);
+    next(e);
   }
   }
 };
 };
 
 
-const startApp = (req: Request, res: Response) => {
+const startApp = async (req: Request, res: Response, next: NextFunction) => {
   try {
   try {
     const { id: appName } = req.params;
     const { id: appName } = req.params;
 
 
@@ -233,25 +194,31 @@ const startApp = (req: Request, res: Response) => {
       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', ['start', appName], (err: string) => {
-      if (err) {
-        throw new Error(err);
-      }
+    await runAppScript(['start', appName]);
 
 
-      res.status(200).json({ message: 'App started successfully' });
-    });
+    res.status(200).json({ message: 'App started successfully' });
   } catch (e) {
   } catch (e) {
-    res.status(500).end(e);
+    next(e);
+  }
+};
+
+const initalFormValues = (req: Request, res: Response, next: NextFunction) => {
+  try {
+    const { id } = req.params;
+
+    if (!id) {
+      throw new Error('App name is required');
+    }
+
+    res.status(200).json(getInitalFormValues(id));
+  } catch (e) {
+    next(e);
   }
   }
 };
 };
-// console.log('');
 
 
 const AppController = {
 const AppController = {
   uninstallApp,
   uninstallApp,
@@ -261,6 +228,7 @@ const AppController = {
   getAppInfo,
   getAppInfo,
   listApps,
   listApps,
   startApp,
   startApp,
+  initalFormValues,
 };
 };
 
 
 export default AppController;
 export default AppController;

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

@@ -0,0 +1,85 @@
+import portUsed from 'tcp-port-used';
+import p from 'p-iteration';
+import { AppConfig } from '../../config/types';
+import { fileExists, readFile, readJsonFile, runScript } 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();
+    });
+  });
+};

+ 2 - 0
system-api/src/modules/apps/apps.routes.ts

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

+ 8 - 0
system-api/src/modules/network/network.controller.ts

@@ -1,6 +1,7 @@
 import { Request, Response } from 'express';
 import { Request, Response } from 'express';
 import publicIp from 'public-ip';
 import publicIp from 'public-ip';
 import portScanner from 'node-port-scanner';
 import portScanner from 'node-port-scanner';
+import { internalIpV4 } from 'internal-ip';
 
 
 const isPortOpen = async (req: Request, res: Response<boolean>) => {
 const isPortOpen = async (req: Request, res: Response<boolean>) => {
   const { port } = req.params;
   const { port } = req.params;
@@ -12,8 +13,15 @@ const isPortOpen = async (req: Request, res: Response<boolean>) => {
   res.status(200).send(isOpen);
   res.status(200).send(isOpen);
 };
 };
 
 
+const getInternalIp = async (req: Request, res: Response<string>) => {
+  const ip = await internalIpV4();
+
+  res.status(200).send(ip);
+};
+
 const NetworkController = {
 const NetworkController = {
   isPortOpen,
   isPortOpen,
+  getInternalIp,
 };
 };
 
 
 export default NetworkController;
 export default NetworkController;

+ 8 - 0
system-api/src/modules/network/network.routes.ts

@@ -0,0 +1,8 @@
+import { Router } from 'express';
+import NetworkController from './network.controller';
+
+const router = Router();
+
+router.route('/internal-ip').get(NetworkController.getInternalIp);
+
+export default router;

+ 6 - 5
system-api/src/server.ts

@@ -1,10 +1,11 @@
-import express from 'express';
+import express, { NextFunction, Request, Response } from 'express';
 import compression from 'compression';
 import compression from 'compression';
 import helmet from 'helmet';
 import helmet from 'helmet';
 import cors from 'cors';
 import cors from 'cors';
 import { isProd } from './constants/constants';
 import { isProd } from './constants/constants';
 import appsRoutes from './modules/apps/apps.routes';
 import appsRoutes from './modules/apps/apps.routes';
 import systemRoutes from './modules/system/system.routes';
 import systemRoutes from './modules/system/system.routes';
+import networkRoutes from './modules/network/network.routes';
 
 
 const app = express();
 const app = express();
 const port = 3001;
 const port = 3001;
@@ -20,11 +21,11 @@ app.use(cors());
 
 
 app.use('/system', systemRoutes);
 app.use('/system', systemRoutes);
 app.use('/apps', appsRoutes);
 app.use('/apps', appsRoutes);
+app.use('/network', networkRoutes);
 
 
-app.use((err, req, res, next) => {
-  // logic
-  console.error('Middleware', err);
-  res.status(500).send('Something broke!');
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
+  res.status(200).json({ error: err.message });
 });
 });
 
 
 app.listen(port, () => {
 app.listen(port, () => {

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