Frontend GraphQL queries

This commit is contained in:
Nicolas Meienberger 2022-06-21 21:53:49 +02:00
parent 537cdcd811
commit 729c2311f5
46 changed files with 797 additions and 641 deletions

View file

@ -51,7 +51,7 @@ export interface AppConfig {
ports?: number[];
};
description: string;
version: string;
version?: string;
image: string;
form_fields: FormField[];
short_desc: string;

View file

@ -1,7 +1,7 @@
import validator from 'validator';
import { AppConfig, FieldTypes } from '@runtipi/common';
import { FieldTypesEnum, FormField } from '../../generated/graphql';
const validateField = (field: AppConfig['form_fields'][0], value: string): string | undefined => {
const validateField = (field: FormField, value: string): string | undefined => {
if (field.required && !value) {
return `${field.label} is required`;
}
@ -11,7 +11,7 @@ const validateField = (field: AppConfig['form_fields'][0], value: string): strin
}
switch (field.type) {
case FieldTypes.text:
case FieldTypesEnum.Text:
if (field.max && value.length > field.max) {
return `${field.label} must be less than ${field.max} characters`;
}
@ -19,37 +19,37 @@ const validateField = (field: AppConfig['form_fields'][0], value: string): strin
return `${field.label} must be at least ${field.min} characters`;
}
break;
case FieldTypes.password:
if (!validator.isLength(value, { min: field.min, max: field.max })) {
case FieldTypesEnum.Password:
if (!validator.isLength(value, { min: field.min || 0, max: field.max || 100 })) {
return `${field.label} must be between ${field.min} and ${field.max} characters`;
}
break;
case FieldTypes.email:
case FieldTypesEnum.Email:
if (!validator.isEmail(value)) {
return `${field.label} must be a valid email address`;
}
break;
case FieldTypes.number:
case FieldTypesEnum.Number:
if (!validator.isNumeric(value)) {
return `${field.label} must be a number`;
}
break;
case FieldTypes.fqdn:
case FieldTypesEnum.Fqdn:
if (!validator.isFQDN(value)) {
return `${field.label} must be a valid domain`;
}
break;
case FieldTypes.ip:
case FieldTypesEnum.Ip:
if (!validator.isIP(value)) {
return `${field.label} must be a valid IP address`;
}
break;
case FieldTypes.fqdnip:
case FieldTypesEnum.Fqdnip:
if (!validator.isFQDN(value || '') && !validator.isIP(value)) {
return `${field.label} must be a valid domain or IP address`;
}
break;
case FieldTypes.url:
case FieldTypesEnum.Url:
if (!validator.isURL(value)) {
return `${field.label} must be a valid URL`;
}
@ -59,11 +59,11 @@ const validateField = (field: AppConfig['form_fields'][0], value: string): strin
}
};
export const validateAppConfig = (values: Record<string, string>, fields: (AppConfig['form_fields'][0] & { id: string })[]) => {
export const validateAppConfig = (values: Record<string, string>, fields: FormField[]) => {
const errors: any = {};
fields.forEach((field) => {
errors[field.id] = validateField(field, values[field.id]);
errors[field.env_variable] = validateField(field, values[field.env_variable]);
});
return errors;

View file

@ -8,12 +8,12 @@ import Link from 'next/link';
import clsx from 'clsx';
import { useRouter } from 'next/router';
import { IconType } from 'react-icons';
import { useAuthStore } from '../../state/authStore';
import { useLogoutMutation } from '../../generated/graphql';
const SideMenu: React.FC = () => {
const router = useRouter();
const { colorMode, setColorMode } = useColorMode();
const { logout } = useAuthStore();
const [logout] = useLogoutMutation({ refetchQueries: ['Me'] });
const path = router.pathname.split('/')[1];
const renderMenuItem = (title: string, name: string, Icon: IconType) => {
@ -53,7 +53,7 @@ const SideMenu: React.FC = () => {
<Flex flex="1" />
<List>
<div className="mx-3">
<ListItem onClick={logout} className="cursor-pointer hover:font-bold flex items-center mb-5">
<ListItem onClick={() => logout()} className="cursor-pointer hover:font-bold flex items-center mb-5">
<FiLogOut size={20} className="mr-3" />
<p className="flex-1">Log out</p>
</ListItem>

View file

@ -21,19 +21,34 @@ export type Scalars = {
export type App = {
__typename?: 'App';
config: Scalars['JSONObject'];
createdAt: Scalars['DateTime'];
id: Scalars['String'];
lastOpened: Scalars['DateTime'];
numOpened: Scalars['Float'];
status: Scalars['String'];
status: AppStatusEnum;
updatedAt: Scalars['DateTime'];
};
export type AppConfig = {
__typename?: 'AppConfig';
export enum AppCategoriesEnum {
Automation = 'AUTOMATION',
Books = 'BOOKS',
Data = 'DATA',
Development = 'DEVELOPMENT',
Featured = 'FEATURED',
Media = 'MEDIA',
Network = 'NETWORK',
Photography = 'PHOTOGRAPHY',
Security = 'SECURITY',
Social = 'SOCIAL',
Utilities = 'UTILITIES'
}
export type AppInfo = {
__typename?: 'AppInfo';
author: Scalars['String'];
available: Scalars['Boolean'];
categories: Array<Scalars['String']>;
categories: Array<AppCategoriesEnum>;
description: Scalars['String'];
form_fields: Array<FormField>;
id: Scalars['String'];
@ -43,7 +58,6 @@ export type AppConfig = {
port: Scalars['Float'];
short_desc: Scalars['String'];
source: Scalars['String'];
status: Scalars['String'];
url_suffix?: Maybe<Scalars['String']>;
version?: Maybe<Scalars['String']>;
};
@ -53,6 +67,44 @@ export type AppInputType = {
id: Scalars['String'];
};
export type AppResponse = {
__typename?: 'AppResponse';
app?: Maybe<App>;
info: AppInfo;
};
export enum AppStatusEnum {
Installing = 'INSTALLING',
Running = 'RUNNING',
Starting = 'STARTING',
Stopped = 'STOPPED',
Stopping = 'STOPPING',
Uninstalling = 'UNINSTALLING'
}
export type Cpu = {
__typename?: 'Cpu';
load: Scalars['Float'];
};
export type DiskMemory = {
__typename?: 'DiskMemory';
available: Scalars['Float'];
total: Scalars['Float'];
used: Scalars['Float'];
};
export enum FieldTypesEnum {
Email = 'email',
Fqdn = 'fqdn',
Fqdnip = 'fqdnip',
Ip = 'ip',
Number = 'number',
Password = 'password',
Text = 'text',
Url = 'url'
}
export type FormField = {
__typename?: 'FormField';
env_variable: Scalars['String'];
@ -61,12 +113,12 @@ export type FormField = {
max?: Maybe<Scalars['Float']>;
min?: Maybe<Scalars['Float']>;
required?: Maybe<Scalars['Boolean']>;
type: Scalars['String'];
type: FieldTypesEnum;
};
export type ListAppsResonse = {
__typename?: 'ListAppsResonse';
apps: Array<AppConfig>;
apps: Array<AppInfo>;
total: Scalars['Float'];
};
@ -82,47 +134,64 @@ export type Mutation = {
updateAppConfig: App;
};
export type MutationInstallAppArgs = {
input: AppInputType;
};
export type MutationLoginArgs = {
input: UsernamePasswordInput;
};
export type MutationRegisterArgs = {
input: UsernamePasswordInput;
};
export type MutationStartAppArgs = {
id: Scalars['String'];
};
export type MutationStopAppArgs = {
id: Scalars['String'];
};
export type MutationUninstallAppArgs = {
id: Scalars['String'];
};
export type MutationUpdateAppConfigArgs = {
input: AppInputType;
};
export type Query = {
__typename?: 'Query';
getAppInfo: AppConfig;
getApp: AppResponse;
installedApps: Array<App>;
isConfigured: Scalars['Boolean'];
listAppsInfo: ListAppsResonse;
me?: Maybe<User>;
systemInfo?: Maybe<SystemInfoResponse>;
version: Scalars['String'];
};
export type QueryGetAppInfoArgs = {
export type QueryGetAppArgs = {
id: Scalars['String'];
};
export type SystemInfoResponse = {
__typename?: 'SystemInfoResponse';
cpu: Cpu;
disk: DiskMemory;
memory: DiskMemory;
};
export type User = {
__typename?: 'User';
createdAt: Scalars['DateTime'];
@ -145,25 +214,63 @@ export type LoginMutationVariables = Exact<{
input: UsernamePasswordInput;
}>;
export type LoginMutation = { __typename?: 'Mutation'; login: { __typename?: 'UserResponse'; user?: { __typename?: 'User'; id: string } | null } };
export type ConfiguredQueryVariables = Exact<{ [key: string]: never }>;
export type LoginMutation = { __typename?: 'Mutation', login: { __typename?: 'UserResponse', user?: { __typename?: 'User', id: string } | null } };
export type ConfiguredQuery = { __typename?: 'Query'; isConfigured: boolean };
export type LogoutMutationVariables = Exact<{ [key: string]: never; }>;
export type MeQueryVariables = Exact<{ [key: string]: never }>;
export type MeQuery = { __typename?: 'Query'; me?: { __typename?: 'User'; id: string } | null };
export type LogoutMutation = { __typename?: 'Mutation', logout: boolean };
export type RegisterMutationVariables = Exact<{
input: UsernamePasswordInput;
}>;
export type RegisterMutation = { __typename?: 'Mutation', register: { __typename?: 'UserResponse', user?: { __typename?: 'User', id: string } | null } };
export type GetAppQueryVariables = Exact<{
appId: Scalars['String'];
}>;
export type GetAppQuery = { __typename?: 'Query', getApp: { __typename?: 'AppResponse', app?: { __typename?: 'App', id: string, status: AppStatusEnum, config: any } | null, info: { __typename?: 'AppInfo', id: string, port: number, name: string, description: string, available: boolean, version?: string | null, image: string, short_desc: string, author: string, source: string, installed: boolean, categories: Array<AppCategoriesEnum>, url_suffix?: string | null, form_fields: Array<{ __typename?: 'FormField', type: FieldTypesEnum, label: string, max?: number | null, min?: number | null, hint?: string | null, required?: boolean | null, env_variable: string }> } } };
export type InstalledAppsQueryVariables = Exact<{ [key: string]: never; }>;
export type InstalledAppsQuery = { __typename?: 'Query', installedApps: Array<{ __typename?: 'App', id: string, status: AppStatusEnum, config: any }> };
export type ConfiguredQueryVariables = Exact<{ [key: string]: never; }>;
export type ConfiguredQuery = { __typename?: 'Query', isConfigured: boolean };
export type ListAppsQueryVariables = Exact<{ [key: string]: never; }>;
export type ListAppsQuery = { __typename?: 'Query', listAppsInfo: { __typename?: 'ListAppsResonse', total: number, apps: Array<{ __typename?: 'AppInfo', id: string, available: boolean, installed: boolean, image: string, port: number, name: string, version?: string | null, short_desc: string, author: string, categories: Array<AppCategoriesEnum> }> } };
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
export type MeQuery = { __typename?: 'Query', me?: { __typename?: 'User', id: string } | null };
export type SystemInfoQueryVariables = Exact<{ [key: string]: never; }>;
export type SystemInfoQuery = { __typename?: 'Query', systemInfo?: { __typename?: 'SystemInfoResponse', cpu: { __typename?: 'Cpu', load: number }, disk: { __typename?: 'DiskMemory', available: number, used: number, total: number }, memory: { __typename?: 'DiskMemory', available: number, used: number, total: number } } | null };
export const LoginDocument = gql`
mutation Login($input: UsernamePasswordInput!) {
login(input: $input) {
user {
id
}
mutation Login($input: UsernamePasswordInput!) {
login(input: $input) {
user {
id
}
}
`;
}
`;
export type LoginMutationFn = Apollo.MutationFunction<LoginMutation, LoginMutationVariables>;
/**
@ -184,17 +291,181 @@ export type LoginMutationFn = Apollo.MutationFunction<LoginMutation, LoginMutati
* });
*/
export function useLoginMutation(baseOptions?: Apollo.MutationHookOptions<LoginMutation, LoginMutationVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useMutation<LoginMutation, LoginMutationVariables>(LoginDocument, options);
}
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<LoginMutation, LoginMutationVariables>(LoginDocument, options);
}
export type LoginMutationHookResult = ReturnType<typeof useLoginMutation>;
export type LoginMutationResult = Apollo.MutationResult<LoginMutation>;
export type LoginMutationOptions = Apollo.BaseMutationOptions<LoginMutation, LoginMutationVariables>;
export const ConfiguredDocument = gql`
query Configured {
isConfigured
export const LogoutDocument = gql`
mutation Logout {
logout
}
`;
export type LogoutMutationFn = Apollo.MutationFunction<LogoutMutation, LogoutMutationVariables>;
/**
* __useLogoutMutation__
*
* To run a mutation, you first call `useLogoutMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useLogoutMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [logoutMutation, { data, loading, error }] = useLogoutMutation({
* variables: {
* },
* });
*/
export function useLogoutMutation(baseOptions?: Apollo.MutationHookOptions<LogoutMutation, LogoutMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<LogoutMutation, LogoutMutationVariables>(LogoutDocument, options);
}
export type LogoutMutationHookResult = ReturnType<typeof useLogoutMutation>;
export type LogoutMutationResult = Apollo.MutationResult<LogoutMutation>;
export type LogoutMutationOptions = Apollo.BaseMutationOptions<LogoutMutation, LogoutMutationVariables>;
export const RegisterDocument = gql`
mutation Register($input: UsernamePasswordInput!) {
register(input: $input) {
user {
id
}
}
`;
}
`;
export type RegisterMutationFn = Apollo.MutationFunction<RegisterMutation, RegisterMutationVariables>;
/**
* __useRegisterMutation__
*
* To run a mutation, you first call `useRegisterMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useRegisterMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [registerMutation, { data, loading, error }] = useRegisterMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useRegisterMutation(baseOptions?: Apollo.MutationHookOptions<RegisterMutation, RegisterMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<RegisterMutation, RegisterMutationVariables>(RegisterDocument, options);
}
export type RegisterMutationHookResult = ReturnType<typeof useRegisterMutation>;
export type RegisterMutationResult = Apollo.MutationResult<RegisterMutation>;
export type RegisterMutationOptions = Apollo.BaseMutationOptions<RegisterMutation, RegisterMutationVariables>;
export const GetAppDocument = gql`
query GetApp($appId: String!) {
getApp(id: $appId) {
app {
id
status
config
}
info {
id
port
name
description
available
version
image
short_desc
author
source
installed
categories
url_suffix
form_fields {
type
label
max
min
hint
required
env_variable
}
}
}
}
`;
/**
* __useGetAppQuery__
*
* To run a query within a React component, call `useGetAppQuery` and pass it any options that fit your needs.
* When your component renders, `useGetAppQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetAppQuery({
* variables: {
* appId: // value for 'appId'
* },
* });
*/
export function useGetAppQuery(baseOptions: Apollo.QueryHookOptions<GetAppQuery, GetAppQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetAppQuery, GetAppQueryVariables>(GetAppDocument, options);
}
export function useGetAppLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetAppQuery, GetAppQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetAppQuery, GetAppQueryVariables>(GetAppDocument, options);
}
export type GetAppQueryHookResult = ReturnType<typeof useGetAppQuery>;
export type GetAppLazyQueryHookResult = ReturnType<typeof useGetAppLazyQuery>;
export type GetAppQueryResult = Apollo.QueryResult<GetAppQuery, GetAppQueryVariables>;
export const InstalledAppsDocument = gql`
query InstalledApps {
installedApps {
id
status
config
}
}
`;
/**
* __useInstalledAppsQuery__
*
* To run a query within a React component, call `useInstalledAppsQuery` and pass it any options that fit your needs.
* When your component renders, `useInstalledAppsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useInstalledAppsQuery({
* variables: {
* },
* });
*/
export function useInstalledAppsQuery(baseOptions?: Apollo.QueryHookOptions<InstalledAppsQuery, InstalledAppsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<InstalledAppsQuery, InstalledAppsQueryVariables>(InstalledAppsDocument, options);
}
export function useInstalledAppsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<InstalledAppsQuery, InstalledAppsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<InstalledAppsQuery, InstalledAppsQueryVariables>(InstalledAppsDocument, options);
}
export type InstalledAppsQueryHookResult = ReturnType<typeof useInstalledAppsQuery>;
export type InstalledAppsLazyQueryHookResult = ReturnType<typeof useInstalledAppsLazyQuery>;
export type InstalledAppsQueryResult = Apollo.QueryResult<InstalledAppsQuery, InstalledAppsQueryVariables>;
export const ConfiguredDocument = gql`
query Configured {
isConfigured
}
`;
/**
* __useConfiguredQuery__
@ -212,23 +483,69 @@ export const ConfiguredDocument = gql`
* });
*/
export function useConfiguredQuery(baseOptions?: Apollo.QueryHookOptions<ConfiguredQuery, ConfiguredQueryVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useQuery<ConfiguredQuery, ConfiguredQueryVariables>(ConfiguredDocument, options);
}
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ConfiguredQuery, ConfiguredQueryVariables>(ConfiguredDocument, options);
}
export function useConfiguredLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ConfiguredQuery, ConfiguredQueryVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useLazyQuery<ConfiguredQuery, ConfiguredQueryVariables>(ConfiguredDocument, options);
}
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ConfiguredQuery, ConfiguredQueryVariables>(ConfiguredDocument, options);
}
export type ConfiguredQueryHookResult = ReturnType<typeof useConfiguredQuery>;
export type ConfiguredLazyQueryHookResult = ReturnType<typeof useConfiguredLazyQuery>;
export type ConfiguredQueryResult = Apollo.QueryResult<ConfiguredQuery, ConfiguredQueryVariables>;
export const MeDocument = gql`
query Me {
me {
export const ListAppsDocument = gql`
query ListApps {
listAppsInfo {
apps {
id
available
installed
image
port
name
version
short_desc
author
categories
}
total
}
`;
}
`;
/**
* __useListAppsQuery__
*
* To run a query within a React component, call `useListAppsQuery` and pass it any options that fit your needs.
* When your component renders, `useListAppsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useListAppsQuery({
* variables: {
* },
* });
*/
export function useListAppsQuery(baseOptions?: Apollo.QueryHookOptions<ListAppsQuery, ListAppsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ListAppsQuery, ListAppsQueryVariables>(ListAppsDocument, options);
}
export function useListAppsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ListAppsQuery, ListAppsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ListAppsQuery, ListAppsQueryVariables>(ListAppsDocument, options);
}
export type ListAppsQueryHookResult = ReturnType<typeof useListAppsQuery>;
export type ListAppsLazyQueryHookResult = ReturnType<typeof useListAppsLazyQuery>;
export type ListAppsQueryResult = Apollo.QueryResult<ListAppsQuery, ListAppsQueryVariables>;
export const MeDocument = gql`
query Me {
me {
id
}
}
`;
/**
* __useMeQuery__
@ -246,13 +563,59 @@ export const MeDocument = gql`
* });
*/
export function useMeQuery(baseOptions?: Apollo.QueryHookOptions<MeQuery, MeQueryVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useQuery<MeQuery, MeQueryVariables>(MeDocument, options);
}
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<MeQuery, MeQueryVariables>(MeDocument, options);
}
export function useMeLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<MeQuery, MeQueryVariables>) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useLazyQuery<MeQuery, MeQueryVariables>(MeDocument, options);
}
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<MeQuery, MeQueryVariables>(MeDocument, options);
}
export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
export type MeQueryResult = Apollo.QueryResult<MeQuery, MeQueryVariables>;
export const SystemInfoDocument = gql`
query SystemInfo {
systemInfo {
cpu {
load
}
disk {
available
used
total
}
memory {
available
used
total
}
}
}
`;
/**
* __useSystemInfoQuery__
*
* To run a query within a React component, call `useSystemInfoQuery` and pass it any options that fit your needs.
* When your component renders, `useSystemInfoQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useSystemInfoQuery({
* variables: {
* },
* });
*/
export function useSystemInfoQuery(baseOptions?: Apollo.QueryHookOptions<SystemInfoQuery, SystemInfoQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<SystemInfoQuery, SystemInfoQueryVariables>(SystemInfoDocument, options);
}
export function useSystemInfoLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SystemInfoQuery, SystemInfoQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<SystemInfoQuery, SystemInfoQueryVariables>(SystemInfoDocument, options);
}
export type SystemInfoQueryHookResult = ReturnType<typeof useSystemInfoQuery>;
export type SystemInfoLazyQueryHookResult = ReturnType<typeof useSystemInfoLazyQuery>;
export type SystemInfoQueryResult = Apollo.QueryResult<SystemInfoQuery, SystemInfoQueryVariables>;

