Merge branch '82-dynamically-list-apps' into 'main'

Implement fetching apps from BE

Closes #77 and #82

See merge request stackspin/dashboard!53
main
Maarten de Waard 2022-10-06 09:38:55 +00:00
commit 3ff81a9ee5
50 changed files with 1216 additions and 538 deletions

View File

@ -13,6 +13,7 @@
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/js-yaml": "^4.0.5",
"@types/node": "^12.0.0",
"@types/react-dom": "^17.0.0",
"axios": "^0.21.1",
@ -28,8 +29,8 @@
"react-hot-toast": "^2.0.0",
"react-markdown": "^7.0.1",
"react-redux": "^7.2.4",
"react-router-dom": "6.2.1",
"react-router": "6.2.1",
"react-router-dom": "6.2.1",
"react-scripts": "4.0.3",
"react-simple-code-editor": "^0.11.0",
"react-table": "^7.7.0",

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
</style>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-0.7593" y1="513.0059" x2="1.0811" y2="513.0059" gradientTransform="matrix(1.431349e-14 -254.0268 254.0268 1.690345e-14 -130061.25 434.0558)">
<stop offset="0" style="stop-color:#FFF100"/>
<stop offset="1" style="stop-color:#F05A28"/>
</linearGradient>
<path class="st0" d="M490.8,226c-0.8-8.6-2.3-18.5-5.1-29.5c-2.8-10.9-7.1-22.8-13.3-35.3c-6.2-12.4-14.2-25.2-24.7-37.8
c-4.1-4.9-8.6-9.7-13.4-14.4c7.2-28.6-8.7-53.5-8.7-53.5c-27.5-1.7-45,8.6-51.5,13.3c-1.1-0.4-2.1-1-3.2-1.4
c-4.7-1.8-9.5-3.7-14.5-5.2c-4.9-1.6-10-3-15.2-4.2c-5.2-1.3-10.4-2.3-15.8-3.1c-1-0.1-1.8-0.3-2.8-0.4C310.6,16.1,276.1,0,276.1,0
c-38.5,24.4-45.7,58.5-45.7,58.5s-0.1,0.7-0.4,2c-2.1,0.6-4.2,1.3-6.3,1.8c-3,0.8-5.9,2-8.7,3.1c-3,1.1-5.8,2.3-8.7,3.5
c-5.8,2.5-11.6,5.4-17.2,8.5c-5.5,3.1-10.9,6.5-16.1,10c-0.7-0.3-1.4-0.6-1.4-0.6C118.2,66.6,70.9,91,70.9,91
c-4.4,56.7,21.3,92.4,26.4,98.9c-1.3,3.5-2.4,7.1-3.5,10.6c-3.9,12.8-6.9,26-8.7,39.6c-0.3,2-0.6,3.9-0.7,5.9
c-49.4,24.4-63.9,74.2-63.9,74.2c41,47.3,89,50.2,89,50.2l0.1-0.1c6.1,10.9,13.1,21.2,21,30.9c3.4,4.1,6.8,7.9,10.4,11.7
c-15,42.9,2.1,78.4,2.1,78.4c45.7,1.7,75.7-20,82.1-25c4.5,1.6,9.2,3,13.8,4.1c14.1,3.7,28.5,5.8,42.9,6.3
c3.5,0.1,7.2,0.3,10.7,0.1h1.7h1.1h2.3l2.3-0.1v0.1c21.6,30.7,59.4,35.1,59.4,35.1c26.9-28.4,28.5-56.6,28.5-62.6c0,0,0-0.1,0-0.4
c0-0.6,0-0.8,0-0.8c0-0.4,0-0.8,0-1.3c5.6-4,11-8.2,16.1-12.8c10.7-9.7,20.2-20.9,28.1-32.9c0.7-1.1,1.4-2.3,2.1-3.4
c30.5,1.7,52-18.9,52-18.9c-5.1-31.7-23.1-47.3-26.9-50.2c0,0-0.1-0.1-0.4-0.3c-0.3-0.1-0.3-0.3-0.3-0.3c-0.1-0.1-0.4-0.3-0.7-0.4
c0.1-2,0.3-3.8,0.4-5.8c0.3-3.4,0.3-6.9,0.3-10.3V309v-1.3v-0.7c0-0.8,0-0.6,0-0.8l-0.1-2.1l-0.1-2.8c0-1-0.1-1.8-0.3-2.7
c-0.1-0.8-0.1-1.8-0.3-2.7l-0.3-2.7l-0.4-2.7c-0.6-3.5-1.1-6.9-2-10.4c-3.2-13.7-8.6-26.7-15.5-38.4c-7.1-11.7-15.8-22-25.8-30.7
c-9.9-8.7-21-15.8-32.6-21c-11.7-5.2-23.8-8.6-36-10.2c-6.1-0.8-12.1-1.1-18.2-1h-2.3h-0.6h-0.7h-1l-2.3,0.1
c-0.8,0-1.7,0.1-2.4,0.1c-3.1,0.3-6.2,0.7-9.2,1.3c-12.1,2.3-23.6,6.6-33.6,12.7c-10,6.1-18.8,13.5-25.8,22
c-7.1,8.5-12.6,17.9-16.4,27.6s-5.9,19.9-6.5,29.6c-0.1,2.4-0.1,4.9-0.1,7.3c0,0.6,0,1.3,0,1.8l0.1,2c0.1,1.1,0.1,2.4,0.3,3.5
c0.4,4.9,1.4,9.7,2.7,14.2c2.7,9.2,6.9,17.5,12.1,24.5c5.2,7.1,11.6,12.8,18.2,17.5c6.6,4.5,13.8,7.8,20.9,9.9
c7.1,2.1,14.1,3,20.7,3c0.8,0,1.7,0,2.4,0c0.4,0,0.8,0,1.3,0s0.8,0,1.3-0.1c0.7,0,1.4-0.1,2.1-0.1l0.6-0.1l0.7-0.1
c0.4,0,0.8-0.1,1.3-0.1c0.8-0.1,1.6-0.3,2.4-0.4c0.8-0.1,1.6-0.3,2.3-0.6c1.6-0.3,3-0.8,4.4-1.3c2.8-1,5.6-2.1,8-3.4
c2.5-1.3,4.8-2.8,7.1-4.2c0.6-0.4,1.3-0.8,1.8-1.4c2.3-1.8,2.7-5.2,0.8-7.5c-1.6-2-4.4-2.5-6.6-1.3c-0.6,0.3-1.1,0.6-1.7,0.8
c-2,1-3.9,1.8-6.1,2.5c-2.1,0.7-4.4,1.3-6.6,1.7c-1.1,0.1-2.3,0.3-3.5,0.4c-0.6,0-1.1,0.1-1.8,0.1c-0.6,0-1.3,0-1.7,0
c-0.6,0-1.1,0-1.7,0c-0.7,0-1.4,0-2.1-0.1c0,0-0.4,0-0.1,0h-0.3h-0.4c-0.3,0-0.7,0-1-0.1c-0.7-0.1-1.3-0.1-2-0.3
c-5.2-0.7-10.4-2.3-15.4-4.5c-5.1-2.3-9.9-5.4-14.2-9.3c-4.4-4-8.2-8.6-11.1-14c-3-5.4-5.1-11.3-6.1-17.5c-0.4-3.1-0.7-6.3-0.6-9.5
c0-0.8,0.1-1.7,0.1-2.5c0,0.3,0-0.1,0-0.1v-0.3v-0.7c0-0.4,0.1-0.8,0.1-1.3c0.1-1.7,0.4-3.4,0.7-5.1c2.4-13.5,9.2-26.8,19.6-36.8
c2.7-2.5,5.5-4.8,8.5-6.9c3-2.1,6.2-3.9,9.6-5.5c3.4-1.6,6.8-2.8,10.4-3.8c3.5-1,7.2-1.6,11-2c1.8-0.1,3.7-0.3,5.6-0.3
c0.6,0,0.8,0,1.3,0h1.6h1c0.4,0,0,0,0.1,0h0.4l1.6,0.1c4.1,0.3,8,0.8,12,1.8c7.9,1.7,15.7,4.7,22.8,8.6c14.4,8,26.7,20.5,34.1,35.4
c3.8,7.5,6.5,15.5,7.8,23.8c0.3,2.1,0.6,4.2,0.7,6.3l0.1,1.6l0.1,1.6c0,0.6,0,1.1,0,1.6c0,0.6,0,1.1,0,1.6v1.4v1.6
c0,1-0.1,2.7-0.1,3.7c-0.1,2.3-0.4,4.7-0.7,6.9c-0.3,2.3-0.7,4.5-1.1,6.8c-0.4,2.3-1,4.5-1.6,6.6c-1.1,4.4-2.5,8.7-4.2,13.1
c-3.4,8.5-7.9,16.6-13.3,24.1c-10.9,15-25.7,27.1-42.6,34.8c-8.5,3.8-17.3,6.6-26.5,8c-4.5,0.8-9.2,1.3-13.8,1.4h-0.8h-0.7h-1.6
h-2.3h-1.1c0.6,0-0.1,0-0.1,0h-0.4c-2.5,0-4.9-0.1-7.5-0.4c-9.9-0.7-19.6-2.5-29.2-5.2c-9.5-2.7-18.6-6.5-27.4-11
c-17.3-9.3-33-22-45.1-37.4c-6.1-7.6-11.4-15.9-15.8-24.5c-4.4-8.6-7.9-17.8-10.4-26.9c-2.5-9.3-4.1-18.8-4.8-28.4l-0.1-1.8v-0.4
v-0.4v-0.8v-1.6v-0.4v-0.6v-1.1V268v-0.4c0,0,0,0.1,0-0.1v-0.8c0-1.1,0-2.4,0-3.5c0.1-4.7,0.6-9.6,1.1-14.4
c0.6-4.8,1.4-9.7,2.4-14.5c1-4.8,2.1-9.6,3.5-14.4c2.7-9.5,6.1-18.6,10-27.2c8-17.2,18.5-32.6,31-44.9c3.1-3.1,6.3-5.9,9.7-8.7
c3.4-2.7,6.9-5.2,10.6-7.6c3.5-2.4,7.3-4.5,11.1-6.5c1.8-1,3.8-2,5.8-2.8c1-0.4,2-0.8,3-1.3c1-0.4,2-0.8,3-1.3
c3.9-1.7,8-3.1,12.3-4.4c1-0.3,2.1-0.6,3.1-1c1-0.3,2.1-0.6,3.1-0.8c2.1-0.6,4.2-1.1,6.3-1.6c1-0.3,2.1-0.4,3.2-0.7
c1.1-0.3,2.1-0.4,3.2-0.7c1.1-0.1,2.1-0.4,3.2-0.6l1.6-0.3l1.7-0.3c1.1-0.1,2.1-0.3,3.2-0.4c1.3-0.1,2.4-0.3,3.7-0.4
c1-0.1,2.7-0.3,3.7-0.4c0.7-0.1,1.6-0.1,2.3-0.3l1.6-0.1l0.7-0.1h0.8c1.3-0.1,2.4-0.1,3.7-0.3l1.8-0.1c0,0,0.7,0,0.1,0h0.4h0.8
c1,0,2.1-0.1,3.1-0.1c4.1-0.1,8.3-0.1,12.4,0c8.2,0.3,16.2,1.3,24,2.7c15.7,3,30.3,7.9,43.7,14.5c13.4,6.5,25.2,14.5,35.7,23.3
c0.7,0.6,1.3,1.1,2,1.7c0.6,0.6,1.3,1.1,1.8,1.7c1.3,1.1,2.4,2.3,3.7,3.4c1.3,1.1,2.4,2.3,3.5,3.4c1.1,1.1,2.3,2.3,3.4,3.5
c4.4,4.7,8.5,9.3,12.1,14.1c7.3,9.5,13.3,19,17.9,28.1c0.3,0.6,0.6,1.1,0.8,1.7s0.6,1.1,0.8,1.7c0.6,1.1,1.1,2.3,1.6,3.4
c0.6,1.1,1,2.1,1.6,3.2c0.4,1.1,1,2.1,1.4,3.2c1.7,4.2,3.4,8.3,4.7,12.1c2.1,6.2,3.7,11.7,4.9,16.5c0.4,2,2.3,3.2,4.2,3
c2.1-0.1,3.7-1.8,3.7-3.9C491.7,238.9,491.5,232.9,490.8,226z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1 @@
Monitor your system with Grafana

