Improve error handling

This commit is contained in:
Nicolas Meienberger 2022-04-13 23:47:07 +02:00
parent 5716a38dff
commit 251b0ba9a0
42 changed files with 732 additions and 14608 deletions

View file

@ -1,5 +1,6 @@
{
"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",

View file

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

View file

@ -1,5 +1,6 @@
{
"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… ",

View file

@ -6,7 +6,7 @@ services:
image: freshrss/freshrss:arm
restart: unless-stopped
ports:
- "${APP_FRESHRSS_PORT}:80"
- ${APP_PORT}:80
volumes:
- ${APP_DATA_DIR}/data/freshrss:/var/www/FreshRSS/data
- ${APP_DATA_DIR}/data/extensions/:/var/www/FreshRSS/extensions

View file

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

View file

@ -4,10 +4,9 @@ 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
- ${APP_DATA_DIR}/data/media:/data/media
environment:
- PUID=1000
- PGID=1000
@ -15,7 +14,7 @@ services:
# - JELLYFIN_PublishedServerUrl=192.168.0.5 #optional
restart: "unless-stopped"
ports:
- ${APP_JELLYFIN_PORT}:8096
- ${APP_PORT}:8096
networks:
- tipi_main_network
# Optional - alternative address used for autodiscovery

View file

@ -1,5 +1,6 @@
{
"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",

View file

@ -45,7 +45,7 @@ services:
image: nextcloud:22.1.1-apache
restart: unless-stopped
ports:
- ${APP_NEXTCLOUD_PORT}:80
- ${APP_PORT}:80
volumes:
- ${APP_DATA_DIR}/data/nextcloud:/var/www/html
environment:

View file

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

View file

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

View file

@ -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": {}
}

View file

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

View file

@ -1,5 +1,6 @@
{
"name": "Radarr",
"port": 8088,
"id": "radarr",
"description": "",
"short_desc": "",
@ -7,5 +8,22 @@
"source": "",
"image": "https://avatars.githubusercontent.com/u/25025331?s=200&v=4",
"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"
}
}
}

View file