View file

@ -0,0 +1,3 @@
mutation Logout {
logout
}

View file

@ -0,0 +1,7 @@
mutation Register($input: UsernamePasswordInput!) {
register(input: $input) {
user {
id
}
}
}

View file

@ -0,0 +1,33 @@
query GetApp($appId: String!) {
getApp(id: $appId) {
app {
id
status
config
}
info {
id
port
name
description
available
version
image
short_desc
author
source
installed
categories
url_suffix
form_fields {
type
label
max
min
hint
required
env_variable
}
}
}
}

View file

@ -0,0 +1,7 @@
query InstalledApps {
installedApps {
id
status
config
}
}

View file

@ -0,0 +1,18 @@
# Write your query or mutation here
query ListApps {
listAppsInfo {
apps {
id
available
installed
image
port
name
version
short_desc
author
categories
}
total
}
}

View file

@ -0,0 +1,17 @@
query SystemInfo {
systemInfo {
cpu {
load
}
disk {
available
used
total
}
memory {
available
used
total
}
}
}

View file

@ -1,12 +1,12 @@
import { Flex, Input, SimpleGrid } from '@chakra-ui/react';
import { AppCategoriesEnum, AppConfig } from '@runtipi/common';
import { AppCategoriesEnum } from '@runtipi/common';
import React from 'react';
import { SortableColumns, SortDirection } from '../helpers/table.types';
import { AppTableData, SortableColumns, SortDirection } from '../helpers/table.types';
import AppStoreTile from './AppStoreTile';
import CategorySelect from './CategorySelect';
interface IProps {
data: AppConfig[];
data: AppTableData;
onSearch: (value: string) => void;
onSelectCategories: (value: AppCategoriesEnum[]) => void;
onSortBy: (value: SortableColumns) => void;

View file

@ -1,11 +1,19 @@
import { Tag, TagLabel } from '@chakra-ui/react';
import { AppConfig } from '@runtipi/common';
import { AppCategoriesEnum } from '@runtipi/common';
import Link from 'next/link';
import React from 'react';
import AppLogo from '../../../components/AppLogo/AppLogo';
import { colorSchemeForCategory, limitText } from '../helpers/table.helpers';
const AppStoreTile: React.FC<{ app: AppConfig }> = ({ app }) => {
type App = {
id: string;
name: string;
categories: string[];
short_desc: string;
image: string;
};
const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
return (
<Link href={`/app-store/${app.id}`} passHref>
<div key={app.id} className="p-2 rounded-md app-store-tile flex items-center group">
@ -14,7 +22,7 @@ const AppStoreTile: React.FC<{ app: AppConfig }> = ({ app }) => {
<div className="font-bold">{limitText(app.name, 20)}</div>
<div className="text-sm mb-1">{limitText(app.short_desc, 45)}</div>
{app.categories?.map((category) => (
<Tag colorScheme={colorSchemeForCategory[category]} className="mr-1" borderRadius="full" key={`${app.id}-${category}`} size="sm" variant="solid">
<Tag colorScheme={colorSchemeForCategory[category as AppCategoriesEnum]} className="mr-1" borderRadius="full" key={`${app.id}-${category}`} size="sm" variant="solid">
<TagLabel>{category}</TagLabel>
</Tag>
))}

View file

@ -1,17 +1,19 @@
import { Flex } from '@chakra-ui/react';
import { AppCategoriesEnum } from '@runtipi/common';
import React from 'react';
import { useAppsStore } from '../../../state/appsStore';
import AppStoreTable from '../components/AppStoreTable';
import { sortTable } from '../helpers/table.helpers';
import { SortableColumns, SortDirection } from '../helpers/table.types';
import { AppTableData, SortableColumns, SortDirection } from '../helpers/table.types';
// function nonNullable<T>(value: T): value is NonNullable<T> {
// return value !== null && value !== undefined;
// }
const AppStoreContainer = () => {
const { apps } = useAppsStore();
interface IProps {
apps: AppTableData;
}
const AppStoreContainer: React.FC<IProps> = ({ apps }) => {
const [search, setSearch] = React.useState('');
const [categories, setCategories] = React.useState<AppCategoriesEnum[]>([]);
const [sort, setSort] = React.useState<SortableColumns>('name');

View file

@ -1,6 +1,8 @@
import { AppCategoriesEnum, AppConfig } from '@runtipi/common';
import { AppCategoriesEnum } from '@runtipi/common';
import { AppConfig } from '../../../generated/graphql';
import { AppTableData } from './table.types';
export const sortTable = (data: AppConfig[], col: keyof Pick<AppConfig, 'name'>, direction: 'asc' | 'desc', categories: AppCategoriesEnum[], search: string) => {
export const sortTable = (data: AppTableData, col: keyof Pick<AppConfig, 'name'>, direction: 'asc' | 'desc', categories: AppCategoriesEnum[], search: string) => {
const sortedData = [...data].sort((a, b) => {
const aVal = a[col];
const bVal = b[col];
@ -14,7 +16,7 @@ export const sortTable = (data: AppConfig[], col: keyof Pick<AppConfig, 'name'>,
});
if (categories.length > 0) {
return sortedData.filter((app) => app.categories.some((c) => categories.includes(c))).filter((app) => app.name.toLowerCase().includes(search.toLowerCase()));
return sortedData.filter((app) => app.categories.some((c) => categories.includes(c as AppCategoriesEnum))).filter((app) => app.name.toLowerCase().includes(search.toLowerCase()));
} else {
return sortedData.filter((app) => app.name.toLowerCase().includes(search.toLowerCase()));
}

View file

@ -1,4 +1,6 @@
import { AppConfig } from '@runtipi/common';
import { AppConfig } from '../../../generated/graphql';
export type SortableColumns = keyof Pick<AppConfig, 'name'>;
export type SortDirection = 'asc' | 'desc';
export type AppTableData = Omit<AppConfig, 'description' | 'form_fields' | 'source' | 'status' | 'url_suffix' | 'version'>[];

View file

@ -1,10 +1,11 @@
import { Button } from '@chakra-ui/react';
import React from 'react';
import { FiExternalLink, FiPause, FiPlay, FiSettings, FiTrash2 } from 'react-icons/fi';
import { AppConfig, AppStatusEnum } from '@runtipi/common';
import { AppInfo, AppStatusEnum } from '../../../generated/graphql';
interface IProps {
app: AppConfig;
app: AppInfo;
status?: AppStatusEnum;
onInstall: () => void;
onUninstall: () => void;
onStart: () => void;
@ -13,10 +14,10 @@ interface IProps {
onUpdate: () => void;
}
const AppActions: React.FC<IProps> = ({ app, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate }) => {
const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate }) => {
const hasSettings = Object.keys(app.form_fields).length > 0;
if (app?.installed && app.status === AppStatusEnum.STOPPED) {
if (status === AppStatusEnum.Stopped) {
return (
<div className="flex flex-wrap justify-center">
<Button onClick={onStart} width={150} colorScheme="green" className="mt-3 mr-2">
@ -35,7 +36,7 @@ const AppActions: React.FC<IProps> = ({ app, onInstall, onUninstall, onStart, on
)}
</div>
);
} else if (app?.installed && app.status === AppStatusEnum.RUNNING) {
} else if (status === AppStatusEnum.Running) {
return (
<div>
<Button onClick={onOpen} width={150} colorScheme="gray" className="mt-3 mr-2">
@ -48,14 +49,14 @@ const AppActions: React.FC<IProps> = ({ app, onInstall, onUninstall, onStart, on
</Button>
</div>
);
} else if (app.status === AppStatusEnum.INSTALLING || app.status === AppStatusEnum.UNINSTALLING || app.status === AppStatusEnum.STARTING || app.status === AppStatusEnum.STOPPING) {
} else if (status === AppStatusEnum.Installing || status === AppStatusEnum.Uninstalling || status === AppStatusEnum.Starting || status === AppStatusEnum.Stopping) {
return (
<div className="flex items-center sm:items-start flex-col md:flex-row">
<Button isLoading onClick={() => null} width={160} colorScheme="green" className="mt-3">
Install
<FiPlay className="ml-1" />
</Button>
<span className="text-gray-500 text-sm ml-2 mt-3 self-center text-center sm:text-left">{`App is ${app.status} please wait and don't refresh page...`}</span>
<span className="text-gray-500 text-sm ml-2 mt-3 self-center text-center sm:text-left">{`App is ${status} please wait and don't refresh page...`}</span>
</div>
);
}

View file

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

View file

@ -1,10 +1,10 @@
import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import React from 'react';
import { AppConfig } from '@runtipi/common';
import InstallForm from './InstallForm';
import { AppInfo } from '../../../generated/graphql';
interface IProps {
app: AppConfig;
app: AppInfo;
isOpen: boolean;
onClose: () => void;
onSubmit: (values: Record<string, any>) => void;

View file

@ -1,9 +1,9 @@
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import React from 'react';
import { AppConfig } from '@runtipi/common';
import { AppInfo } from '../../../generated/graphql';
interface IProps {
app: AppConfig;
app: AppInfo;
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;

View file

@ -1,9 +1,9 @@
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import React from 'react';
import { AppConfig } from '@runtipi/common';
import { AppInfo } from '../../../generated/graphql';
interface IProps {
app: AppConfig;
app: AppInfo;
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;

View file

@ -2,11 +2,11 @@ import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOve
import React, { useEffect } from 'react';
import useSWR from 'swr';
import fetcher from '../../../core/fetcher';
import { AppConfig } from '@runtipi/common';
import InstallForm from './InstallForm';
import { AppInfo } from '../../../generated/graphql';
interface IProps {
app: AppConfig;
app: AppInfo;
isOpen: boolean;
onClose: () => void;
onSubmit: (values: Record<string, any>) => void;

View file

@ -1,8 +1,6 @@
import { SlideFade, VStack, Flex, Divider, useDisclosure, useToast } from '@chakra-ui/react';
import React from 'react';
import { FiExternalLink } from 'react-icons/fi';
import { AppConfig } from '@runtipi/common';
import { useAppsStore } from '../../../state/appsStore';
import { useSytemStore } from '../../../state/systemStore';
import AppActions from '../components/AppActions';
import InstallModal from '../components/InstallModal';
@ -11,19 +9,20 @@ import UninstallModal from '../components/UninstallModal';
import UpdateModal from '../components/UpdateModal';
import AppLogo from '../../../components/AppLogo/AppLogo';
import Markdown from '../../../components/Markdown/Markdown';
import { AppInfo, AppStatusEnum } from '../../../generated/graphql';
interface IProps {
app: AppConfig;
status?: AppStatusEnum;
info: AppInfo;
}
const AppDetails: React.FC<IProps> = ({ app }) => {
const AppDetails: React.FC<IProps> = ({ status, info }) => {
const toast = useToast();
const installDisclosure = useDisclosure();
const uninstallDisclosure = useDisclosure();
const stopDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const { install, update, uninstall, stop, start, fetchApp } = useAppsStore();
const { internalIp } = useSytemStore();
const handleError = (error: unknown) => {
@ -35,14 +34,13 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
position: 'top',
isClosable: true,
});
fetchApp(app.id);
}
};
const handleInstallSubmit = async (values: Record<string, any>) => {
installDisclosure.onClose();
try {
await install(app.id, values);
await install(info.id, values);
} catch (error) {
handleError(error);
}
@ -51,7 +49,7 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
const handleUnistallSubmit = async () => {
uninstallDisclosure.onClose();
try {
await uninstall(app.id);
await uninstall(info.id);
} catch (error) {
handleError(error);
}
@ -60,7 +58,7 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
const handleStopSubmit = async () => {
stopDisclosure.onClose();
try {
await stop(app.id);
await stop(info.id);
} catch (error) {
handleError(error);
}
@ -68,7 +66,7 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
const handleStartSubmit = async () => {
try {
await start(app.id);
await start(info.id);
} catch (e: unknown) {
handleError(e);
}
@ -76,7 +74,7 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
const handleUpdateSubmit = async (values: Record<string, any>) => {
try {
await update(app.id, values);
await update(info.id, values);
toast({
title: 'Success',
description: 'App config updated successfully',
@ -90,27 +88,27 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
};
const handleOpen = () => {
window.open(`http://${internalIp}:${app.port}${app.url_suffix || ''}`, '_blank', 'noreferrer');
window.open(`http://${internalIp}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
};
return (
<SlideFade in className="flex flex-1" offsetY="20px">
<div className="flex flex-1 p-4 mt-3 rounded-lg flex-col">
<Flex className="flex-col md:flex-row">
<AppLogo src={app?.image} size={180} className="self-center sm:self-auto" alt={app.name} />
<AppLogo src={info.image} size={180} className="self-center sm:self-auto" alt={info.name} />
<VStack align="flex-start" justify="space-between" className="ml-0 md:ml-4">
<div className="mt-3 items-center self-center flex flex-col sm:items-start sm:self-start md:mt-0">
<h1 className="font-bold text-2xl">{app?.name}</h1>
<h2 className="text-center md:text-left">{app?.short_desc}</h2>
{app.source && (
<a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={app?.source}>
<h1 className="font-bold text-2xl">{info.name}</h1>
<h2 className="text-center md:text-left">{info.short_desc}</h2>
{info.source && (
<a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={info.source}>
<Flex className="mt-2 items-center">
Source
<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 {info.author}</p>
</div>
<div className="flex justify-center xs:absolute md:static top-0 right-5 self-center sm:self-auto">
<AppActions
@ -120,17 +118,18 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
onStop={stopDisclosure.onOpen}
onUninstall={uninstallDisclosure.onOpen}
onInstall={installDisclosure.onOpen}
app={app}
app={info}
status={status}
/>
</div>
</VStack>
</Flex>
<Divider className="mt-5" />
<Markdown className="mt-3">{app?.description}</Markdown>
<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} />
<Markdown className="mt-3">{info.description}</Markdown>
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={info} />
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.onClose} app={info} />
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={info} />
<UpdateModal onSubmit={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} />
</div>
</SlideFade>
);

View file

@ -1,12 +1,13 @@
import { useToast } from '@chakra-ui/react';
import React from 'react';
import { useAuthStore } from '../../../state/authStore';
import React, { useState } from 'react';
import { useRegisterMutation } from '../../../generated/graphql';
import AuthFormLayout from '../components/AuthFormLayout';
import RegisterForm from '../components/RegisterForm';
const Onboarding: React.FC = () => {
const toast = useToast();
const { me, register, loading } = useAuthStore();
const [register] = useRegisterMutation({ refetchQueries: ['Me'] });
const [loading, setLoading] = useState(false);
const handleError = (error: unknown) => {
if (error instanceof Error) {
@ -22,10 +23,12 @@ const Onboarding: React.FC = () => {
const handleRegister = async (values: { email: string; password: string }) => {
try {
await register(values.email, values.password);
await me();
setLoading(true);
await register({ variables: { input: { username: values.email, password: values.password } } });
} catch (error) {
handleError(error);
} finally {
setLoading(false);
}
};

View file

@ -1,27 +1,17 @@
import { SimpleGrid, Text } from '@chakra-ui/react';
import React, { useEffect } from 'react';
import React from 'react';
import { BsCpu } from 'react-icons/bs';
import { FaMemory } from 'react-icons/fa';
import { FiHardDrive } from 'react-icons/fi';
import { useSytemStore } from '../../../state/systemStore';
import { SystemInfoResponse } from '../../../generated/graphql';
import SystemStat from '../components/SystemStat';
const Dashboard: React.FC = () => {
const { fetchDiskSpace, fetchCpuLoad, fetchMemoryLoad, disk, cpuLoad, memory } = useSytemStore();
interface IProps {
data: SystemInfoResponse;
}
useEffect(() => {
fetchDiskSpace();
fetchCpuLoad();
fetchMemoryLoad();
const interval = setInterval(() => {
fetchDiskSpace();
fetchCpuLoad();
fetchMemoryLoad();
}, 10000);
return () => clearInterval(interval);
}, [fetchCpuLoad, fetchDiskSpace, fetchMemoryLoad]);
const Dashboard: React.FC<IProps> = ({ data }) => {
const { disk, memory, cpu } = data;
// Convert bytes to GB
const diskFree = Math.round(disk.available / 1024 / 1024 / 1024);
@ -43,7 +33,7 @@ const Dashboard: React.FC = () => {
</Text>
<SimpleGrid className="mt-5" minChildWidth="180px" spacing="20px">
<SystemStat title="Disk space" metric={`${diskUsed} GB`} subtitle={`Used out of ${diskSize} GB`} icon={FiHardDrive} progress={percentUsed} />
<SystemStat title="CPU Load" metric={`${cpuLoad.toFixed(2)}%`} subtitle="Uninstall apps if there is to much load" icon={BsCpu} progress={cpuLoad} />
<SystemStat title="CPU Load" metric={`${cpu.load.toFixed(2)}%`} subtitle="Uninstall apps if there is to much load" icon={BsCpu} progress={cpu.load} />
<SystemStat title="Memory Used" metric={`${percentUsedMemory}%`} subtitle={`${memoryTotal} GB`} icon={FaMemory} progress={percentUsedMemory} />
</SimpleGrid>
</>

View file

@ -1,29 +1,23 @@
import type { NextPage } from 'next';
import { useEffect } from 'react';
import Layout from '../../components/Layout';
import { useAppsStore } from '../../state/appsStore';
import AppDetails from '../../modules/Apps/containers/AppDetails';
import { useGetAppQuery } from '../../generated/graphql';
interface IProps {
appId: string;
}
const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
const { fetchApp, getApp } = useAppsStore((state) => state);
const app = getApp(appId);
useEffect(() => {
fetchApp(appId);
}, [appId, fetchApp]);
const { data, loading } = useGetAppQuery({ variables: { appId }, pollInterval: 5000 });
const breadcrumb = [
{ name: 'App Store', href: '/app-store' },
{ name: app?.name || '', href: `/app-store/${appId}`, current: true },
{ name: data?.getApp.info?.name || '', href: `/app-store/${appId}`, current: true },
];
return (
<Layout breadcrumbs={breadcrumb} loading={!app}>
{app && <AppDetails app={app} />}
<Layout breadcrumbs={breadcrumb} loading={!data?.getApp && loading}>
{data?.getApp.info && <AppDetails status={data?.getApp.app?.status} info={data?.getApp.info} />}
</Layout>
);
};

View file

@ -1,20 +1,15 @@
import React, { useEffect } from 'react';
import React from 'react';
import type { NextPage } from 'next';
import Layout from '../../components/Layout';
import AppStoreContainer from '../../modules/AppStore/containers/AppStoreContainer';
import { useAppsStore } from '../../state/appsStore';
import { RequestStatus } from '../../core/types';
import { useListAppsQuery } from '../../generated/graphql';
const Apps: NextPage = () => {
const { fetch, status, apps } = useAppsStore((state) => state);
useEffect(() => {
fetch();
}, [fetch]);
const { loading, data } = useListAppsQuery();
return (
<Layout loading={status === RequestStatus.LOADING && apps.length === 0}>
<AppStoreContainer />
<Layout loading={loading && !data}>
<AppStoreContainer apps={data?.listAppsInfo.apps || []} />
</Layout>
);
};

View file

@ -1,29 +1,23 @@
import type { NextPage } from 'next';
import { useEffect } from 'react';
import Layout from '../../components/Layout';
import { useAppsStore } from '../../state/appsStore';
import AppDetails from '../../modules/Apps/containers/AppDetails';
import { useGetAppQuery } from '../../generated/graphql';
interface IProps {
appId: string;
}
const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
const { fetchApp, getApp } = useAppsStore((state) => state);
const app = getApp(appId);
useEffect(() => {
fetchApp(appId);
}, [appId, fetchApp]);
const { data, loading } = useGetAppQuery({ variables: { appId }, pollInterval: 5000 });
const breadcrumb = [
{ name: 'Apps', href: '/apps' },
{ name: app?.name || '', href: `/apps/${appId}`, current: true },
{ name: data?.getApp.info.name || '', href: `/apps/${appId}`, current: true },
];
return (
<Layout breadcrumbs={breadcrumb} loading={!app}>
{app && <AppDetails app={app} />}
<Layout breadcrumbs={breadcrumb} loading={!data?.getApp.app && loading}>
{data?.getApp.info && <AppDetails status={data?.getApp.app?.status} info={data.getApp.info} />}
</Layout>
);
};

View file

@ -1,13 +1,11 @@
import type { NextPage } from 'next';
import Layout from '../components/Layout';
import { useSystemInfoQuery } from '../generated/graphql';
import Dashboard from '../modules/Dashboard/containers/Dashboard';
const Home: NextPage = () => {
return (
<Layout>
<Dashboard />
</Layout>
);
const { data, loading } = useSystemInfoQuery({ pollInterval: 10000 });
return <Layout loading={loading && !data}>{data?.systemInfo && <Dashboard data={data.systemInfo} />}</Layout>;
};
export default Home;

View file

@ -1,118 +0,0 @@
import produce from 'immer';
import create, { SetState } from 'zustand';
import api from '../core/api';
import { AppConfig, AppStatusEnum } from '@runtipi/common';
import { RequestStatus } from '../core/types';
type AppsStore = {
apps: AppConfig[];
status: RequestStatus;
fetch: () => void;
getApp: (id: string) => AppConfig | undefined;
fetchApp: (id: string) => void;
install: (id: string, form: Record<string, string>) => Promise<void>;
update: (id: string, form: Record<string, string>) => Promise<void>;
uninstall: (id: string) => Promise<void>;
stop: (id: string) => Promise<void>;
start: (id: string) => Promise<void>;
};
type Set = SetState<AppsStore>;
const sortApps = (a: AppConfig, b: AppConfig) => a.name.localeCompare(b.name);
const setAppStatus = (appId: string, status: AppStatusEnum, set: Set) => {
set((state) => {
return produce(state, (draft) => {
const app = draft.apps.find((a) => a.id === appId);
if (app) app.status = status;
});
});
};
/**
* 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: apps.sort(sortApps) };
});
};
export const useAppsStore = create<AppsStore>((set, get) => ({
apps: [],
status: RequestStatus.LOADING,
fetchApp: async (appId: string) => fetchApp(appId, set),
fetch: async () => {
set({ status: RequestStatus.LOADING });
const response = await api.fetch<AppConfig[]>({
endpoint: '/apps/list',
method: 'get',
});
const apps = response.sort(sortApps);
set({ apps, status: RequestStatus.SUCCESS });
},
getApp: (appId: string) => {
return get().apps.find((app) => app.id === appId);
},
install: async (appId: string, form?: Record<string, string>) => {
setAppStatus(appId, AppStatusEnum.INSTALLING, set);
await api.fetch({
endpoint: `/apps/install/${appId}`,
method: 'POST',
data: { form },
});
await get().fetchApp(appId);
},
update: async (appId: string, form?: Record<string, string>) => {
await api.fetch({
endpoint: `/apps/update/${appId}`,
method: 'POST',
data: { form },
});
await get().fetchApp(appId);
},
uninstall: async (appId: string) => {
setAppStatus(appId, AppStatusEnum.UNINSTALLING, set);
await api.fetch({
endpoint: `/apps/uninstall/${appId}`,
});
await get().fetchApp(appId);
},
stop: async (appId: string) => {
setAppStatus(appId, AppStatusEnum.STOPPING, set);
await api.fetch({
endpoint: `/apps/stop/${appId}`,
});
await get().fetchApp(appId);
},
start: async (appId: string) => {
setAppStatus(appId, AppStatusEnum.STARTING, set);
await api.fetch({
endpoint: `/apps/start/${appId}`,
});
await get().fetchApp(appId);
},
}));

View file

@ -1,7 +1,12 @@
import { AppStatusEnum } from '@runtipi/common';
import { Field, ObjectType } from 'type-graphql';
import { GraphQLJSONObject } from 'graphql-type-json';
import { Field, ObjectType, registerEnumType } from 'type-graphql';
import { BaseEntity, Column, CreateDateColumn, Entity, UpdateDateColumn } from 'typeorm';
registerEnumType(AppStatusEnum, {
name: 'AppStatusEnum',
});
@ObjectType()
@Entity()
class App extends BaseEntity {
@ -9,7 +14,7 @@ class App extends BaseEntity {
@Column({ type: 'varchar', primary: true, unique: true })
id!: string;
@Field(() => String)
@Field(() => AppStatusEnum)
@Column({ type: 'enum', enum: AppStatusEnum, default: AppStatusEnum.STOPPED, nullable: false })
status!: AppStatusEnum;
@ -21,6 +26,10 @@ class App extends BaseEntity {
@Column({ type: 'integer', default: 0, nullable: false })
numOpened!: number;
@Field(() => GraphQLJSONObject)
@Column({ type: 'jsonb', nullable: false })
config!: Record<string, string>;
@Field(() => Date)
@CreateDateColumn()
createdAt!: Date;

View file

@ -1,139 +0,0 @@
import { NextFunction, Request, Response } from 'express';
import { AppConfig } from '@runtipi/common';
import AppsService from './apps.service';
import { getInitalFormValues } from './apps.helpers';
const uninstallApp = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id: appName } = req.params;
if (!appName) {
throw new Error('App name is required');
}
await AppsService.uninstallApp(appName);
res.status(200).json({ message: 'App uninstalled successfully' });
} catch (e) {
next(e);
}
};
const stopApp = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id: appName } = req.params;
if (!appName) {
throw new Error('App name is required');
}
await AppsService.stopApp(appName);
res.status(200).json({ message: 'App stopped successfully' });
} catch (e) {
next(e);
}
};
const updateAppConfig = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id: appName } = req.params;
const { form } = req.body;
if (!appName) {
throw new Error('App name is required');
}
AppsService.updateAppConfig(appName, form);
res.status(200).json({ message: 'App updated successfully' });
} catch (e) {
next(e);
}
};
const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunction) => {
try {
const { id } = req.params;
if (!id) {
throw new Error('App name is required');
}
const appInfo = await AppsService.getAppInfo(id);
res.status(200).json(appInfo);
} catch (e) {
next(e);
}
};
const listApps = async (_req: Request, res: Response, next: NextFunction) => {
try {
const apps = await AppsService.listApps();
res.status(200).json(apps);
} catch (e) {
next(e);
}
};
const startApp = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
if (!id) {
throw new Error('App name is required');
}
await AppsService.startApp(id);
res.status(200).json({ message: 'App started successfully' });
} catch (e) {
next(e);
}
};
const installApp = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const { form } = req.body;
if (!id) {
throw new Error('App name is required');
}
await AppsService.installApp(id, form);
res.status(200).json({ message: 'App installed successfully' });
} catch (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);
}
};
const AppController = {
uninstallApp,
installApp,
stopApp,
updateAppConfig,
getAppInfo,
listApps,
startApp,
initalFormValues,
};
export default AppController;

View file

@ -50,23 +50,6 @@ export const checkEnvFile = (appName: string) => {
});
};
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> = {};
configFile.form_fields.forEach((field) => {
const envVar = field.env_variable;
const envVarValue = envMap.get(envVar);
if (envVarValue) {
formValues[field.env_variable] = envVarValue;
}
});
return formValues;
};
export const checkAppExists = (appName: string) => {
const appExists = fileExists(`/app-data/${appName}`);

View file

@ -1,6 +1,6 @@
import { Arg, Authorized, Mutation, Query, Resolver } from 'type-graphql';
import AppsService from './apps.service';
import { AppConfig, AppInputType, ListAppsResonse } from './apps.types';
import { AppInputType, AppResponse, ListAppsResonse } from './apps.types';
import App from './app.entity';
@Resolver()
@ -10,9 +10,9 @@ export default class AppsResolver {
return AppsService.listApps();
}
@Query(() => AppConfig)
getAppInfo(@Arg('id', () => String) appId: string): Promise<AppConfig> {
return AppsService.getAppInfo(appId);
@Query(() => AppResponse)
getApp(@Arg('id', () => String) id: string): Promise<AppResponse> {
return AppsService.getApp(id);
}
@Authorized()

View file

@ -1,15 +0,0 @@
import { Router } from 'express';
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,8 +1,7 @@
import si from 'systeminformation';
import { AppStatusEnum } from '@runtipi/common';
import { createFolder, readFile, readJsonFile } from '../fs/fs.helpers';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, getInitalFormValues, getStateFile, runAppScript } from './apps.helpers';
import { AppConfig, ListAppsResonse } from './apps.types';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, getStateFile, runAppScript } from './apps.helpers';
import { AppInfo, AppResponse, ListAppsResonse } from './apps.types';
import App from './app.entity';
const startApp = async (appName: string): Promise<App> => {
@ -15,8 +14,7 @@ const startApp = async (appName: string): Promise<App> => {
checkEnvFile(appName);
// Regenerate env file
const form = getInitalFormValues(appName);
generateEnvFile(appName, form);
generateEnvFile(appName, app.config);
await App.update({ id: appName }, { status: AppStatusEnum.STARTING });
// Run script
@ -46,7 +44,7 @@ const installApp = async (id: string, form: Record<string, string>): Promise<App
// Create env file
generateEnvFile(id, form);
await App.create({ id, status: AppStatusEnum.INSTALLING }).save();
app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form }).save();
// Run script
await runAppScript(['install', id]);
@ -58,7 +56,7 @@ const installApp = async (id: string, form: Record<string, string>): Promise<App
};
const listApps = async (): Promise<ListAppsResonse> => {
const apps: AppConfig[] = getAvailableApps()
const apps: AppInfo[] = getAvailableApps()
.map((app) => {
try {
return readJsonFile(`/apps/${app}/config.json`);
@ -68,41 +66,39 @@ const listApps = async (): Promise<ListAppsResonse> => {
})
.filter(Boolean);
const dockerContainers = await si.dockerContainers();
const state = getStateFile();
const installed: string[] = state.installed.split(' ').filter(Boolean);
apps.forEach((app) => {
app.installed = installed.includes(app.id);
app.status = (dockerContainers.find((container) => container.name === `${app.id}`)?.state as AppStatusEnum) || AppStatusEnum.STOPPED;
app.description = readFile(`/apps/${app.id}/metadata/description.md`);
});
return { apps, total: apps.length };
};
const getAppInfo = async (id: string): Promise<AppConfig> => {
const dockerContainers = await si.dockerContainers();
const configFile: AppConfig = readJsonFile(`/apps/${id}/config.json`);
const getAppInfo = (id: string): AppInfo => {
const configFile: AppInfo = readJsonFile(`/apps/${id}/config.json`);
const state = getStateFile();
const installed: string[] = state.installed.split(' ').filter(Boolean);
configFile.installed = installed.includes(id);
configFile.status = (dockerContainers.find((container) => container.name === `${id}`)?.state as AppStatusEnum) || AppStatusEnum.STOPPED;
configFile.description = readFile(`/apps/${id}/metadata/description.md`);
return configFile;
};
const updateAppConfig = async (id: string, form: Record<string, string>): Promise<App> => {
const app = await App.findOne({ where: { id } });
let app = await App.findOne({ where: { id } });
if (!app) {
throw new Error(`App ${id} not found`);
}
generateEnvFile(id, form);
await App.update({ id }, { config: form });
app = (await App.findOne({ where: { id } })) as App;
return app;
};
@ -136,4 +132,13 @@ const uninstallApp = async (id: string): Promise<boolean> => {
return true;
};
export default { installApp, startApp, listApps, getAppInfo, updateAppConfig, stopApp, uninstallApp };
const getApp = async (id: string): Promise<AppResponse> => {
const app = await App.findOne({ where: { id } });
return {
info: getAppInfo(id),
app,
};
};
export default { installApp, startApp, listApps, getApp, updateAppConfig, stopApp, uninstallApp };

View file

@ -1,10 +1,19 @@
import { AppCategoriesEnum, AppStatusEnum, FieldTypes } from '@runtipi/common';
import { Field, InputType, ObjectType } from 'type-graphql';
import { AppCategoriesEnum, FieldTypes } from '@runtipi/common';
import { Field, InputType, ObjectType, registerEnumType } from 'type-graphql';
import { GraphQLJSONObject } from 'graphql-type-json';
import App from './app.entity';
registerEnumType(AppCategoriesEnum, {
name: 'AppCategoriesEnum',
});
registerEnumType(FieldTypes, {
name: 'FieldTypesEnum',
});
@ObjectType()
class FormField {
@Field(() => String)
@Field(() => FieldTypes)
type!: FieldTypes;
@Field(() => String)
@ -27,7 +36,7 @@ class FormField {
}
@ObjectType()
class AppConfig {
class AppInfo {
@Field(() => String)
id!: string;
@ -61,12 +70,9 @@ class AppConfig {
@Field(() => Boolean)
installed!: boolean;
@Field(() => [String])
@Field(() => [AppCategoriesEnum])
categories!: AppCategoriesEnum[];
@Field(() => String)
status!: AppStatusEnum;
@Field(() => String, { nullable: true })
url_suffix?: string;
@ -76,13 +82,22 @@ class AppConfig {
@ObjectType()
class ListAppsResonse {
@Field(() => [AppConfig])
apps!: AppConfig[];
@Field(() => [AppInfo])
apps!: AppInfo[];
@Field(() => Number)
total!: number;
}
@ObjectType()
class AppResponse {
@Field(() => App, { nullable: true })
app!: App | null;
@Field(() => AppInfo)
info!: AppInfo;
}
@InputType()
class AppInputType {
@Field(() => String)
@ -92,4 +107,4 @@ class AppInputType {
form!: Record<string, string>;
}
export { ListAppsResonse, AppConfig, AppInputType };
export { ListAppsResonse, AppInfo, AppInputType, AppResponse };

View file

@ -1,4 +1,4 @@
import { Arg, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql';
import { Arg, Ctx, Mutation, Query, Resolver } from 'type-graphql';
import { MyContext } from '../../types';
import { UsernamePasswordInput, UserResponse } from './auth.types';
@ -36,7 +36,6 @@ export default class AuthResolver {
return { user };
}
@Authorized()
@Mutation(() => Boolean)
logout(@Ctx() { req }: MyContext): boolean {
req.session.userId = undefined;

View file

@ -27,6 +27,12 @@ const register = async (input: UsernamePasswordInput): Promise<UserResponse> =>
throw new Error('Missing email or password');
}
const user = await User.findOne({ where: { username: username.trim().toLowerCase() } });
if (user) {
throw new Error('User already exists');
}
const hash = await argon2.hash(password);
const newUser = await User.create({ username: username.trim().toLowerCase(), password: hash }).save();

View file

@ -1,27 +0,0 @@
import { Request, Response } from 'express';
import publicIp from 'public-ip';
import portScanner from 'node-port-scanner';
import internalIp from 'internal-ip';
const isPortOpen = async (req: Request, res: Response<boolean>) => {
const { port } = req.params;
const host = await publicIp.v4();
const isOpen = await portScanner(host, [port]);
res.status(200).send(isOpen);
};
const getInternalIp = async (_req: Request, res: Response<string>) => {
const ip = await internalIp.v4();
res.status(200).send(ip);
};
const NetworkController = {
isPortOpen,
getInternalIp,
};
export default NetworkController;

View file

@ -1,8 +0,0 @@
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,87 +0,0 @@
import axios from 'axios';
import { Request, Response } from 'express';
import config from '../../config';
import TipiCache from '../../config/TipiCache';
import { readJsonFile } from '../fs/fs.helpers';
type CpuData = {
load: number;
};
type DiskData = {
total: number;
used: number;
available: number;
};
type MemoryData = {
total: number;
available: number;
used: number;
};
type SystemInfo = {
cpu: CpuData;
disk: DiskData;
memory: MemoryData;
};
/**
*
* @param req
* @param res
*/
const getCpuInfo = async (_req: Request, res: Response<CpuData>) => {
const systemInfo: SystemInfo = readJsonFile('/state/system-info.json');
const cpu = systemInfo.cpu;
res.status(200).send({ load: cpu.load });
};
/**
*
* @param req
* @param res
*/
const getDiskInfo = async (_req: Request, res: Response<DiskData>) => {
const systemInfo: SystemInfo = readJsonFile('/state/system-info.json');
const result: DiskData = systemInfo.disk;
res.status(200).send(result);
};
/**
*
* @param req
* @param res
*/
const getMemoryInfo = async (_req: Request, res: Response<MemoryData>) => {
const systemInfo: SystemInfo = readJsonFile('/state/system-info.json');
const result: MemoryData = systemInfo.memory;
res.status(200).json(result);
};
const getVersion = async (_req: Request, res: Response<{ current: string; latest?: string }>) => {
try {
let version = TipiCache.get<string>('latestVersion');
if (!version) {
const { data } = await axios.get('https://api.github.com/repos/meienberger/runtipi/releases/latest');
TipiCache.set('latestVersion', data.name);
version = data.name.replace('v', '');
}
TipiCache.set('latestVersion', version?.replace('v', ''));
res.status(200).send({ current: config.VERSION, latest: version?.replace('v', '') });
} catch (e) {
res.status(500).send({ current: config.VERSION, latest: undefined });
}
};
export default { getCpuInfo, getDiskInfo, getMemoryInfo, getVersion };

View file

@ -0,0 +1,16 @@
import { Query, Resolver } from 'type-graphql';
import SystemService from './system.service';
import { SystemInfoResponse, VersionResponse } from './system.types';
@Resolver()
export default class AuthResolver {
@Query(() => SystemInfoResponse, { nullable: true })
async systemInfo(): Promise<SystemInfoResponse> {
return SystemService.systemInfo();
}
@Query(() => String)
async version(): Promise<VersionResponse> {
return SystemService.getVersion();
}
}

View file

@ -1,11 +0,0 @@
import { Router } from 'express';
import SystemController from './system.controller';
const router = Router();
router.route('/cpu').get(SystemController.getCpuInfo);
router.route('/disk').get(SystemController.getDiskInfo);
router.route('/memory').get(SystemController.getMemoryInfo);
router.route('/version').get(SystemController.getVersion);
export default router;

View file

@ -0,0 +1,52 @@
import axios from 'axios';
import config from '../../config';
import TipiCache from '../../config/TipiCache';
import { readJsonFile } from '../fs/fs.helpers';
type SystemInfo = {
cpu: {
load: number;
};
disk: {
total: number;
used: number;
available: number;
};
memory: {
total: number;
available: number;
used: number;
};
};
const systemInfo = (): SystemInfo => {
const info: SystemInfo = readJsonFile('/state/system-info.json');
return info;
};
const getVersion = async (): Promise<{ current: string; latest?: string }> => {
try {
let version = TipiCache.get<string>('latestVersion');
if (!version) {
const { data } = await axios.get('https://api.github.com/repos/meienberger/runtipi/releases/latest');
TipiCache.set('latestVersion', data.name);
version = data.name.replace('v', '');
}
TipiCache.set('latestVersion', version?.replace('v', ''));
return { current: config.VERSION, latest: version?.replace('v', '') };
} catch (e) {
return { current: config.VERSION, latest: undefined };
}
};
const SystemService = {
systemInfo,
getVersion,
};
export default SystemService;

View file

@ -0,0 +1,42 @@
import { Field, ObjectType } from 'type-graphql';
@ObjectType()
class Cpu {
@Field(() => Number, { nullable: false })
load!: number;
}
@ObjectType()
class DiskMemory {
@Field(() => Number, { nullable: false })
total!: number;
@Field(() => Number, { nullable: false })
used!: number;
@Field(() => Number, { nullable: false })
available!: number;
}
@ObjectType()
class SystemInfoResponse {
@Field(() => Cpu, { nullable: false })
cpu!: Cpu;
@Field(() => DiskMemory, { nullable: false })
disk!: DiskMemory;
@Field(() => DiskMemory, { nullable: false })
memory!: DiskMemory;
}
@ObjectType()
class VersionResponse {
@Field(() => String, { nullable: false })
current!: string;
@Field(() => String, { nullable: true })
latest?: string;
}
export { SystemInfoResponse, VersionResponse };

View file

@ -3,10 +3,11 @@ import { buildSchema } from 'type-graphql';
import { customAuthChecker } from './core/middlewares/authChecker';
import AppsResolver from './modules/apps/apps.resolver';
import AuthResolver from './modules/auth/auth.resolver';
import SystemResolver from './modules/system/system.resolver';
const createSchema = (): Promise<GraphQLSchema> =>
buildSchema({
resolvers: [AppsResolver, AuthResolver],
resolvers: [AppsResolver, AuthResolver, SystemResolver],
validate: true,
authChecker: customAuthChecker,
});