View File

@ -0,0 +1 @@
Access documentation website

View File

@ -4,7 +4,7 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { useAuth } from 'src/services/auth';
import { Dashboard, Users, Login } from './modules';
import { Dashboard, Users, Login, Apps, AppSingle } from './modules';
import { Layout } from './components';
import { LoginCallback } from './modules/login/LoginCallback';
@ -43,7 +43,11 @@ function App() {
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/users" element={<ProtectedRoute />}>
<Route path="/users" element={<Users />} />
<Route index element={<Users />} />
</Route>
<Route path="/apps" element={<ProtectedRoute />}>
<Route path=":slug" element={<AppSingle />} />
<Route index element={<Apps />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" />} />
</Routes>

View File

@ -0,0 +1,43 @@
import React from 'react';
import { useController } from 'react-hook-form';
/* eslint-disable react/react-in-jsx-scope */
export const Checkbox = ({ control, name, label, ...props }: CheckboxProps) => {
const {
field,
// fieldState: { invalid, isTouched, isDirty },
// formState: { touchedFields, dirtyFields },
} = useController({
name,
control,
defaultValue: false,
});
return (
<>
{label && (
<label htmlFor={name} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
)}
<input
type="checkbox"
id={name}
onChange={field.onChange} // send value to hook form
onBlur={field.onBlur} // notify when input is touched/blur
checked={field.value}
name={name} // send down the checkbox name
className="shadow-sm focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
{...props}
/>
</>
);
};
type CheckboxProps = {
control: any;
name: string;
id?: string;
label?: string;
className?: string;
};

View File

@ -0,0 +1 @@
export { Checkbox } from './Checkbox';

View File

@ -0,0 +1,39 @@
import React from 'react';
import { highlight, languages } from 'prismjs';
import { useController } from 'react-hook-form';
import Editor from 'react-simple-code-editor';
/* eslint-disable react/react-in-jsx-scope */
export const CodeEditor = ({ control, name, required, disabled = false }: CodeEditorProps) => {
const {
field,
// fieldState: { invalid, isTouched, isDirty },
// formState: { touchedFields, dirtyFields },
} = useController({
name,
control,
rules: { required },
defaultValue: '',
});
return (
<>
<Editor
value={field.value}
onValueChange={field.onChange}
highlight={(value) => highlight(value, languages.js, 'yaml')}
preClassName="font-mono whitespace-normal font-light"
textareaClassName="font-mono overflow-auto font-light"
className="font-mono text-sm font-light"
disabled={disabled}
/>
</>
);
};
type CodeEditorProps = {
control: any;
name: string;
required?: boolean;
disabled?: boolean;
};

View File

@ -0,0 +1 @@
export { CodeEditor } from './CodeEditor';

View File

@ -0,0 +1,51 @@
import React from 'react';
import { useController } from 'react-hook-form';
import { Switch as HeadlessSwitch } from '@headlessui/react';
function classNames(...classes: any) {
return classes.filter(Boolean).join(' ');
}
/* eslint-disable react/react-in-jsx-scope */
export const Switch = ({ control, name, label }: SwitchProps) => {
const {
field,
// fieldState: { invalid, isTouched, isDirty },
// formState: { touchedFields, dirtyFields },
} = useController({
name,
control,
defaultValue: '',
});
return (
<>
{label && (
<label htmlFor={name} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
)}
<HeadlessSwitch
checked={field.value}
onChange={field.onChange}
className={classNames(
field.value ? 'bg-primary-600' : 'bg-gray-200',
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none',
)}
>
<span
aria-hidden="true"
className={classNames(
field.value ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
)}
/>
</HeadlessSwitch>
</>
);
};
type SwitchProps = {
control: any;
name: string;
label?: string;
};

View File

@ -0,0 +1 @@
export { Switch } from './Switch';

View File

@ -1,3 +1,6 @@
export { Input } from './Input';
export { Select } from './Select';
export { Switch } from './Switch';
export { CodeEditor } from './CodeEditor';
export { TextArea } from './TextArea';
export { Checkbox } from './Checkbox';

View File

@ -14,6 +14,7 @@ const HYDRA_LOGOUT_URL = `${process.env.REACT_APP_HYDRA_PUBLIC_URL}/oauth2/sessi
const navigation = [
{ name: 'Dashboard', to: '/dashboard', requiresAdmin: false },
{ name: 'Users', to: '/users', requiresAdmin: true },
{ name: 'Apps', to: '/apps', requiresAdmin: true },
];
function classNames(...classes: any[]) {

View File

@ -7,9 +7,11 @@ export const Modal: React.FC<ModalProps> = ({
open,
onClose,
onSave,
saveButtonTitle = 'Save Changes',
children,
title = '',
useCancelButton = false,
cancelButtonTitle = 'Cancel',
isLoading = false,
leftActions = <></>,
saveButtonDisabled = false,
@ -94,7 +96,7 @@ export const Modal: React.FC<ModalProps> = ({
ref={saveButtonRef}
disabled={saveButtonDisabled}
>
Save Changes
{saveButtonTitle}
</button>
{useCancelButton && (
<button
@ -103,7 +105,7 @@ export const Modal: React.FC<ModalProps> = ({
onClick={onClose}
ref={cancelButtonRef}
>
Cancel
{cancelButtonTitle}
</button>
)}
</div>

View File

@ -5,7 +5,9 @@ export type ModalProps = {
onClose: () => void;
title?: string;
onSave?: () => void;
saveButtonTitle?: string;
useCancelButton?: boolean;
cancelButtonTitle?: string;
isLoading?: boolean;
leftActions?: React.ReactNode;
saveButtonDisabled?: boolean;

View File

@ -166,7 +166,7 @@ export const Table = <T extends Record<string, unknown>>({
/>
</svg>
</div>
<p className="text-sm text-primary-600 mt-2">Loading users</p>
<p className="text-sm text-primary-600 mt-2">Loading...</p>
</div>
</td>
</tr>

View File

@ -2,11 +2,14 @@ import React, { useState } from 'react';
import { TabPanel } from './TabPanel';
import { TabsProps } from './types';
export const Tabs = ({ tabs }: TabsProps) => {
export const Tabs = ({ tabs, onTabClick }: TabsProps) => {
const [activeTabIndex, setActiveTabIndex] = useState<number>(0);
const handleTabPress = (index: number) => () => {
setActiveTabIndex(index);
if (onTabClick) {
onTabClick(index);
}
};
function classNames(...classes: any) {
@ -23,7 +26,6 @@ export const Tabs = ({ tabs }: TabsProps) => {
id="tabs"
name="tabs"
className="block w-full focus:ring-primary-500 focus:border-primary-500 border-gray-300 rounded-md"
// defaultValue={tabs ? tabs.find((tab) => tab.current).name : undefined}
>
{tabs.map((tab) => (
<option key={tab.name}>{tab.name}</option>

View File

@ -6,6 +6,7 @@ type Tab = {
export interface TabsProps {
tabs: Tab[];
onTabClick?: (index: number) => void;
}
export interface TabPanelProps {

View File

@ -5,21 +5,31 @@ export const appAccessList = [
name: 'wekan',
image: '/assets/wekan.svg',
label: 'Wekan',
documentationUrl: 'https://github.com/wekan/wekan/wiki',
},
{
name: 'wordpress',
image: '/assets/wordpress.svg',
label: 'Wordpress',
documentationUrl: 'https://wordpress.org/support/',
},
{
name: 'nextcloud',
image: '/assets/nextcloud.svg',
label: 'Nextcloud',
documentationUrl: 'https://docs.nextcloud.com/server/latest/user_manual/en/',
},
{
name: 'zulip',
image: '/assets/zulip.svg',
label: 'Zulip',
documentationUrl: 'https://docs.zulip.com/help/',
},
{
name: 'monitoring',
image: '/assets/monitoring.svg',
label: 'Monitoring',
documentationUrl: 'https://grafana.com/docs/',
},
];

View File

@ -1,96 +1,173 @@
import React from 'react';
import { ChevronRightIcon } from '@heroicons/react/solid';
/**
* This page shows information about a single application. It contains several
* configuration options (that are not implemented in the back-end yet) such as:
*
* 1. Toggling auto-updates
* 2. Advanced configuration by overwriting helm values
* 3. Deleting the application
*
* This page is only available for Admin users.
*/
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm, useWatch } from 'react-hook-form';
import _ from 'lodash';
import { XCircleIcon } from '@heroicons/react/outline';
import { Tabs, Banner } from 'src/components';
import { Link } from 'react-router-dom';
import { DisableAppForm, useApps } from 'src/services/apps';
import { Modal, Tabs } from 'src/components';
import { Checkbox } from 'src/components/Form';
import { appAccessList } from 'src/components/UserModal/consts';
import { AdvancedTab, GeneralTab } from './components';
const pages = [
{ name: 'Apps', to: '/apps', current: true },
{ name: 'Nextcloud', to: '', current: false },
];
const tabs = [
{ name: 'General', component: <GeneralTab /> },
{ name: 'Advanced Configuration', component: <AdvancedTab /> },
];
export const AppSingle: React.FC = () => {
const [disableAppModal, setDisableAppModal] = useState(false);
const [removeAppData, setRemoveAppData] = useState(false);
const params = useParams();
const appSlug = params.slug;
const { app, loadApp, disableApp, clearSelectedApp } = useApps();
const navigate = useNavigate();
const initialDisableData = { slug: appSlug, removeAppData: false };
const { control, reset, handleSubmit } = useForm<DisableAppForm>({
defaultValues: initialDisableData,
});
const removeAppDataWatch = useWatch({
control,
name: 'removeAppData',
});
useEffect(() => {
setRemoveAppData(removeAppDataWatch);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [removeAppDataWatch]);
useEffect(() => {
if (appSlug) {
loadApp(appSlug);
}
return () => {
clearSelectedApp();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appSlug]);
if (!app) {
return null;
}
const appImageSrc = _.find(appAccessList, { name: appSlug })?.image;
const appDocumentationUrl = _.find(appAccessList, { name: appSlug })?.documentationUrl;
const openDocumentationInNewTab = () => {
window.open(appDocumentationUrl, '_blank', 'noopener,noreferrer');
};
const tabs = [
{
name: 'General',
component: <GeneralTab />,
},
{ name: 'Advanced Configuration', component: <AdvancedTab /> },
];
const onDisableApp = async () => {
try {
await handleSubmit((data) => disableApp(data))();
} catch (e: any) {
// Continue
}
setDisableAppModal(false);
clearSelectedApp();
navigate('/apps');
};
const handleCloseDisableModal = () => {
reset(initialDisableData);
setDisableAppModal(false);
};
return (
<>
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 flex-grow">
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-4">
<li>
<div className="flex items-center">
<Link to="/dashboard" className="text-sm font-medium text-gray-500 hover:text-gray-700">
<span>Dashboard</span>
</Link>
</div>
</li>
{pages.map((page) => (
<li key={page.name}>
<div className="flex items-center">
<ChevronRightIcon className="flex-shrink-0 h-5 w-5 text-gray-400" aria-hidden="true" />
<Link
to={page.to}
className="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"
aria-current={page.current ? 'page' : undefined}
>
{page.name}
</Link>
</div>
</li>
))}
</ol>
</nav>
</div>
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 overflow-hidden">
<Banner title="Managing single app instances coming soon." titleSm="Comming soon!" />
</div>
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 overflow-hidden opacity-40 cursor-default pointer-events-none select-none">
<div className="bg-white overflow-hidden shadow rounded-sm mb-5">
<div className="px-4 py-5 sm:p-6 flex justify-between items-center">
<div className="mr-4 flex items-center">
<img
className="h-24 w-24 rounded-md overflow-hidden mr-4"
src="./../assets/nextcloud.svg"
alt="Nextcloud"
/>
<div>
<h2 className="text-2xl leading-8 font-bold">Nextcloud</h2>
<div className="text-sm leading-5 font-medium text-gray-500">Installed on August 25, 2020</div>
</div>
</div>
<div className="flex flex-col">
<button
type="button"
className="mb-3 inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-yellow-900 bg-yellow-300 hover:bg-yellow-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
>
<XCircleIcon className="-ml-0.5 mr-2 h-4 w-4 text-yellow-900" aria-hidden="true" />
Disable App
</button>
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
>
View Documentation
</button>
</div>
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 overflow-hidden lg:flex lg:flex-row">
<div
className="block bg-white overflow-hidden shadow rounded-sm basis-4/12 mx-auto sm:px-6 lg:px-8 overflow-hidden block lg:flex-none"
style={{ height: 'fit-content' }}
>
<div className="px-4 py-5 sm:p-6 flex flex-col">
<img className="h-24 w-24 rounded-md overflow-hidden mr-4 mb-3" src={appImageSrc} alt={app.name} />
<div className="mb-3">
<h2 className="text-2xl leading-8 font-bold">{app.name}</h2>
<div className="text-sm leading-5 font-medium text-gray-500">Installed on August 25, 2020</div>
</div>
<button
type="button"
className="mb-3 inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-yellow-900 bg-yellow-300 hover:bg-yellow-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
onClick={() => setDisableAppModal(true)}
>
<XCircleIcon className="-ml-0.5 mr-2 h-4 w-4 text-yellow-900" aria-hidden="true" />
Disable App
</button>
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
onClick={openDocumentationInNewTab}
>
View manual
</button>
</div>
</div>
<div className="bg-white shadow rounded-sm">
<div className="mx-auto sm:mt-8 lg:mt-0 lg:px-8 overflow-hidden block">
<div className="bg-white sm:px-6 shadow rounded-sm basis-8/12">
<div className="px-4 py-5 sm:p-6">
<Tabs tabs={tabs} />
</div>
</div>
</div>
</>
{disableAppModal && (
<Modal
onClose={handleCloseDisableModal}
open={disableAppModal}
onSave={onDisableApp}
saveButtonTitle={removeAppData ? `Yes, delete and it's data` : 'Yes, delete'}
cancelButtonTitle="No, cancel"
useCancelButton
>
<div className="bg-white px-4">
<div className="space-y-10 divide-y divide-gray-200">
<div>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">Disable app</h3>
</div>
<div className="px-4 py-5 sm:p-6">
Are you sure you want to disable {app.name}? The app will get uninstalled and none of your users will
be able to access the app.
</div>
<fieldset className="px-4 py-5 sm:p-6">
<div className="relative flex items-start">
<div className="flex items-center h-5">
<Checkbox control={control} name="removeAppData" id="removeAppData" />
</div>
<div className="ml-3 text-sm">
<label htmlFor="removeAppData" className="font-medium text-gray-700">
Remove app data
</label>
<p id="removeAppData-description" className="text-gray-500">
{removeAppData
? `The app's data will be removed. After this operation is done you will not be able to access the app, nor the app data. If you re-install the app, it will have none of the data it had before.`
: `The app's data does not get removed. If you install the app again, you will be able to access the data again.`}
</p>
</div>
</div>
</fieldset>
</div>
</div>
</div>
</Modal>
)}
</div>
);
};

