Browse Source

Control containers from the dashboard 🚀

Nicolas Meienberger 3 years ago
parent
commit
0066f5c931

+ 1 - 0
ansible/host_vars/tipi.yml

@@ -10,6 +10,7 @@ packages:
   - avahi
   - avahi
   - nodejs
   - nodejs
   - npm
   - npm
+  # - libusb
 
 
 username: nicolas
 username: nicolas
 
 

+ 10 - 0
apps/filerun/config.json

@@ -0,0 +1,10 @@
+{
+  "name": "FileRun",
+  "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": {}
+}

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

@@ -0,0 +1,39 @@
+services:
+  filerun-db:
+    container_name: filerun-db
+    image: mariadb:10.1
+    environment:
+      MYSQL_ROOT_PASSWORD: tipi
+      MYSQL_USER: tipi
+      MYSQL_PASSWORD: tipi
+      MYSQL_DATABASE: tipi
+    volumes:
+      - ${APP_DATA_DIR}/data/db:/var/lib/mysql
+    networks:
+      - tipi_main_network
+
+  filerun:
+    container_name: filerun
+    image: filerun/filerun
+    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: www-data
+      APACHE_RUN_USER_ID: 33
+      APACHE_RUN_GROUP: www-data
+      APACHE_RUN_GROUP_ID: 33
+    depends_on:
+      - db
+    links:
+      - db:db
+    ports:
+      - "${APP_FILERUN_PORT}:80"
+    volumes:
+      - ${APP_DATA_DIR}/data/html:/var/www/html
+      - ${ROOT_FOLDER}/app-data:/user-files
+    networks:
+      - tipi_main_network
+    

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

@@ -8,8 +8,8 @@ services:
     ports:
     ports:
       - "${APP_FRESHRSS_PORT}:80"
       - "${APP_FRESHRSS_PORT}:80"
     volumes:
     volumes:
-      - ${APP_DATA_DIR}/data/:/var/www/FreshRSS/data
-      - ${APP_DATA_DIR}/extensions/:/var/www/FreshRSS/extensions
+      - ${APP_DATA_DIR}/data/freshrss:/var/www/FreshRSS/data
+      - ${APP_DATA_DIR}/data/extensions/:/var/www/FreshRSS/extensions
     environment:
     environment:
       CRON_MIN: '*/20'
       CRON_MIN: '*/20'
       TZ: $TZ
       TZ: $TZ

+ 11 - 0
apps/jellyfin/config.json

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

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

@@ -0,0 +1,23 @@
+version: "3.7"
+
+services:
+  jellyfin:
+    image: lscr.io/linuxserver/jellyfin
+    container_name: jellyfin
+    # user: 1000:1000
+    volumes:
+      - ${APP_DATA_DIR}/data/config:/config
+      - ${ROOT_FOLDER}/app-data/transmission/data/downloads/complete:/data/movies
+    environment:
+      - PUID=1000
+      - PGID=1000
+      - TZ=Europe/London
+      # - JELLYFIN_PublishedServerUrl=192.168.0.5 #optional
+    restart: "unless-stopped"
+    ports:
+      - ${APP_JELLYFIN_PORT}:8096
+    networks:
+      - tipi_main_network
+    # Optional - alternative address used for autodiscovery
+    # environment:
+    #   - JELLYFIN_PublishedServerUrl=http://example.com

+ 2 - 2
apps/nextcloud/config.json

@@ -13,7 +13,7 @@
       "max": 50,
       "max": 50,
       "min": 3,
       "min": 3,
       "required": true,
       "required": true,
-      "env_variable": "NEXTCLOUD_USERNAME"
+      "env_variable": "NEXTCLOUD_ADMIN_USER"
     },
     },
     "password": {
     "password": {
       "type": "password",
       "type": "password",
@@ -21,7 +21,7 @@
       "max": 50,
       "max": 50,
       "min": 3,
       "min": 3,
       "required": true,
       "required": true,
-      "env_variable": "NEXTCLOUD_PASSWORD"
+      "env_variable": "NEXTCLOUD_ADMIN_PASSWORD"
     }
     }
   }
   }
 }
 }

+ 11 - 0
apps/plex/config.json

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

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

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

+ 11 - 0
apps/radarr/config.json

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

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

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

+ 27 - 0
apps/transmission/config.json

@@ -0,0 +1,27 @@
+{
+  "name": "Transmission",
+  "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"
+    }
+  }
+}

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

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

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