@ -27,9 +27,9 @@ services:
volumes:
- ${APP_DATA_DIR}/data/config:/config
- ${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:
- ${APP_RADARR_PORT}:7878
- ${APP_PORT}:7878
restart: unless-stopped
networks:
- tipi_main_network

View file

@ -1,5 +1,6 @@
{
"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",

View file

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

View file

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

View file

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

View file

@ -1,5 +1,9 @@
{
"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",

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { Button } from '@chakra-ui/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';
interface IProps {
@ -9,30 +9,36 @@ interface IProps {
onUninstall: () => void;
onStart: () => 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) {
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
<FiPlay className="ml-1" />
</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
<FiTrash2 className="ml-1" />
</Button>
<Button onClick={onUpdate} width={160} className="mt-3 mr-2">
Settings
<FiSettings className="ml-1" />
</Button>
</div>
);
} else if (app?.installed && app.status === AppStatus.RUNNING) {
return (
<div>
<Button onClick={() => alert('open')} width={160} colorScheme="gray" className="mt-3">
<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 ml-2">
<Button onClick={onStop} width={160} colorScheme="red" className="mt-3">
Stop
<FiPause className="ml-2" />
</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) {
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">
Install
<FiPlay className="ml-1" />

View file

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

View file

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

View file

@ -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 { FiExternalLink } from 'react-icons/fi';
import { AppConfig } from '../../../core/types';
import { useAppsStore } from '../../../state/appsStore';
import { useNetworkStore } from '../../../state/networkStore';
import AppActions from '../components/AppActions';
import InstallModal from '../components/InstallModal';
import StopModal from '../components/StopModal';
import UninstallModal from '../components/UninstallModal';
import UpdateModal from '../components/UpdateModal';
interface IProps {
app: AppConfig;
}
const AppDetails: React.FC<IProps> = ({ app }) => {
const toast = useToast();
const installDisclosure = useDisclosure();
const uninstallDisclosure = useDisclosure();
const stopDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const { 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>) => {
installDisclosure.onClose();
await install(app.id, values);
try {
await install(app.id, values);
} catch (error) {
handleError(error);
}
};
const handleUnistallSubmit = async () => {
uninstallDisclosure.onClose();
await uninstall(app.id);
try {
await uninstall(app.id);
} catch (error) {
handleError(error);
}
};
const handleStopSubmit = async () => {
stopDisclosure.onClose();
await stop(app.id);
try {
await stop(app.id);
} catch (error) {
handleError(error);
}
};
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 (
@ -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">
<h1 className="font-bold text-2xl">{app?.name}</h1>
<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>
</div>
<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>
</VStack>
</Flex>
@ -64,6 +127,7 @@ const AppDetails: React.FC<IProps> = ({ 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} />
<UpdateModal onSubmit={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={app} />
</div>
</SlideFade>
);

View file

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

View file

@ -12,6 +12,7 @@ type AppsStore = {
getApp: (id: string) => AppConfig | undefined;
fetchApp: (id: string) => void;
install: (id: string, form: Record<string, string>) => Promise<void>;
update: (id: string, form: Record<string, string>) => Promise<void>;
uninstall: (id: string) => Promise<void>;
stop: (id: string) => Promise<void>;
start: (id: string) => Promise<void>;
@ -79,54 +80,47 @@ export const useAppsStore = create<AppsStore>((set, get) => ({
install: async (appId: string, form?: Record<string, string>) => {
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);
},
uninstall: async (appId: string) => {
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);
},
stop: async (appId: string) => {
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);
},
start: async (appId: string) => {
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);
},

View file

@ -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 });
},
}));

View file

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

View file

@ -106,8 +106,10 @@ compose() {
if [[ "$command" = "install" ]]; then
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
exit

View file

@ -35,17 +35,17 @@ docker-compose up --detach --remove-orphans --build || {
}
# 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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -21,14 +21,18 @@
"dotenv": "^16.0.0",
"express": "^4.17.3",
"helmet": "^5.0.2",
"internal-ip": "^7.0.0",
"node-port-scanner": "^3.0.1",
"p-iteration": "^1.1.8",
"public-ip": "^5.0.0",
"systeminformation": "^5.11.9"
"systeminformation": "^5.11.9",
"tcp-port-used": "^1.0.2"
},
"devDependencies": {
"@types/compression": "^1.7.2",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/tcp-port-used": "^1.0.1",
"@types/validator": "^13.7.2",
"concurrently": "^7.1.0",
"esbuild": "^0.14.32",

View file

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

View file

@ -2,7 +2,8 @@ import { NextFunction, Request, Response } from 'express';
import si from 'systeminformation';
import { appNames } from '../../config/apps';
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 };
@ -11,15 +12,9 @@ const getStateFile = (): AppsState => {
};
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`);
let envFile = `${baseEnvFile}\n`;
const baseEnvFile = readFile('/.env').toString();
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
Object.keys(configFile.form_fields).forEach((key) => {
const value = form[key];
@ -35,7 +30,7 @@ const generateEnvFile = (appName: string, form: Record<string, string>) => {
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 {
const { id } = req.params;
const { form } = req.body;
@ -56,10 +51,14 @@ const installApp = (req: Request, res: Response, next: NextFunction) => {
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
createFolder(`/app-data/${id}`);
// Copy default app files from app-data folder
// copyFile(`/apps/${id}/data/`, `/app-data/${id}/data`);
// Create env file
generateEnvFile(id, form);
@ -68,19 +67,15 @@ const installApp = (req: Request, res: Response, next: NextFunction) => {
writeFile('/state/apps.json', JSON.stringify(state));
// 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) {
next(e);
}
};
const uninstallApp = (req: Request, res: Response, next: NextFunction) => {
const uninstallApp = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id: appName } = req.params;
@ -88,11 +83,7 @@ const uninstallApp = (req: Request, res: Response, next: NextFunction) => {
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
const state = getStateFile();
@ -101,19 +92,15 @@ const uninstallApp = (req: Request, res: Response, next: NextFunction) => {
writeFile('/state/apps.json', JSON.stringify(state));
// 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) {
next(e);
}
};
const stopApp = (req: Request, res: Response) => {
const stopApp = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id: appName } = req.params;
@ -121,61 +108,35 @@ const stopApp = (req: Request, res: Response) => {
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
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) {
res.status(500).end(e);
next(e);
}
};
const updateAppConfig = (req: Request, res: Response) => {
const updateAppConfig = async (req: Request, res: Response, next: NextFunction) => {
try {
const { appName, form } = req.body;
const { id: appName } = req.params;
const { form } = req.body;
if (!appName) {
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);
// 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) {
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 {
const { id } = req.params;
@ -193,11 +154,11 @@ const getAppInfo = async (req: Request, res: Response<AppConfig>) => {
res.status(200).json(configFile);
} 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 {
const apps = appNames
.map((app) => {
@ -221,11 +182,11 @@ const listApps = async (req: Request, res: Response) => {
res.status(200).json(apps);
} 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 {
const { id: appName } = req.params;
@ -233,25 +194,31 @@ const startApp = (req: Request, res: Response) => {
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
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) {
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 = {
uninstallApp,
@ -261,6 +228,7 @@ const AppController = {
getAppInfo,
listApps,
startApp,
initalFormValues,
};
export default AppController;

View file

@ -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();
});
});
};

View file

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

View file

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

View file

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

View file

@ -1,10 +1,11 @@
import express from 'express';
import express, { NextFunction, Request, Response } from 'express';
import compression from 'compression';
import helmet from 'helmet';
import cors from 'cors';
import { isProd } from './constants/constants';
import appsRoutes from './modules/apps/apps.routes';
import systemRoutes from './modules/system/system.routes';
import networkRoutes from './modules/network/network.routes';
const app = express();
const port = 3001;
@ -20,11 +21,11 @@ app.use(cors());
app.use('/system', systemRoutes);
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, () => {