View File

@ -1,167 +1,129 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useCallback, useMemo } from 'react';
import { ChevronRightIcon, SearchIcon, PlusIcon } from '@heroicons/react/solid';
import { CogIcon, TrashIcon } from '@heroicons/react/outline';
import { ConfirmationModal } from 'src/components/Modal';
import { Table, Banner } from 'src/components';
import { Link } from 'react-router-dom';
const pages = [{ name: 'Apps', href: '#', current: true }];
/**
* This page shows all the applications and their status in a table.
*
* This page is only available for Admin users.
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
// import { useNavigate } from 'react-router';
import { SearchIcon } from '@heroicons/react/solid';
import { showToast, ToastType } from 'src/common/util/show-toast';
import _, { debounce } from 'lodash';
import { Table } from 'src/components';
import { App, AppStatusEnum, useApps } from 'src/services/apps';
// import { AppInstallModal } from './components';
import { getConstForStatus } from './consts';
export const Apps: React.FC = () => {
const [selectedRowsIds, setSelectedRowsIds] = useState({});
const [deleteModal, setDeleteModal] = useState(false);
const [search, setSearch] = useState('');
// If you want to enable the App Install button again, uncomment this:
// const [installModalOpen, setInstallModalOpen] = useState(false);
// const [appSlug, setAppSlug] = useState(null);
const deleteModalOpen = () => setDeleteModal(true);
const deleteModalClose = () => setDeleteModal(false);
const [search, setSearch] = useState('');
const { apps, appTableLoading, loadApps } = useApps();
const handleSearch = useCallback((event: any) => {
setSearch(event.target.value);
}, []);
const data: any[] = useMemo(
() => [
{
id: 1,
name: 'Nextcloud',
status: 'Active for everyone',
assetSrc: './assets/nextcloud.svg',
},
{
id: 2,
name: 'Wekan',
status: 'Active for everyone',
assetSrc: './assets/wekan.svg',
},
{
id: 3,
name: 'Rocketchat',
status: 'Active for everyone',
assetSrc: './assets/rocketchat.svg',
},
{
id: 4,
name: 'Wordpress',
status: 'Active for everyone',
assetSrc: './assets/wordpress.svg',
},
],
[],
);
const debouncedSearch = useCallback(debounce(handleSearch, 250), []);
const filterSearch = useMemo(() => {
return data.filter((item: any) => item.name?.toLowerCase().includes(search.toLowerCase()));
}, [search]);
useEffect(() => {
loadApps();
const columns: any = useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
Cell: (e: any) => {
return (
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<img className="h-10 w-10 rounded-md overflow-hidden" src={e.cell.row.original.assetSrc} alt="" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{e.cell.row.original.name}</div>
</div>
</div>
);
},
width: 'auto',
},
{
Header: 'Status',
accessor: 'status',
Cell: (e: any) => {
return (
<div className="flex items-center">
<div className="flex-shrink-0 h-4 w-4 rounded-full bg-green-600" />
<div className="ml-2 text-sm text-green-600">{e.cell.row.original.status}</div>
</div>
);
},
width: 'auto',
},
{
Header: ' ',
Cell: () => {
return (
<div className="text-right opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {}}
type="button"
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Configure
</button>
</div>
);
},
width: 'auto',
},
],
[],
);
const selectedRows = useCallback((rows: Record<string, boolean>) => {
setSelectedRowsIds(rows);
return () => {
debouncedSearch.cancel();
};
}, []);
return (
<>
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 flex-grow">
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-4">
<li>
<div className="flex items-center">
<Link to="/dashboard" className="text-sm font-medium text-gray-500 hover:text-gray-700">
<span>Dashboard</span>
</Link>
</div>
</li>
{pages.map((page) => (
<li key={page.name}>
<div className="flex items-center">
<ChevronRightIcon className="flex-shrink-0 h-5 w-5 text-gray-400" aria-hidden="true" />
<a
href={page.href}
className="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"
aria-current={page.current ? 'page' : undefined}
>
{page.name}
</a>
</div>
</li>
))}
</ol>
</nav>
</div>
const filterSearch = useMemo(() => {
return _.filter(apps, (item) => item.name?.toLowerCase().includes(search.toLowerCase()));
}, [apps, search]);
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 overflow-hidden">
<Banner
title="Your app instances management will be here soon, in the meantime, feel free to explore."
titleSm="Comming soon!"
/>
</div>
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 flex-grow">
<div className="pb-5 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
<h1 className="text-3xl leading-6 font-bold text-gray-900">Apps</h1>
<div className="mt-3 sm:mt-0 sm:ml-4">
<button
disabled
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800"
>
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Add new app
</button>
const columns: any = [
{
Header: 'Name',
accessor: 'name',
Cell: (e: any) => {
const app = e.cell.row.original as App;
return (
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<img className="h-10 w-10 rounded-md overflow-hidden" src={app.assetSrc} alt={app.name} />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{app.name}</div>
</div>
</div>
);
},
width: 'auto',
},
{
Header: 'Status',
accessor: 'status',
Cell: (e: any) => {
const status = e.cell.row.original.status as AppStatusEnum;
return (
<div className="flex items-center">
<div className={`flex-shrink-0 h-4 w-4 rounded-full bg-${getConstForStatus(status, 'colorClass')}`} />
{status === AppStatusEnum.Installing ? (
<div
className={`ml-2 cursor-pointer text-sm text-${getConstForStatus(status, 'colorClass')}`}
onClick={() => showToast('Installing an app can take up to 10 minutes.', ToastType.Success)}
>
{status}
</div>
) : (
<div className={`ml-2 text-sm text-${getConstForStatus(status, 'colorClass')}`}>{status}</div>
)}
</div>
);
},
width: 'auto',
},
// If you want to enable the App Install button again, uncomment this:
//
// We need to implement installation and configuration in the back-end to be
// able to use those buttons.
// {
// Header: ' ',
// Cell: (e: any) => {
// const navigate = useNavigate();
// const appStatus = e.cell.row.original.status as AppStatusEnum;
// if (appStatus === AppStatusEnum.Installing) {
// return null;
// }
// const { slug } = e.cell.row.original;
// let buttonFunction = () => navigate(`/apps/${slug}`);
// if (appStatus === AppStatusEnum.NotInstalled) {
// buttonFunction = () => {
// setAppSlug(slug);
// setInstallModalOpen(true);
// };
// }
// return (
// <div className="text-right opacity-0 group-hover:opacity-100 transition-opacity">
// <button
// onClick={buttonFunction}
// type="button"
// className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
// >
// {getConstForStatus(appStatus, 'buttonIcon')}
// {getConstForStatus(appStatus, 'buttonTitle')}
// </button>
// </div>
// );
// },
// width: 'auto',
// },
];
return (
<div className="relative">
<div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
<div className="pb-5 mt-6 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
<h1 className="text-3xl leading-6 font-bold text-gray-900">Apps</h1>
</div>
<div className="flex justify-between w-100 my-3 items-center">
@ -187,36 +149,30 @@ export const Apps: React.FC = () => {
</div>
</div>
</div>
{selectedRowsIds && Object.keys(selectedRowsIds).length !== 0 && (
<button
onClick={deleteModalOpen}
type="button"
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
<TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Delete
</button>
)}
</div>
<div className="flex flex-col">
<div className="-my-2 sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow border-b border-gray-200 sm:rounded-lg">
<Table data={filterSearch} columns={columns} getSelectedRowIds={selectedRows} selectable />
<Table
data={_.filter(filterSearch, (app) => app.slug !== 'dashboard') as any}
columns={columns}
loading={appTableLoading}
/>
</div>
</div>
</div>
</div>
<ConfirmationModal
open={deleteModal}
onClose={deleteModalClose}
title="Delete service"
body="Are you sure you want to delete this service? All of your data will be permanently removed. This action cannot be undone."
/>
</div>
</>
{
// If you want to enable the App Install button again, uncomment this:
//
// installModalOpen && (
// <AppInstallModal appSlug={appSlug} onClose={() => setInstallModalOpen(false)} open={installModalOpen} />
// )
}
</div>
);
};

View File

@ -1,20 +1,50 @@
import React, { Fragment } from 'react';
import React from 'react';
import _ from 'lodash';
import Editor from 'react-simple-code-editor';
import { Menu, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/solid';
// import { Menu, Transition } from '@headlessui/react';
// import { ChevronDownIcon } from '@heroicons/react/solid';
import yaml from 'js-yaml';
import { highlight, languages } from 'prismjs';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-yaml';
import 'prismjs/themes/prism.css';
import { showToast, ToastType } from 'src/common/util/show-toast';
import { useApps } from 'src/services/apps';
import { initialEditorYaml } from '../../consts';
import { Secrets } from './components';
function classNames(...classes: any) {
return classes.filter(Boolean).join(' ');
}
export const AdvancedTab = () => {
const [code, setCode] = React.useState(initialEditorYaml);
const { app, editApp } = useApps();
const resetCode = () => {
setCode(initialEditorYaml);
showToast('Code was reset.');
};
const isConfigurationValid = () => {
try {
yaml.load(code);
return true;
} catch (e: any) {
return false;
}
};
const verifyCode = () => {
if (isConfigurationValid()) {
showToast('Configuration is valid.', ToastType.Success);
} else {
showToast('Configuration is not valid! Please fix configuration issues and try again.', ToastType.Error);
}
};
const saveChanges = () => {
if (isConfigurationValid()) {
editApp({ ...app, configuration: code });
return;
}
showToast('Configuration is not valid! Please fix configuration issues and try again.', ToastType.Error);
};
return (
<>
@ -22,9 +52,24 @@ export const AdvancedTab = () => {
<h1 className="text-2xl leading-6 font-medium text-gray-900">Configuration</h1>
</div>
<div className="grid grid-flow-col grid-cols-2 gap-8">
<div>
<div>
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex justify-between items-center rounded-t-lg">
<div className="bg-gray-100 overflow-hidden rounded-lg">
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex items-center">
<span className="text-gray-600 text-lg leading-6 font-medium">Current Configuration</span>
</div>
<div className="px-4 py-5 sm:p-6 overflow-x-auto">
<Editor
value={initialEditorYaml}
onValueChange={_.noop}
highlight={(value) => highlight(value, languages.js, 'yaml')}
preClassName="font-mono text-sm font-light"
textareaClassName="font-mono overflow-auto font-light"
className="font-mono text-sm font-light"
disabled
/>
</div>
</div>
<div className="overflow-hidden rounded-lg">
{/* <div className="px-4 h-16 sm:px-6 bg-gray-200 flex justify-between items-center rounded-t-lg">
<span className="text-gray-600 text-lg leading-6 font-medium">Edit Configuration</span>
<Menu as="div" className="relative inline-block text-left">
@ -89,62 +134,34 @@ export const AdvancedTab = () => {
</Menu.Items>
</Transition>
</Menu>
</div>
<div className="px-4 py-5 sm:p-6 border border-t-0 border-gray-200">
<Editor
value={code}
onValueChange={(value) => setCode(value)}
highlight={(value) => highlight(value, languages.js, 'yaml')}
preClassName="font-mono whitespace-normal font-light"
textareaClassName="font-mono overflow-auto font-light"
className="font-mono text-sm font-light"
/>
</div>
</div> */}
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex items-center">
<span className="text-gray-600 text-lg leading-6 font-medium">Edit Configuration</span>
</div>
</div>
<div>
<div className="bg-gray-100 overflow-hidden rounded-lg">
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex items-center">
<span className="text-gray-600 text-lg leading-6 font-medium">Current Configuration</span>
</div>
<div className="px-4 py-5 sm:p-6 overflow-x-auto">
<pre className="font-mono text-sm font-light">
{`luck: except
natural: still
near: though
search:
- feature
- - 1980732354.689713
- hour
- butter:
ordinary: 995901949.8974948
teeth: true
whole:
- -952367353
- - talk: -1773961379
temperature: false
oxygen: true
laugh:
flag:
in: 2144751662
hospital: -1544066384.1973226
law: congress
great: stomach`}
</pre>
</div>
<div className="px-4 py-5 sm:p-6 border border-t-0 border-gray-200">
<Editor
value={code}
onValueChange={(value) => setCode(value)}
highlight={(value) => highlight(value, languages.js, 'yaml')}
preClassName="font-mono whitespace-normal font-light"
textareaClassName="font-mono overflow-auto font-light"
className="font-mono text-sm font-light"
/>
</div>
</div>
</div>
<div className="flex justify-end mt-10">
<button
type="button"
onClick={resetCode}
className="mr-3 inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Cancel
Reset
</button>
<button
type="button"
onClick={verifyCode}
className="mr-3 inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Verify
@ -152,13 +169,12 @@ search:
<button
type="button"
onClick={saveChanges}
className="inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Save changes
</button>
</div>
<Secrets />
</>
);
};