@@ -0,0 +1,25 @@
+{
+  "name": "Wireguard",
+  "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": "ip",
+      "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"
+    }
+  }
+}

+ 4 - 3
dashboard/src/components/Form/FormInput.tsx

@@ -8,14 +8,15 @@ interface IProps {
   type?: Parameters<typeof Input>[0]['type'];
   type?: Parameters<typeof Input>[0]['type'];
   label: string;
   label: string;
   className?: string;
   className?: string;
+  isInvalid?: boolean;
 }
 }
 
 
-const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, ...rest }) => {
+const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, ...rest }) => {
   return (
   return (
     <div className={clsx('transition-all', className)}>
     <div className={clsx('transition-all', className)}>
       <label>{label}</label>
       <label>{label}</label>
-      <Input type={type} placeholder={placeholder} isInvalid={Boolean(error)} {...rest} />
-      {error && <span className="text-red-500 text-sm">{error}</span>}
+      <Input type={type} placeholder={placeholder} isInvalid={isInvalid} {...rest} />
+      {isInvalid && <span className="text-red-500 text-sm">{error}</span>}
     </div>
     </div>
   );
   );
 };
 };

+ 12 - 3
dashboard/src/components/Form/validators.ts

@@ -1,4 +1,5 @@
-import { AppConfig } from '../../core/types';
+import validator from 'validator';
+import { AppConfig, FieldTypes } from '../../core/types';
 
 
 export const validateAppConfig = (values: Record<string, string>, fields: (AppConfig['form_fields'][0] & { id: string })[]) => {
 export const validateAppConfig = (values: Record<string, string>, fields: (AppConfig['form_fields'][0] & { id: string })[]) => {
   const errors: any = {};
   const errors: any = {};
@@ -6,10 +7,18 @@ export const validateAppConfig = (values: Record<string, string>, fields: (AppCo
   fields.forEach((field) => {
   fields.forEach((field) => {
     if (field.required && !values[field.id]) {
     if (field.required && !values[field.id]) {
       errors[field.id] = 'Field required';
       errors[field.id] = 'Field required';
-    } else if (field.min && values[field.id].length < field.min) {
+    } 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`;
       errors[field.id] = `Field must be at least ${field.min} characters long`;
-    } else if (field.max && values[field.id].length > field.max) {
+    } 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`;
       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';
     }
     }
   });
   });
 
 

+ 3 - 1
dashboard/src/core/api.ts

@@ -6,15 +6,17 @@ interface IFetchParams {
   endpoint: string;
   endpoint: string;
   method?: Method;
   method?: Method;
   params?: JSON;
   params?: JSON;
+  data?: Record<string, unknown>;
 }
 }
 
 
 const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
 const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
-  const { endpoint, method = 'GET', params } = fetchParams;
+  const { endpoint, method = 'GET', params, data } = fetchParams;
 
 
   try {
   try {
     const response = await axios.request<T>({
     const response = await axios.request<T>({
       method,
       method,
       params,
       params,
+      data,
       url: `${BASE_URL}${endpoint}`,
       url: `${BASE_URL}${endpoint}`,
     });
     });
 
 

+ 14 - 2
dashboard/src/core/types.ts

@@ -1,5 +1,14 @@
+export enum FieldTypes {
+  text = 'text',
+  password = 'password',
+  email = 'email',
+  number = 'number',
+  fqdn = 'fqdn',
+  ip = 'ip',
+}
+
 interface FormField {
 interface FormField {
-  type: string;
+  type: FieldTypes;
   label: string;
   label: string;
   max?: number;
   max?: number;
   min?: number;
   min?: number;
@@ -18,7 +27,7 @@ export interface AppConfig {
   author: string;
   author: string;
   source: string;
   source: string;
   installed: boolean;
   installed: boolean;
-  status: 'running' | 'stopped';
+  status: AppStatus;
 }
 }
 
 
 export enum RequestStatus {
 export enum RequestStatus {
@@ -31,4 +40,7 @@ export enum AppStatus {
   RUNNING = 'running',
   RUNNING = 'running',
   STOPPED = 'stopped',
   STOPPED = 'stopped',
   INSTALLING = 'installing',
   INSTALLING = 'installing',
+  UNINSTALLING = 'uninstalling',
+  STOPPING = 'stopping',
+  STARTING = 'starting',
 }
 }

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

@@ -1,7 +1,7 @@
 import { Button } from '@chakra-ui/react';
 import { Button } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
-import { FiPause, FiPlay, FiTrash2 } from 'react-icons/fi';
-import { AppConfig } from '../../../core/types';
+import { FiExternalLink, FiPause, FiPlay, FiTrash2 } from 'react-icons/fi';
+import { AppConfig, AppStatus } from '../../../core/types';
 
 
 interface IProps {
 interface IProps {
   app: AppConfig;
   app: AppConfig;
@@ -12,7 +12,7 @@ interface IProps {
 }
 }
 
 
 const AppActions: React.FC<IProps> = ({ app, onInstall, onUninstall, onStart, onStop }) => {
 const AppActions: React.FC<IProps> = ({ app, onInstall, onUninstall, onStart, onStop }) => {
-  if (app?.installed && app.status === 'stopped') {
+  if (app?.installed && app.status === AppStatus.STOPPED) {
     return (
     return (
       <div>
       <div>
         <Button onClick={onStart} width={160} colorScheme="green" className="mt-3">
         <Button onClick={onStart} width={160} colorScheme="green" className="mt-3">
@@ -25,12 +25,28 @@ const AppActions: React.FC<IProps> = ({ app, onInstall, onUninstall, onStart, on
         </Button>
         </Button>
       </div>
       </div>
     );
     );
-  } else if (app?.installed && app.status === 'running') {
+  } else if (app?.installed && app.status === AppStatus.RUNNING) {
     return (
     return (
-      <Button onClick={onStop} width={160} colorScheme="red" className="mt-3">
-        Stop
-        <FiPause className="ml-2" />
-      </Button>
+      <div>
+        <Button onClick={() => alert('open')} width={160} colorScheme="gray" className="mt-3">
+          Open
+          <FiExternalLink className="ml-1" />
+        </Button>
+        <Button onClick={onStop} width={160} colorScheme="red" className="mt-3 ml-2">
+          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">
+        <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>
     );
     );
   }
   }
 
 

+ 7 - 1
dashboard/src/modules/Apps/components/InstallForm.tsx

@@ -15,7 +15,13 @@ const InstallForm: React.FC<IProps> = ({ formFields, onSubmit }) => {
   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]) => {
-    return <Field key={field.id} name={field.id} render={({ input, meta }) => <FormInput className="mb-3" error={meta.error} label={field.label} {...input} />} />;
+    return (
+      <Field
+        key={field.id}
+        name={field.id}
+        render={({ input, meta }) => <FormInput className="mb-3" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} label={field.label} {...input} />}
+      />
+    );
   };
   };
 
 
   return (
   return (

+ 1 - 1
dashboard/src/modules/Apps/components/InstallModal.tsx

@@ -7,7 +7,7 @@ interface IProps {
   app: AppConfig;
   app: AppConfig;
   isOpen: boolean;
   isOpen: boolean;
   onClose: () => void;
   onClose: () => void;
-  onSubmit: (values: Record<string, unknown>) => void;
+  onSubmit: (values: Record<string, any>) => void;
 }
 }
 
 
 const InstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onSubmit }) => {
 const InstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onSubmit }) => {

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

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

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

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

+ 29 - 5
dashboard/src/modules/Apps/containers/AppDetails.tsx

@@ -2,18 +2,40 @@ import { SlideFade, Image, VStack, Flex, Divider, useDisclosure } from '@chakra-
 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 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 UninstallModal from '../components/UninstallModal';
 
 
 interface IProps {
 interface IProps {
   app: AppConfig;
   app: AppConfig;
 }
 }
 
 
 const AppDetails: React.FC<IProps> = ({ app }) => {
 const AppDetails: React.FC<IProps> = ({ app }) => {
-  const { isOpen, onOpen, onClose } = useDisclosure();
+  const installDisclosure = useDisclosure();
+  const uninstallDisclosure = useDisclosure();
+  const stopDisclosure = useDisclosure();
 
 
-  const handleInstallSubmit = (values: Record<string, unknown>) => {
-    console.error(values);
+  const { install, uninstall, stop, start } = useAppsStore((state) => state);
+
+  const handleInstallSubmit = async (values: Record<string, any>) => {
+    installDisclosure.onClose();
+    await install(app.id, values);
+  };
+
+  const handleUnistallSubmit = async () => {
+    uninstallDisclosure.onClose();
+    await uninstall(app.id);
+  };
+
+  const handleStopSubmit = async () => {
+    stopDisclosure.onClose();
+    await stop(app.id);
+  };
+
+  const handleStartSubmit = async () => {
+    await start(app.id);
   };
   };
 
 
   return (
   return (
@@ -33,13 +55,15 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
               <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={() => null} onStop={() => null} onUninstall={() => null} onInstall={onOpen} app={app} />
+              <AppActions onStart={handleStartSubmit} onStop={stopDisclosure.onOpen} onUninstall={uninstallDisclosure.onOpen} onInstall={installDisclosure.onOpen} app={app} />
             </div>
             </div>
           </VStack>
           </VStack>
         </Flex>
         </Flex>
         <Divider className="mt-5" />
         <Divider className="mt-5" />
         <p className="mt-3">{app?.description}</p>
         <p className="mt-3">{app?.description}</p>
-        <InstallModal onSubmit={handleInstallSubmit} isOpen={isOpen} onClose={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} />
+        <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={app} />
       </div>
       </div>
     </SlideFade>
     </SlideFade>
   );
   );

+ 7 - 6
dashboard/src/pages/apps/[id].tsx

@@ -4,13 +4,12 @@ import Layout from '../../components/Layout';
 import { useAppsStore } from '../../state/appsStore';
 import { useAppsStore } from '../../state/appsStore';
 import AppDetails from '../../modules/Apps/containers/AppDetails';
 import AppDetails from '../../modules/Apps/containers/AppDetails';
 
 
-interface Props {
+interface IProps {
   appId: string;
   appId: string;
 }
 }
 
 
-const AppDetailsPage: NextPage<Props> = ({ appId }) => {
-  const { getApp, fetchApp } = useAppsStore((state) => state);
-
+const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
+  const { fetchApp, getApp } = useAppsStore((state) => state);
   const app = getApp(appId);
   const app = getApp(appId);
 
 
   useEffect(() => {
   useEffect(() => {
@@ -29,8 +28,10 @@ const AppDetailsPage: NextPage<Props> = ({ appId }) => {
   );
   );
 };
 };
 
 
-AppDetailsPage.getInitialProps = async ({ query, pathname }) => {
-  const appId = (query.id as string) || pathname.split('/')[1];
+AppDetailsPage.getInitialProps = async (ctx) => {
+  const { query } = ctx;
+
+  const appId = query.id as string;
 
 
   return { appId };
   return { appId };
 };
 };

+ 95 - 32
dashboard/src/state/appsStore.ts

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

+ 5 - 6
scripts/app.sh

@@ -89,10 +89,13 @@ compose() {
   export APP_DATA_DIR="${app_data_dir}"
   export APP_DATA_DIR="${app_data_dir}"
   export APP_PASSWORD="password"
   export APP_PASSWORD="password"
   export APP_DIR="${app_dir}"
   export APP_DIR="${app_dir}"
+  export ROOT_FOLDER="${ROOT_FOLDER}"
+
+  # Docker-compose does not support multiple env files
+  # --env-file "${env_file}" \
 
 
   docker-compose \
   docker-compose \
-    --env-file "${env_file}" \
-    --env-file "${app_dir}/.env" \
+    --env-file "${ROOT_FOLDER}/app-data/${app}/app.env" \
     --project-name "${app}" \
     --project-name "${app}" \
     --file "${app_compose_file}" \
     --file "${app_compose_file}" \
     --file "${common_compose_file}" \
     --file "${common_compose_file}" \
@@ -109,7 +112,6 @@ fi
 
 
 # Removes images and destroys all data for an app
 # Removes images and destroys all data for an app
 if [[ "$command" = "uninstall" ]]; then
 if [[ "$command" = "uninstall" ]]; then
-
   echo "Removing images for app ${app}..."
   echo "Removing images for app ${app}..."
   compose "${app}" down --rmi all --remove-orphans
   compose "${app}" down --rmi all --remove-orphans
 
 
@@ -118,9 +120,6 @@ if [[ "$command" = "uninstall" ]]; then
     rm -rf "${app_data_dir}"
     rm -rf "${app_data_dir}"
   fi
   fi
 
 
-  echo "Removing app ${app} from DB..."
-  update_installed_apps remove "${app}"
-
   echo "Successfully uninstalled app ${app}"
   echo "Successfully uninstalled app ${app}"
   exit
   exit
 fi
 fi

+ 1 - 6
state/apps.json

@@ -1,6 +1 @@
-{
-  "installed": "nextcloud",
-  "environment": {
-    "anonaddy": {}
-  }
-}
+{"installed":" filerun nextcloud wg-easy freshrss radarr transmission plex jellyfin","environment":{"anonaddy":{}}}