View File

@ -0,0 +1,81 @@
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import _ from 'lodash';
import { App, useApps } from 'src/services/apps';
import { Modal } from 'src/components';
import { Input } from 'src/components/Form';
import { AppInstallModalProps } from './types';
import { initialAppForm, initialCode } from './consts';
export const AppInstallModal = ({ open, onClose, appSlug }: AppInstallModalProps) => {
const [appName, setAppName] = useState('');
const { app, appLoading, installApp, loadApp, clearSelectedApp } = useApps();
const { control, reset, handleSubmit } = useForm<App>({
defaultValues: initialAppForm,
});
useEffect(() => {
if (appSlug) {
loadApp(appSlug);
}
return () => {
reset(initialAppForm);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appSlug, open]);
useEffect(() => {
if (!_.isEmpty(app)) {
setAppName(app.name);
reset({ url: app.url, configuration: initialCode, slug: appSlug ?? '' });
}
return () => {
reset({ url: initialAppForm.url, configuration: initialCode, slug: appSlug ?? '' });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [app, reset, open]);
const handleClose = () => {
clearSelectedApp();
reset();
onClose();
};
const handleSave = async () => {
try {
await handleSubmit((data) => installApp(data))();
} catch (e: any) {
// Continue
}
handleClose();
};
const handleKeyPress = (e: any) => {
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
handleSave();
}
};
return (
<Modal onClose={handleClose} open={open} onSave={handleSave} isLoading={appLoading} useCancelButton>
<div className="bg-white px-4">
<div className="space-y-10 divide-y divide-gray-200">
<div>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">Install app {appName}</h3>
</div>
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<Input control={control} name="url" label="URL" onKeyPress={handleKeyPress} required={false} />
</div>
</div>
</div>
</div>
</div>
</Modal>
);
};

View File

@ -1,5 +1,6 @@
export const initialEditorYaml = () => {
return `luck: except
import { App } from 'src/services/apps';
export const initialCode = `luck: except
natural: still
near: though
search:
@ -20,4 +21,7 @@ search:
hospital: -1544066384.1973226
law: congress
great: stomach`;
};
export const initialAppForm = {
url: '',
} as App;

View File

@ -0,0 +1 @@
export { AppInstallModal } from './AppInstallModal';

View File

@ -0,0 +1,5 @@
export type AppInstallModalProps = {
open: boolean;
onClose: () => void;
appSlug: string | null;
};

View File

@ -1,161 +1,59 @@
import React, { useState } from 'react';
import { RadioGroup } from '@headlessui/react';
function classNames(...classes: any) {
return classes.filter(Boolean).join(' ');
}
const settings = [
{ name: 'Public access', description: 'This project would be available to anyone who has the link' },
{ name: 'Private to Project Members', description: 'Only members of this project would be able to access' },
{ name: 'Private to you', description: 'You are the only one able to access this project' },
];
import _ from 'lodash';
import React, { useEffect } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { Switch } from 'src/components/Form';
import { App, useApps } from 'src/services/apps';
export const GeneralTab = () => {
const [selected, setSelected] = useState(settings[0]);
const { app, editApp } = useApps();
const { control, reset, handleSubmit } = useForm<App>({
defaultValues: { automaticUpdates: false },
});
useEffect(() => {
if (!_.isEmpty(app)) {
reset(app);
}
return () => {
reset({ automaticUpdates: false });
};
}, [app, reset]);
const onSubmit: SubmitHandler<App> = (data) => {
try {
editApp(data);
} catch (e: any) {
// continue
}
};
return (
<div className="py-4">
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-4">
<div className="md:grid md:grid-cols-3 md:gap-6">
<div className="md:col-span-1">
<h3 className="text-lg font-medium leading-6 text-gray-900">Privacy</h3>
<p className="mt-1 text-sm text-gray-500">Change your app privacy</p>
<div className="md:col-span-2">
<h3 className="text-lg font-medium leading-6 text-gray-900">Automatic updates</h3>
<p className="mt-1 text-sm text-gray-500">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla et nibh sit amet mauris faucibus molestie
gravida at orci.
</p>
</div>
<div className="mt-5 md:mt-0 md:col-span-2">
<div className="mt-1">
<RadioGroup value={selected} onChange={setSelected}>
<RadioGroup.Label className="sr-only">Privacy setting</RadioGroup.Label>
<div className="bg-white rounded-md -space-y-px">
{settings.map((setting, settingIdx) => (
<RadioGroup.Option
key={setting.name}
value={setting}
className={({ checked }) =>
classNames(
settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '',
settingIdx === settings.length - 1 ? 'rounded-bl-md rounded-br-md' : '',
checked ? 'bg-primary-50 border-primary-400 z-10' : 'border-gray-200',
'relative border p-4 flex cursor-pointer focus:outline-none',
)
}
>
{({ active, checked }) => (
<>
<span
className={classNames(
checked ? 'bg-primary-600 border-transparent' : 'bg-white border-gray-300',
active ? 'ring-2 ring-offset-2 ring-primary-100' : '',
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center',
)}
aria-hidden="true"
>
<span className="rounded-full bg-white w-1.5 h-1.5" />
</span>
<div className="ml-3 flex flex-col">
<RadioGroup.Label
as="span"
className={classNames(
checked ? 'text-primary-900' : 'text-gray-900',
'block text-sm font-medium',
)}
>
{setting.name}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className={classNames(checked ? 'text-primary-900' : 'text-gray-500', 'block text-sm')}
>
{setting.description}
</RadioGroup.Description>
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
<div className="my-auto ml-auto">
<Switch control={control} name="automaticUpdates" />
</div>
</div>
</div>
<div className="mt-10">
<div className="md:grid md:grid-cols-3 md:gap-6">
<div className="md:col-span-1">
<h3 className="text-lg font-medium leading-6 text-gray-900">Notifications</h3>
<p className="mt-1 text-sm text-gray-500">Change you notifications settings</p>
</div>
<div className="mt-5 md:mt-0 md:col-span-2">
<fieldset className="space-y-5 -mt-4">
<legend className="sr-only">Notifications</legend>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="comments"
aria-describedby="comments-description"
name="comments"
type="checkbox"
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="comments" className="font-medium text-gray-700">
Comments
</label>
<p id="comments-description" className="text-gray-500">
Get notified when someones posts a comment on a posting.
</p>
</div>
</div>
<div>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="candidates"
aria-describedby="candidates-description"
name="candidates"
type="checkbox"
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="candidates" className="font-medium text-gray-700">
Candidates
</label>
<p id="candidates-description" className="text-gray-500">
Get notified when a candidate applies for a job.
</p>
</div>
</div>
</div>
<div>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="offers"
aria-describedby="offers-description"
name="offers"
type="checkbox"
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="offers" className="font-medium text-gray-700">
Offers
</label>
<p id="offers-description" className="text-gray-500">
Get notified when a candidate accepts or rejects an offer.
</p>
</div>
</div>
</div>
</fieldset>
</div>
<div className="py-3 sm:flex">
<div className="ml-auto sm:flex sm:flex-row-reverse">
<button
type="submit"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
>
Save changes
</button>
</div>
</div>
</div>
</form>
);
};

View File

@ -1,2 +1,3 @@
export { GeneralTab } from './GeneralTab';
export { AdvancedTab } from './AdvancedTab';
export { AppInstallModal } from './AppInstallModal';

View File

@ -0,0 +1,52 @@
import React from 'react';
import { CogIcon, PlusCircleIcon } from '@heroicons/react/outline';
import _ from 'lodash';
import { AppStatusEnum } from 'src/services/apps';
export const initialEditorYaml = `luck: except
natural: still
near: though
search:
- feature
- - 1980732354.689713
- hour
- butter:
ordinary: 995901949.8974948
teeth: true
whole:
- -952367353
- - talk: -1773961379
temperature: false
oxygen: true
laugh:
flag:
in: 2144751662
hospital: -1544066384.1973226
law: congress
great: stomach`;
const tableConsts = [
{
status: AppStatusEnum.Installed,
colorClass: 'green-600',
buttonTitle: 'Configure',
buttonIcon: <CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />,
},
{
status: AppStatusEnum.NotInstalled,
colorClass: 'gray-600',
buttonTitle: 'Install',
buttonIcon: <PlusCircleIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />,
},
{
status: AppStatusEnum.Installing,
colorClass: 'primary-600',
buttonTitle: null,
buttonIcon: null,
},
];
export const getConstForStatus = (appStatus: AppStatusEnum, paramName: string) => {
const tableConst = _.find(tableConsts, { status: appStatus });
return _.get(tableConst, paramName);
};

View File

@ -1,13 +1,28 @@
import React from 'react';
import clsx from 'clsx';
import { DASHBOARD_APPS, DASHBOARD_QUICK_ACCESS } from './consts';
import { DashboardCard } from './components';
/* eslint-disable react-hooks/exhaustive-deps */
/**
* Page that shows only installed applications, and links to them.
*
* "Utilities" is a special section that links to the Stackspin documentation,
* and that shows the "Monitoring" application if it is installed.
*/
import React, { useEffect } from 'react';
import { useApps } from 'src/services/apps';
import { AppStatusEnum } from 'src/services/apps/types';
import { DashboardCard, DashboardUtility } from './components';
import { DASHBOARD_QUICK_ACCESS, HIDDEN_APPS, UTILITY_APPS } from './consts';
export const Dashboard: React.FC = () => {
const host = window.location.hostname;
const splitedDomain = host.split('.');
splitedDomain.shift();
const rootDomain = splitedDomain.join('.');
const { apps, loadApps } = useApps();
// Tell React to load the apps
useEffect(() => {
loadApps();
return () => {};
}, []);
return (
<div className="relative">
@ -19,36 +34,28 @@ export const Dashboard: React.FC = () => {
<div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-4 mb-10">
{DASHBOARD_APPS(rootDomain!).map((app) => (
<DashboardCard app={app} key={app.name} />
))}
{apps
.filter((app) => HIDDEN_APPS.concat(UTILITY_APPS).indexOf(app.slug) === -1)
.filter((app) => app.status !== AppStatusEnum.NotInstalled)
.map((app) => (
<DashboardCard app={app} key={app.name} />
))}
</div>
<div className="max-w-4xl mx-auto py-4 sm:px-6 lg:px-8 h-full flex-grow">
<div className="pb-4 border-b border-gray-200 sm:flex sm:items-center">
<h3 className="text-lg leading-6 font-medium text-gray-900">Utilities</h3>
</div>
<dl className="mt-5 grid grid-cols-1 gap-2 sm:grid-cols-2">
{DASHBOARD_QUICK_ACCESS(rootDomain!).map((item) => (
<a
href={item.url}
key={item.name}
target="_blank"
rel="noreferrer"
className={clsx('bg-white rounded-lg overflow-hidden sm:p-2 flex items-center group', {
'opacity-40 cursor-default': !item.active,
})}
>
<div className="w-16 h-16 flex items-center justify-center bg-primary-100 group-hover:bg-primary-200 transition-colors rounded-lg mr-4">
<item.icon className="h-6 w-6 text-primary-900" aria-hidden="true" />
</div>
<div>
<dt className="truncate text-sm leading-5 font-medium">{item.name}</dt>
<dd className="mt-1 text-gray-500 text-sm leading-5 font-normal">{item.description}</dd>
</div>
</a>
{DASHBOARD_QUICK_ACCESS.map((item) => (
<DashboardUtility item={item} key={item.name} />
))}
{apps
.filter((app) => UTILITY_APPS.indexOf(app.slug) !== -1 && app.url !== null)
.filter((app) => app.status !== AppStatusEnum.NotInstalled)
.map((app) => (
<DashboardUtility item={app} key={app.name} />
))}
</dl>
</div>
</div>

View File

@ -25,7 +25,7 @@ export const DashboardCard: React.FC<any> = ({ app }: { app: any }) => {
<img
className="h-16 w-16 rounded-md overflow-hidden mr-4 flex-shrink-0"
src={app.assetSrc}
alt="Nextcloud"
alt={app.name}
/>
<div>

View File

@ -0,0 +1,36 @@
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
export const DashboardUtility: React.FC<any> = ({ item }: { item: any }) => {
const [content, setContent] = useState('');
useEffect(() => {
fetch(item.markdownSrc)
.then((res) => res.text())
.then((md) => {
return setContent(md);
})
.catch(() => {});
}, [item.markdownSrc]);
return (
<a
href={item.url}
key={item.name}
target="_blank"
rel="noreferrer"
className="bg-white rounded-lg overflow-hidden sm:p-2 flex items-center group"
>
<div className="w-16 h-16 flex items-center justify-center bg-primary-100 group-hover:bg-primary-200 transition-colors rounded-lg mr-4">
{item.icon && <item.icon className="h-6 w-6 text-primary-900" aria-hidden="true" />}
{item.assetSrc && <img className="h-6 w-6" src={item.assetSrc} alt={item.name} />}
</div>
<div>
<dt className="truncate text-sm leading-5 font-medium">{item.name}</dt>
<dd className="mt-1 text-gray-500 text-sm leading-5 font-normal">
<ReactMarkdown>{content}</ReactMarkdown>
</dd>
</div>
</a>
);
};

View File

@ -0,0 +1 @@
export { DashboardUtility } from './DashboardUtility';

View File

@ -1 +1,2 @@
export { DashboardCard } from './DashboardCard';
export { DashboardUtility } from './DashboardUtility';

View File

@ -1,51 +1,16 @@
import { ChartBarIcon, InformationCircleIcon } from '@heroicons/react/outline';
import { InformationCircleIcon } from '@heroicons/react/outline';
export const DASHBOARD_APPS = (rootDomain: string) => [
export const DASHBOARD_QUICK_ACCESS = [
{
id: 1,
name: 'Nextcloud',
assetSrc: '/assets/nextcloud.svg',
markdownSrc: '/markdown/nextcloud.md',
url: `https://files.${rootDomain}`,
},
{
id: 2,
name: 'Wekan',
assetSrc: '/assets/wekan.svg',
markdownSrc: '/markdown/wekan.md',
url: `https://wekan.${rootDomain}`,
},
{
id: 3,
name: 'Zulip',
assetSrc: '/assets/zulip.svg',
markdownSrc: '/markdown/zulip.md',
url: `https://zulip.${rootDomain}`,
},
{
id: 4,
name: 'Wordpress',
assetSrc: '/assets/wordpress.svg',
markdownSrc: '/markdown/wordpress.md',
url: `https://www.${rootDomain}`,
},
];
export const DASHBOARD_QUICK_ACCESS = (rootDomain: string) => [
{
id: 1,
name: 'Monitoring →',
url: `https://grafana.${rootDomain}`,
description: 'Monitor your system with Grafana',
icon: ChartBarIcon,
active: true,
},
{
id: 2,
name: 'Support →',
name: 'Support',
url: 'https://docs.stackspin.net',
description: 'Access documentation and forum',
markdownSrc: '/markdown/support.md',
icon: InformationCircleIcon,
active: true,
},
];
/** Apps that should not be shown on the dashboard */
export const HIDDEN_APPS = ['dashboard'];
/** Apps that should be shown under "Utilities" */
export const UTILITY_APPS = ['monitoring'];

View File

@ -1,3 +1,6 @@
/**
* Login page that starts the OAuth2 authentication flow.
*/
import React from 'react';
import clsx from 'clsx';
import { LockClosedIcon } from '@heroicons/react/solid';

View File

@ -1,4 +1,9 @@
/* eslint-disable react-hooks/exhaustive-deps */
/**
* This page shows a table of all users. It is only available for Admin users.
*
* Admin users can add one or more users, or edit a user.
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { SearchIcon, PlusIcon, ViewGridAddIcon } from '@heroicons/react/solid';
import { CogIcon, TrashIcon } from '@heroicons/react/outline';

View File

@ -6,6 +6,7 @@ import storage from 'redux-persist/lib/storage';
import { reducer as authReducer } from 'src/services/auth';
import usersReducer from 'src/services/users/redux/reducers';
import appsReducer from 'src/services/apps/redux/reducers';
import { State } from './types';
const persistConfig = {
@ -17,6 +18,7 @@ const persistConfig = {
const appReducer = combineReducers<State>({
auth: authReducer,
users: usersReducer,
apps: appsReducer,
});
const persistedReducer = persistReducer(persistConfig, appReducer);

View File

@ -2,10 +2,12 @@ import { Store } from 'redux';
import { AuthState } from 'src/services/auth/redux';
import { UsersState } from 'src/services/users/redux';
import { AppsState } from 'src/services/apps/redux';
export interface AppStore extends Store, State {}
export interface State {
auth: AuthState;
users: UsersState;
apps: AppsState;
}

View File

@ -0,0 +1 @@
export { useApps } from './use-apps';

View File

@ -0,0 +1,48 @@
import { useDispatch, useSelector } from 'react-redux';
import { fetchApps, fetchAppBySlug, updateApp, installAppBySlug, clearCurrentApp, deleteApp } from '../redux';
import { getCurrentApp, getAppLoading, getAppsLoading, getApps } from '../redux/selectors';
export function useApps() {
const dispatch = useDispatch();
const apps = useSelector(getApps);
const app = useSelector(getCurrentApp);
const appLoading = useSelector(getAppLoading);
const appTableLoading = useSelector(getAppsLoading);
function loadApps() {
return dispatch(fetchApps());
}
function loadApp(slug: string) {
return dispatch(fetchAppBySlug(slug));
}
function editApp(data: any) {
return dispatch(updateApp(data));
}
function installApp(data: any) {
return dispatch(installAppBySlug(data));
}
function disableApp(data: any) {
return dispatch(deleteApp(data));
}
function clearSelectedApp() {
return dispatch(clearCurrentApp());
}
return {
apps,
app,
loadApp,
loadApps,
editApp,
appLoading,
appTableLoading,
installApp,
disableApp,
clearSelectedApp,
};
}

View File

@ -0,0 +1,3 @@
export * from './types';
export { reducer } from './redux';
export { useApps } from './hooks';

View File

@ -0,0 +1,155 @@
import { Dispatch } from 'redux';
import { showToast, ToastType } from 'src/common/util/show-toast';
import { performApiCall } from 'src/services/api';
import { transformAppRequest, transformApp, transformInstallAppRequest } from '../transformations';
export enum AppActionTypes {
FETCH_APPS = 'apps/fetch_apps',
FETCH_APP = 'apps/fetch_app',
UPDATE_APP = 'apps/update_app',
INSTALL_APP = 'apps/install_app',
DELETE_APP = 'apps/delete_app',
CLEAR_APP = 'apps/clear_app',
SET_APP_LOADING = 'apps/app_loading',
SET_APPS_LOADING = 'apps/apps_loading',
}
export const setAppsLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => {
dispatch({
type: AppActionTypes.SET_APPS_LOADING,
payload: isLoading,
});
};
export const setAppLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => {
dispatch({
type: AppActionTypes.SET_APP_LOADING,
payload: isLoading,
});
};
export const fetchApps = () => async (dispatch: Dispatch<any>) => {
dispatch(setAppsLoading(true));
try {
const { data } = await performApiCall({
path: '/apps',
method: 'GET',
});
dispatch({
type: AppActionTypes.FETCH_APPS,
payload: data.map(transformApp),
});
} catch (err) {
console.error(err);
}
dispatch(setAppsLoading(false));
};
export const fetchAppBySlug = (slug: string) => async (dispatch: Dispatch<any>) => {
dispatch(setAppLoading(true));
try {
const { data } = await performApiCall({
path: `/apps/${slug}`,
method: 'GET',
});
dispatch({
type: AppActionTypes.FETCH_APP,
payload: transformApp(data),
});
} catch (err) {
console.error(err);
}
dispatch(setAppLoading(false));
};
export const updateApp = (app: any) => async (dispatch: Dispatch<any>) => {
dispatch(setAppLoading(true));
try {
const { data } = await performApiCall({
path: `/apps/${app.slug}`,
method: 'PUT',
body: transformAppRequest(app),
});
dispatch({
type: AppActionTypes.UPDATE_APP,
payload: transformApp(data),
});
showToast('App is updated!', ToastType.Success);
dispatch(fetchApps());
} catch (err) {
console.error(err);
}
dispatch(setAppLoading(false));
};
export const installAppBySlug = (app: any) => async (dispatch: Dispatch<any>) => {
dispatch(setAppLoading(true));
try {
const { data } = await performApiCall({
path: `/apps/${app.slug}/install`,
method: 'PATCH',
body: transformInstallAppRequest(app),
});
dispatch({
type: AppActionTypes.INSTALL_APP,
payload: transformApp(data),
});
showToast('App installing...', ToastType.Success);
dispatch(fetchApps());
} catch (err: any) {
dispatch(setAppLoading(false));
showToast(`${err}`, ToastType.Error);
throw err;
}
dispatch(setAppLoading(false));
};
export const deleteApp = (appData: any) => async (dispatch: Dispatch<any>) => {
dispatch(setAppLoading(true));
try {
await performApiCall({
path: `/apps/${appData.slug}`,
method: 'DELETE',
body: { remove_app_data: appData.removeAppData },
});
dispatch({
type: AppActionTypes.DELETE_APP,
payload: {},
});
showToast('App disabled', ToastType.Success);
dispatch(fetchApps());
} catch (err: any) {
dispatch(setAppLoading(false));
showToast(`${err}`, ToastType.Error);
throw err;
}
dispatch(setAppLoading(false));
};
export const clearCurrentApp = () => (dispatch: Dispatch<any>) => {
dispatch({
type: AppActionTypes.CLEAR_APP,
payload: {},
});
};

View File

@ -0,0 +1,4 @@
export * from './actions';
export { default as reducer } from './reducers';
export { getApps } from './selectors';
export * from './types';

View File

@ -0,0 +1,45 @@
import { AppActionTypes } from './actions';
const initialAppsState: any = {
apps: [],
app: {},
appModalLoading: false,
appsLoading: false,
};
const appsReducer = (state: any = initialAppsState, action: any) => {
switch (action.type) {
case AppActionTypes.FETCH_APPS:
return {
...state,
apps: action.payload,
};
case AppActionTypes.SET_APP_LOADING:
return {
...state,
appLoading: action.payload,
};
case AppActionTypes.SET_APPS_LOADING:
return {
...state,
appsLoading: action.payload,
};
case AppActionTypes.FETCH_APP:
case AppActionTypes.UPDATE_APP:
case AppActionTypes.INSTALL_APP:
return {
...state,
currentApp: action.payload,
};
case AppActionTypes.CLEAR_APP:
case AppActionTypes.DELETE_APP:
return {
...state,
currentApp: {},
};
default:
return state;
}
};
export default appsReducer;

View File

@ -0,0 +1,6 @@
import { State } from 'src/redux';
export const getApps = (state: State) => state.apps.apps;
export const getCurrentApp = (state: State) => state.apps.currentApp;
export const getAppLoading = (state: State) => state.apps.appLoading;
export const getAppsLoading = (state: State) => state.apps.appsLoading;

View File

@ -0,0 +1,14 @@
import { ApiStatus } from 'src/services/api/redux';
import { App } from '../types';
export interface CurrentUserState extends App {
_status: ApiStatus;
}
export interface AppsState {
currentApp: CurrentUserState;
apps: App[];
appLoading: boolean;
appsLoading: boolean;
}

View File

@ -0,0 +1,34 @@
import { App, AppStatus, AppStatusEnum } from './types';
const transformAppStatus = (status: AppStatus) => {
if (status.installed && status.ready) return AppStatusEnum.Installed;
if (status.installed && !status.ready) return AppStatusEnum.Installing;
return AppStatusEnum.NotInstalled;
};
export const transformApp = (response: any): App => {
return {
id: response.id ?? '',
name: response.name ?? '',
slug: response.slug ?? '',
status: transformAppStatus(response.status),
url: response.url,
automaticUpdates: response.automatic_updates,
assetSrc: `/assets/${response.slug}.svg`,
markdownSrc: `/markdown/${response.slug}.md`,
};
};
export const transformAppRequest = (data: App) => {
return {
automatic_updates: data.automaticUpdates,
configuration: data.configuration,
};
};
export const transformInstallAppRequest = (data: App) => {
return {
url: data.url,
configuration: data.configuration,
};
};

View File

@ -0,0 +1,29 @@
export interface App {
id: number;
name: string;
slug: string;
status?: AppStatusEnum;
url: string;
automaticUpdates: boolean;
configuration?: string;
assetSrc?: string;
markdownSrc?: string;
}
export interface DisableAppForm {
slug: string;
removeAppData: boolean;
}
export interface AppStatus {
installed: boolean;
ready: boolean;
message: string;
}
export enum AppStatusEnum {
NotInstalled = 'Not installed',
Installed = 'Installed',
Installing = 'Installing',
External = 'External',
}

View File

@ -1862,6 +1862,11 @@
jest-diff "^26.0.0"
pretty-format "^26.0.0"
"@types/js-yaml@^4.0.5":
version "4.0.5"
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138"
integrity sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==
"@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8":
version "7.0.9"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz"