瀏覽代碼

v0.2.0-unstable4

Yann Stepienik 2 年之前
父節點
當前提交
9aa2bc48ea

+ 6 - 1
client/src/api/config.ts

@@ -3,7 +3,7 @@ interface Route {
   Name: string;
 }
 
-type Operation = 'replace' | 'move_up' | 'move_down' | 'delete';
+type Operation = 'replace' | 'move_up' | 'move_down' | 'delete' | 'add';
 
 function get() {
   return wrap(fetch('/cosmos/api/config', {
@@ -68,6 +68,10 @@ async function moveRouteDown(routeName: string): Promise<void> {
 async function deleteRoute(routeName: string): Promise<void> {
   return rawUpdateRoute(routeName, 'delete');
 }
+async function addRoute(newRoute: Route): Promise<void> {
+  return rawUpdateRoute("", 'add', newRoute);
+}
+
 export {
   get,
   set,
@@ -77,4 +81,5 @@ export {
   moveRouteUp,
   moveRouteDown,
   deleteRoute,
+  addRoute,
 };

+ 1 - 1
client/src/components/hostChip.jsx

@@ -35,7 +35,7 @@ const HostChip = ({route, settings}) => {
         window.open(window.location.origin + route.PathPrefix, '_blank');
     }}
     onDelete={settings ? () => {
-      window.open('/ui/config-url#'+route.Name, '_blank');
+      window.open('/ui/config-url/'+route.Name, '_blank');
     } : null}
     deleteIcon={settings ? <SettingOutlined /> : null}
   />

+ 10 - 1
client/src/components/routeComponents.jsx

@@ -1,4 +1,4 @@
-import { CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, UpOutlined } from "@ant-design/icons";
+import { CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, SafetyOutlined, UpOutlined } from "@ant-design/icons";
 import { Card, Chip, Stack, Tooltip } from "@mui/material";
 import { useState } from "react";
 import { useTheme } from '@mui/material/styles';
@@ -61,6 +61,15 @@ export const RouteMode = ({route}) => {
 
 export const RouteSecurity = ({route}) => {
   return <div style={{fontWeight: 'bold', fontSize: '110%'}}>
+    <Tooltip title={route.SmartShield && route.SmartShield.Enabled ? "Smart Shield is enabled" : "Smart Shield is disabled"}>
+      <div style={{display: 'inline-block'}}>
+        {route.SmartShield && route.SmartShield.Enabled ? 
+          <SafetyOutlined style={{color: 'green'}} /> :
+          <SafetyOutlined style={{color: 'red'}} />
+        }
+      </div>
+    </Tooltip>
+    &nbsp;
     <Tooltip title={route.AuthEnabled ? "Authentication is enabled" : "Authentication is disabled"}>
       <div style={{display: 'inline-block'}}>
         {route.AuthEnabled ? 

+ 18 - 12
client/src/components/tableView/prettyTableView.jsx

@@ -6,7 +6,7 @@ import TableContainer from '@mui/material/TableContainer';
 import TableHead from '@mui/material/TableHead';
 import TableRow from '@mui/material/TableRow';
 import Paper from '@mui/material/Paper';
-import { Input, InputAdornment, Stack, TextField } from '@mui/material';
+import { Input, InputAdornment, Stack, TextField, useMediaQuery } from '@mui/material';
 import { SearchOutlined } from '@ant-design/icons';
 import { useTheme } from '@mui/material/styles';
 import { Link } from 'react-router-dom';
@@ -15,6 +15,12 @@ const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo }) => {
   const [search, setSearch] = React.useState('');
   const theme = useTheme();
   const isDark = theme.palette.mode === 'dark';
+  const screenMin = {
+    xs: useMediaQuery((theme) => theme.breakpoints.up('xs')),
+    sm: useMediaQuery((theme) => theme.breakpoints.up('sm')),
+    md: useMediaQuery((theme) => theme.breakpoints.up('md')),
+    lg: useMediaQuery((theme) => theme.breakpoints.up('lg')),
+  }
 
   return (
     <Stack direction="column" spacing={2}>
@@ -34,11 +40,11 @@ const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo }) => {
       />
 
       <TableContainer style={{background: isDark ? '#252b32' : '', borderTop: '3px solid ' + theme.palette.primary.main}} component={Paper}>
-        <Table sx={{ minWidth: 650 }} aria-label="simple table">
+        <Table aria-label="simple table">
           <TableHead>
             <TableRow>
               {columns.map((column) => (
-                <TableCell>{column.title}</TableCell>
+                (!column.screenMin || screenMin[column.screenMin]) && <TableCell>{column.title}</TableCell>
               ))}
             </TableRow>
           </TableHead>
@@ -73,15 +79,15 @@ const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo }) => {
                 >
                   {columns.map((column) => (
                 
-                    <TableCell 
-                    component={(linkTo && !column.clickable) ? Link : 'td'}
-                    to={linkTo(row, key)}
-                    className={column.underline ? 'emphasis' : ''}
-                    sx={{
-                      textDecoration: 'none',
-                      ...column.style,
-                    }}>
-                      {column.field(row, key)}
+                    (!column.screenMin || screenMin[column.screenMin]) && <TableCell 
+                      component={(linkTo && !column.clickable) ? Link : 'td'}
+                      to={linkTo && linkTo(row, key)}
+                      className={column.underline ? 'emphasis' : ''}
+                      sx={{
+                        textDecoration: 'none',
+                        ...column.style,
+                      }}>
+                        {column.field(row, key)}
                     </TableCell>
                   ))}
                 </TableRow>

+ 7 - 2
client/src/pages/config/routeConfigPage.jsx

@@ -1,6 +1,6 @@
 import { useParams } from "react-router";
 import Back from "../../components/back";
-import { CircularProgress, Stack } from "@mui/material";
+import { Alert, CircularProgress, Stack } from "@mui/material";
 import PrettyTabbedView from "../../components/tabbedView/tabbedView";
 import RouteManagement from "./routes/routeman";
 import { useEffect, useState } from "react";
@@ -35,7 +35,11 @@ const RouteConfigPage = () => {
         <div>{routeName}</div>
       </Stack>
 
-      {config && <PrettyTabbedView tabs={[
+      {config && !currentRoute && <div>
+        <Alert severity="error">Route not found</Alert>  
+      </div>}
+
+      {config && currentRoute && <PrettyTabbedView tabs={[
         {
           title: 'Overview',
           children: <RouteOverview routeConfig={currentRoute} />
@@ -46,6 +50,7 @@ const RouteConfigPage = () => {
             title="Setup"
             submitButton
             routeConfig={currentRoute}
+            routeNames={config.HTTPConfig.ProxyConfig.Routes.map((r) => r.Name)}
           />
         },
         {

+ 94 - 0
client/src/pages/config/routes/newRoute.jsx

@@ -0,0 +1,94 @@
+// material-ui
+import { AppstoreAddOutlined, PlusCircleOutlined, ReloadOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
+import { Alert, Badge, Button, Card, Checkbox, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, Input, InputAdornment, TextField, Tooltip, Typography } from '@mui/material';
+import Grid2 from '@mui/material/Unstable_Grid2/Grid2';
+import { Stack } from '@mui/system';
+import { useEffect, useState } from 'react';
+import Paper from '@mui/material/Paper';
+import { styled } from '@mui/material/styles';
+
+import * as API from '../../../api';
+import IsLoggedIn from '../../../isLoggedIn';
+import RestartModal from '../../config/users/restart';
+import RouteManagement from '../../config/routes/routeman';
+import { ValidateRoute, getFaviconURL, sanitizeRoute } from '../../../utils/routes';
+import HostChip from '../../../components/hostChip';
+
+
+const NewRouteCreate = ({ openNewModal, setOpenNewModal, config }) => {
+  const [openRestartModal, setOpenRestartModal] = useState(false);
+  const [submitErrors, setSubmitErrors] = useState([]);
+  const [newRoute, setNewRoute] = useState(null);
+
+  function addRoute() {
+    return API.config.addRoute(newRoute).then((res) => {
+      setOpenNewModal(false);
+      setOpenRestartModal(true);
+    });
+  }
+
+  return <>
+  <RestartModal openModal={openRestartModal} setOpenModal={setOpenRestartModal} />
+  <Dialog open={openNewModal} onClose={() => setOpenNewModal(false)}>
+    <DialogTitle>New URL</DialogTitle>
+        {openNewModal && <>
+        <DialogContent>
+            <DialogContentText>
+              <Stack spacing={2}>
+                <div>
+                    <RouteManagement
+                      routeConfig={{
+                        Target: "",
+                        Mode: "SERVAPP",
+                        Name: "New Route",
+                        Description: "New Route",
+                        UseHost: false,
+                        Host: "",
+                        UsePathPrefix: false,
+                        PathPrefix: '',
+                        CORSOrigin: '',
+                        StripPathPrefix: false,
+                        AuthEnabled: false,
+                        Timeout: 14400000,
+                        ThrottlePerMinute: 10000,
+                        SmartShield: {
+                          Enabled: true,
+                        }
+                      }} 
+                      routeNames={config.HTTPConfig.ProxyConfig.Routes.map((r) => r.Name)}
+                      setRouteConfig={(_newRoute) => {
+                        setNewRoute(sanitizeRoute(_newRoute));
+                      }}
+                      up={() => {}}
+                      down={() => {}}
+                      deleteRoute={() => {}}
+                      noControls
+                    />
+                </div>
+              </Stack>
+            </DialogContentText>
+        </DialogContent>
+        <DialogActions>
+            {submitErrors && submitErrors.length > 0 && <Stack spacing={2} direction={"column"}>
+              <Alert severity="error">{submitErrors.map((err) => {
+                  return <div>{err}</div>
+                })}</Alert>
+            </Stack>}
+            <Button onClick={() => setOpenNewModal(false)}>Cancel</Button>
+            <Button onClick={() => {
+              let errors = ValidateRoute(newRoute, config);
+              if (errors && errors.length > 0) {
+                setSubmitErrors(errors);
+              } else {
+                setSubmitErrors([]);
+                addRoute();
+              }
+              
+            }}>Confirm</Button>
+        </DialogActions>
+    </>}
+  </Dialog>
+  </>;
+}
+
+export default NewRouteCreate;

+ 14 - 4
client/src/pages/config/routes/routeSecurity.jsx

@@ -23,20 +23,24 @@ const RouteSecurity = ({ routeConfig }) => {
       <Formik
         initialValues={{
           AuthEnabled: routeConfig.AuthEnabled,
-          Host: routeConfig.Host,
-          UsePathPrefix: routeConfig.UsePathPrefix,
-          PathPrefix: routeConfig.PathPrefix,
-          StripPathPrefix: routeConfig.StripPathPrefix,
           Timeout: routeConfig.Timeout,
           ThrottlePerMinute: routeConfig.ThrottlePerMinute,
           CORSOrigin: routeConfig.CORSOrigin,
           MaxBandwith: routeConfig.MaxBandwith,
+          _SmartShield_Enabled: (routeConfig.SmartShield ? routeConfig.SmartShield.Enabled : false),
         }}
         onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
           const fullValues = {
             ...routeConfig,
             ...values,
           }
+
+          if(!fullValues.SmartShield) {
+            fullValues.SmartShield = {};
+          }
+          fullValues.SmartShield.Enabled = values._SmartShield_Enabled;
+          delete fullValues._SmartShield_Enabled;
+
           API.config.replaceRoute(routeConfig.Name, fullValues).then((res) => {
             if (res.status == "OK") {
               setStatus({ success: true });
@@ -66,6 +70,12 @@ const RouteSecurity = ({ routeConfig }) => {
                       formik={formik}
                     />
 
+                    <CosmosCheckbox
+                      name="_SmartShield_Enabled"
+                      label="Smart Shield Protection"
+                      formik={formik}
+                    />
+
                     <CosmosInputText
                       name="Timeout"
                       label="Timeout in milliseconds (0 for no timeout, at least 30000 or less recommended)"

+ 56 - 33
client/src/pages/config/routes/routeman.jsx

@@ -14,32 +14,7 @@ import RestartModal from '../users/restart';
 import { CosmosCheckbox, CosmosFormDivider, CosmosInputText, CosmosSelect } from '../users/formShortcuts';
 import { CosmosContainerPicker } from '../users/containerPicker';
 import { snackit } from '../../../api/wrap';
-
-export const ValidateRoute = Yup.object().shape({
-  Name: Yup.string().required('Name is required'),
-  Mode: Yup.string().required('Mode is required'),
-  Target: Yup.string().required('Target is required').when('Mode', {
-    is: 'SERVAPP',
-    then: Yup.string().matches(/:[0-9]+$/, 'Invalid Target, must have a port'),
-  }),
-
-  Host: Yup.string().when('UseHost', {
-    is: true,
-    then: Yup.string().required('Host is required')
-      .matches(/[\.|\:]/, 'Host must be full domain ([sub.]domain.com) or an IP')
-  }),
-
-  PathPrefix: Yup.string().when('UsePathPrefix', {
-    is: true,
-    then: Yup.string().required('Path Prefix is required').matches(/^\//, 'Path Prefix must start with / (e.g. /api). Do not include a domain/subdomain in it, use the Host for this.')
-  }),
-
-  UseHost: Yup.boolean().when('UsePathPrefix',
-    {
-      is: false,
-      then: Yup.boolean().oneOf([true], 'Source must at least be either Host or Path Prefix')
-    }),
-})
+import { ValidateRouteSchema, sanitizeRoute } from '../../../utils/routes';
 
 const Hide = ({ children, h }) => {
   return h ? <div style={{ display: 'none' }}>
@@ -47,12 +22,21 @@ const Hide = ({ children, h }) => {
   </div> : <>{children}</>
 }
 
-const RouteManagement = ({ routeConfig, TargetContainer, noControls = false, lockTarget = false, title, setRouteConfig, submitButton = false }) => {
-  const [openModal, setOpenModal] = React.useState(false);
+const debounce = (func, wait) => {
+  let timeout;
+  return function (...args) {
+    const context = this;
+    clearTimeout(timeout);
+    timeout = setTimeout(() => func.apply(context, args), wait);
+  };
+};
 
+const RouteManagement = ({ routeConfig, routeNames, TargetContainer, noControls = false, lockTarget = false, title, setRouteConfig, submitButton = false, newRoute }) => {
+  const [openModal, setOpenModal] = React.useState(false);
+ 
   return <div style={{ maxWidth: '1000px', width: '100%', margin: '', position: 'relative' }}>
     <RestartModal openModal={openModal} setOpenModal={setOpenModal} />
-
+    
     {routeConfig && <>
       <Formik
         initialValues={{
@@ -62,17 +46,32 @@ const RouteManagement = ({ routeConfig, TargetContainer, noControls = false, loc
           Target: routeConfig.Target,
           UseHost: routeConfig.UseHost,
           Host: routeConfig.Host,
+          UsePathPrefix: routeConfig.UsePathPrefix,
+          PathPrefix: routeConfig.PathPrefix,
+          StripPathPrefix: routeConfig.StripPathPrefix,
+          AuthEnabled: routeConfig.AuthEnabled,
+          _SmartShield_Enabled: (routeConfig.SmartShield ? routeConfig.SmartShield.Enabled : false),
         }}
-        validationSchema={ValidateRoute}
+        validationSchema={ValidateRouteSchema}
         onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
           if(!submitButton) {
             return false;
           } else {
-            const fullValues = {
+            let fullValues = {
               ...routeConfig,
               ...values,
             }
-            API.config.replaceRoute(routeConfig.Name, fullValues).then((res) => {
+
+            fullValues = sanitizeRoute(fullValues);
+
+            let op;
+            if(newRoute) {
+              op = API.config.newRoute(routeConfig.Name, fullValues)
+            } else {
+              op = API.config.replaceRoute(routeConfig.Name, fullValues)
+            }
+            
+            op.then((res) => {
               if (res.status == "OK") {
                 setStatus({ success: true });
                 snackit('Route updated successfully', 'success')
@@ -87,7 +86,17 @@ const RouteManagement = ({ routeConfig, TargetContainer, noControls = false, loc
           }
         }}
         validate={(values) => {
-          setRouteConfig && setRouteConfig(values);
+          let fullValues = {
+            ...routeConfig,
+            ...values,
+          }
+
+          // check name is unique
+          if (newRoute && routeNames.includes(fullValues.Name)) {
+            return { Name: 'Name must be unique' }
+          }
+
+          setRouteConfig && debounce(() => setRouteConfig(fullValues), 500)();
         }}
       >
         {(formik) => (
@@ -198,6 +207,20 @@ const RouteManagement = ({ routeConfig, TargetContainer, noControls = false, loc
                     formik={formik}
                     style={{ paddingLeft: '20px' }}
                   />}
+                  
+                  <CosmosFormDivider title={'Basic Security'} />
+                  
+                  <CosmosCheckbox
+                    name="AuthEnabled"
+                    label="Authentication Required"
+                    formik={formik}
+                  />
+                  
+                  <CosmosCheckbox
+                    name="_SmartShield_Enabled"
+                    label="Smart Shield Protection"
+                    formik={formik}
+                  />
                 </Grid>
               </MainCard>
               {submitButton && <MainCard ><Button

+ 17 - 3
client/src/pages/config/routes/routeoverview.jsx

@@ -1,25 +1,39 @@
 import * as React from 'react';
 import MainCard from '../../../components/MainCard';
 import RestartModal from '../users/restart';
-import { Chip, Stack, useMediaQuery } from '@mui/material';
+import { Chip, Divider, Stack, useMediaQuery } from '@mui/material';
 import HostChip from '../../../components/hostChip';
 import { RouteMode, RouteSecurity } from '../../../components/routeComponents';
 import { getFaviconURL } from '../../../utils/routes';
+import * as API from '../../../api';
+import { CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, UpOutlined } from "@ant-design/icons";
 
 const RouteOverview = ({ routeConfig }) => {
   const [openModal, setOpenModal] = React.useState(false);
   const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'));
+  const [confirmDelete, setConfirmDelete] = React.useState(false);
+
+  function deleteRoute(event) {
+    event.stopPropagation();
+    API.config.deleteRoute(routeConfig.Name).then(() => {
+      setOpenModal(true);
+    });
+  }
 
   return <div style={{ maxWidth: '1000px', width: '100%'}}>
     <RestartModal openModal={openModal} setOpenModal={setOpenModal} />
 
     {routeConfig && <>
-      <MainCard name={routeConfig.Name} title={routeConfig.Name}>
+      <MainCard name={routeConfig.Name} title={<div>
+        {routeConfig.Name} &nbsp;&nbsp;
+        {!confirmDelete && (<Chip label={<DeleteOutlined />} onClick={() => setConfirmDelete(true)}/>)}
+        {confirmDelete && (<Chip label={<CheckOutlined />} color="error" onClick={(event) => deleteRoute(event)}/>)}
+      </div>}>
         <Stack spacing={2} direction={isMobile ? 'column' : 'row'} alignItems={isMobile ? 'center' : 'flex-start'}>
           <div>
             <img src={getFaviconURL(routeConfig)} width="128px" />
           </div>
-          <Stack spacing={2}>
+          <Stack spacing={2} >
             <strong>Description</strong>
             <div>{routeConfig.Description}</div>
             <strong>URL</strong>

+ 18 - 51
client/src/pages/config/users/proxyman.jsx

@@ -31,13 +31,14 @@ import {
 import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
 import AnimateButton from '../../../components/@extended/AnimateButton';
 import RestartModal from './restart';
-import RouteManagement, {ValidateRoute} from '../routes/routeman';
+import RouteManagement from '../routes/routeman';
 import { map } from 'lodash';
-import { getFaviconURL, sanitizeRoute } from '../../../utils/routes';
+import { getFaviconURL, sanitizeRoute, ValidateRoute } from '../../../utils/routes';
 import PrettyTableView from '../../../components/tableView/prettyTableView';
 import HostChip from '../../../components/hostChip';
 import {RouteActions, RouteMode, RouteSecurity} from '../../../components/routeComponents';
 import { useNavigate } from 'react-router';
+import NewRouteCreate from '../routes/newRoute';
 
 const stickyButton = {
   position: 'fixed',
@@ -62,7 +63,7 @@ const ProxyManagement = () => {
   const [error, setError] = React.useState(null);
   const [submitErrors, setSubmitErrors] = React.useState([]);
   const [needSave, setNeedSave] = React.useState(false);
-  const navigate = useNavigate();
+  const [openNewModal, setOpenNewModal] = React.useState(false);
   
   function updateRoutes(routes) {
     let con = {
@@ -129,43 +130,22 @@ const ProxyManagement = () => {
     refresh();
   }, []);
 
-  const testRoute = (route) => {
-    try {
-      ValidateRoute.validateSync(route);
-    } catch (e) {
-      return e.errors;
-    }
-  }
   let routes = config && (config.HTTPConfig.ProxyConfig.Routes || []);
 
   return <div style={{   }}>
     <IsLoggedIn />
-    <Button variant="contained" color="primary" startIcon={<SyncOutlined />} onClick={() => {
-        refresh();
-    }}>Refresh</Button>&nbsp;&nbsp;
-    <Button variant="contained" color="primary" startIcon={<PlusCircleOutlined />} onClick={() => {
-        routes.unshift({
-          Name: 'New URL',
-          Description: 'New URL',
-          Mode: "SERVAPP",
-          UseHost: false,
-          Host: '',
-          UsePathPrefix: false,
-          PathPrefix: '',
-          Timeout: 30000,
-          ThrottlePerMinute: 0,
-          CORSOrigin: '',
-          StripPathPrefix: false,
-          AuthEnabled: false,
-        });
-        updateRoutes(routes);
-        setNeedSave(true);
-    }}>Create</Button>
-    
-    <br /><br />
-    
+    <Stack direction="row" spacing={1} style={{ marginBottom: '20px' }}>
+      <Button variant="contained" color="primary" startIcon={<SyncOutlined />} onClick={() => {
+          refresh();
+      }}>Refresh</Button>&nbsp;&nbsp;
+      <Button variant="contained" color="primary" startIcon={<PlusCircleOutlined />} onClick={() => {
+        setOpenNewModal(true);
+      }}>Create</Button>
+    </Stack>
+
     {config && <>
       <RestartModal openModal={openModal} setOpenModal={setOpenModal} />
+      <NewRouteCreate openNewModal={openNewModal} setOpenNewModal={setOpenNewModal} config={config}/>
       
       {routes && <PrettyTableView 
         data={routes}
@@ -190,9 +170,9 @@ const ProxyManagement = () => {
               <div style={{display:'inline-block', textDecoration: 'inherit', fontSize: '90%', opacity: '90%'}}>{r.Description}</div>
             </>
           },
-          { title: 'Origin', clickable:true, search: (r) => r.Host + ' ' + r.PathPrefix, field: (r) => <HostChip route={r} /> },
-          { title: 'Target', search: (r) => r.Target, field: (r) => <><RouteMode route={r} /> <Chip label={r.Target} /></> },
-          { title: 'Security', field: (r) => <RouteSecurity route={r} />,
+          { title: 'Origin', screenMin: 'md', clickable:true, search: (r) => r.Host + ' ' + r.PathPrefix, field: (r) => <HostChip route={r} /> },
+          { title: 'Target', screenMin: 'md', search: (r) => r.Target, field: (r) => <><RouteMode route={r} /> <Chip label={r.Target} /></> },
+          { title: 'Security', screenMin: 'lg', field: (r) => <RouteSecurity route={r} />,
           style: {minWidth: '70px'} },
           { title: '', clickable:true, field: (r, k) =>  <RouteActions
               route={r}
@@ -213,19 +193,6 @@ const ProxyManagement = () => {
         </div>
       }
       
-      {/* {routes && routes.map((route,key) => (<>
-        <RouteManagement key={route.Name} routeConfig={route}
-          setRouteConfig={(newRoute) => {
-            routes[key] = sanitizeRoute(newRoute);
-            setNeedSave(true);
-          }}
-          up={() => up(key)}
-          down={() => down(key)}
-          deleteRoute={() => deleteRoute(key)}
-        />
-        <br /><br />
-      </>))} */}
-
       {routes && needSave && <>
         <div>
         <br /><br /><br /><br />
@@ -249,7 +216,7 @@ const ProxyManagement = () => {
                 fullWidth
                 onClick={() => {
                   if(routes.some((route, key) => {
-                    let errors = testRoute(route);
+                    let errors = ValidateRoute(route, config);
                     if (errors && errors.length > 0) {
                       errors = errors.map((err) => {
                         return `${route.Name}: ${err}`;

+ 1 - 1
client/src/pages/config/users/restart.jsx

@@ -64,7 +64,7 @@ const RestartModal = ({openModal, setOpenModal}) => {
                     }, 1500)
                     setTimeout(() => {
                         setWarn(true);
-                    }, 8000)
+                    }, 20000)
                 }}>Restart</Button>
             </DialogActions>}
         </Dialog>

+ 94 - 91
client/src/pages/config/users/usermanagement.jsx

@@ -22,6 +22,7 @@ import * as API from '../../../api';
 import MainCard from '../../../components/MainCard';
 import IsLoggedIn from '../../../isLoggedIn';
 import { useEffect, useState } from 'react';
+import PrettyTableView from '../../../components/tableView/prettyTableView';
 
 const UserManagement = () => {
     const [isLoading, setIsLoading] = useState(false);
@@ -32,7 +33,7 @@ const UserManagement = () => {
 
     const roles = ['Guest', 'User', 'Admin']
 
-    const [rows, setRows] = useState([]);
+    const [rows, setRows] = useState(null);
 
     function refresh() {
         setIsLoading(true);
@@ -143,97 +144,99 @@ const UserManagement = () => {
             </DialogActions>
         </Dialog>
 
-        <MainCard title="Users">
-            <Button variant="contained" color="primary" startIcon={<SyncOutlined />} onClick={() => {
+
+        <Button variant="contained" color="primary" startIcon={<SyncOutlined />} onClick={() => {
                 refresh();
-            }}>Refresh</Button>&nbsp;&nbsp;
-            <Button variant="contained" color="primary" startIcon={<PlusCircleOutlined />} onClick={() => {
-                setOpenCreateForm(true)
-            }}>Create</Button><br /><br />
-            {isLoading ? <center><br /><CircularProgress color="inherit" /></center>
-            : <TableContainer component={Paper}>
-                <Table aria-label="simple table">
-                    <TableHead>
-                        <TableRow>
-                            <TableCell>Nickname</TableCell>
-                            <TableCell>Status</TableCell>
-                            <TableCell>Created At</TableCell>
-                            <TableCell>Last Login</TableCell>
-                            <TableCell>Actions</TableCell>
-                        </TableRow>
-                    </TableHead>
-                    <TableBody>
-                        {rows.map((row) => {
-                            const isRegistered = new Date(row.registeredAt).getTime() > 0;
-                            const inviteExpired = new Date(row.registerKeyExp).getTime() < new Date().getTime();
-
-                            const hasLastLogin = new Date(row.lastLogin).getTime() > 0;
-
-                            return (
-                                <TableRow
-                                    key={row.nickname}
-                                    sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
-                                >
-                                    <TableCell component="th" scope="row">
-                                        &nbsp;&nbsp;<strong>{row.nickname}</strong>
-                                    </TableCell>
-                                    <TableCell>
-                                        {isRegistered ? (row.role > 1 ? <Chip
-                                            icon={<KeyOutlined />}
-                                            label="Admin"
-                                            variant="outlined"
-                                        /> : <Chip
-                                            icon={<UserOutlined />}
-                                            label="User"
-                                            variant="outlined"
-                                        />) : (
-                                            inviteExpired ? <Chip
-                                                icon={<ExclamationCircleOutlined  />}
-                                                label="Invite Expired"
-                                                color="error"
-                                            /> : <Chip
-                                                icon={<WarningOutlined />}
-                                                label="Invite Pending"
-                                                color="warning"
-                                            />
-                                        )}
-                                    </TableCell>
-                                    <TableCell>
-                                        {new Date(row.createdAt).toLocaleDateString()} -&nbsp;
-                                        {new Date(row.createdAt).toLocaleTimeString()}
-                                    </TableCell>
-                                    <TableCell>
-                                        {hasLastLogin ? <span>
-                                            {new Date(row.lastLogin).toLocaleDateString()} -&nbsp;
-                                            {new Date(row.lastLogin).toLocaleTimeString()}
-                                        </span> : '-'}
-                                    </TableCell>
-                                    <TableCell>
-                                        {isRegistered ?
-                                            (<Button variant="contained" color="primary" onClick={
-                                                () => {
-                                                    sendlink(row.nickname, 1);
-                                                }
-                                            }>Send password reset</Button>) :
-                                            (<Button variant="contained" className={inviteExpired ? 'shinyButton' : ''} onClick={
-                                                () => {
-                                                    sendlink(row.nickname, 2);
-                                                }
-                                            } color="primary">Re-Send Invite</Button>)
-                                        }
-                                        &nbsp;&nbsp;<Button variant="contained" color="error" onClick={
-                                            () => {
-                                                setToAction(row.nickname);
-                                                setOpenDeleteForm(true);
-                                            }
-                                        }>Delete</Button></TableCell>
-                                </TableRow>
-                            )
-                        })}
-                    </TableBody>
-                </Table>
-            </TableContainer>}
-        </MainCard>
+        }}>Refresh</Button>&nbsp;&nbsp;
+        <Button variant="contained" color="primary" startIcon={<PlusCircleOutlined />} onClick={() => {
+            setOpenCreateForm(true)
+        }}>Create</Button><br /><br />
+
+
+        {isLoading && <center><br /><CircularProgress /></center>}
+
+        {!isLoading && rows && (<PrettyTableView 
+            data={rows}
+            getKey={(r) => r.nickname}
+            columns={[
+                {
+                    title: 'User',
+                    // underline: true,
+                    field: (r) => <strong>{r.nickname}</strong>,
+                },
+                {
+                    title: 'Status',
+                    screenMin: 'sm',
+                    field: (r) => {
+                        const isRegistered = new Date(r.registeredAt).getTime() > 0;
+                        const inviteExpired = new Date(r.registerKeyExp).getTime() < new Date().getTime();
+
+                        return <>{isRegistered ? (r.role > 1 ? <Chip
+                                icon={<KeyOutlined />}
+                                label="Admin"
+                            /> : <Chip
+                                icon={<UserOutlined />}
+                                label="User"
+                            />) : (
+                                inviteExpired ? <Chip
+                                    icon={<ExclamationCircleOutlined  />}
+                                    label="Invite Expired"
+                                    color="error"
+                                /> : <Chip
+                                    icon={<WarningOutlined />}
+                                    label="Invite Pending"
+                                    color="warning"
+                                />
+                            )}</>
+                    }
+                },
+                {
+                    title: 'Email',
+                    screenMin: 'md', 
+                    field: (r) => r.email,
+                },
+                {
+                    title: 'Created At',
+                    screenMin: 'lg',
+                    field: (r) => new Date(r.createdAt).toLocaleString(),
+                },
+                {
+                    title: 'Last Login',
+                    screenMin: 'lg', 
+                    field: (r) => {
+                        const hasLastLogin = new Date(r.lastLogin).getTime() > 0;
+                        return <>{hasLastLogin ? new Date(r.lastLogin).toLocaleString() : 'Never'}</>
+                    },
+                },
+                {
+                    title: '',
+                    clickable: true,
+                    field: (r) => {
+                        const isRegistered = new Date(r.registeredAt).getTime() > 0;
+                        const inviteExpired = new Date(r.registerKeyExp).getTime() < new Date().getTime();
+
+                        return <>{isRegistered ?
+                            (<Button variant="contained" color="primary" onClick={
+                                () => {
+                                    sendlink(r.nickname, 1);
+                                }
+                            }>Send password reset</Button>) :
+                            (<Button variant="contained" className={inviteExpired ? 'shinyButton' : ''} onClick={
+                                () => {
+                                    sendlink(r.nickname, 2);
+                                }
+                            } color="primary">Re-Send Invite</Button>)
+                        }
+                        &nbsp;&nbsp;<Button variant="contained" color="error" onClick={
+                            () => {
+                                setToAction(r.nickname);
+                                setOpenDeleteForm(true);
+                            }
+                        }>Delete</Button></>
+                    }
+                },
+            ]}
+        />)}
     </>;
 };
 

+ 12 - 16
client/src/pages/servapps/servapps.jsx

@@ -10,8 +10,8 @@ import { styled } from '@mui/material/styles';
 import * as API from '../../api';
 import IsLoggedIn from '../../isLoggedIn';
 import RestartModal from '../config/users/restart';
-import RouteManagement, { ValidateRoute } from '../config/routes/routeman';
-import { getFaviconURL, sanitizeRoute } from '../../utils/routes';
+import RouteManagement from '../config/routes/routeman';
+import { ValidateRoute, getFaviconURL, sanitizeRoute } from '../../utils/routes';
 import HostChip from '../../components/hostChip';
 
 const Item = styled(Paper)(({ theme }) => ({
@@ -49,14 +49,6 @@ const ServeApps = () => {
     })
   }
 
-  const testRoute = (route) => {
-    try {
-      ValidateRoute.validateSync(route);
-    } catch (e) {
-      return e.errors;
-    }
-  }
-
   const refreshServeApps = () => {
     API.docker.list().then((res) => {
       setServeApps(res.data);
@@ -97,8 +89,8 @@ const ServeApps = () => {
         ProxyConfig: {
           ...config.HTTPConfig.ProxyConfig,
           Routes: [
-            ...config.HTTPConfig.ProxyConfig.Routes,
             newRoute,
+            ...config.HTTPConfig.ProxyConfig.Routes,
           ]
         },
       },
@@ -109,6 +101,7 @@ const ServeApps = () => {
       setOpenRestartModal(true);
     });
   }
+
   const gridAnim = {
     transition: 'all 0.2s ease',
     opacity: 1,
@@ -125,7 +118,6 @@ const ServeApps = () => {
 
   const getFirstRouteFavIcon = (app) => {
     let routes = getContainersRoutes(app.Names[0].replace('/', ''));
-    console.log(routes)
     if(routes.length > 0) {
       let url = getFaviconURL(routes[0]);
       return url;
@@ -160,12 +152,16 @@ const ServeApps = () => {
                             Host: getHostnameFromName(openModal.Names[0]),
                             UsePathPrefix: false,
                             PathPrefix: '',
-                            Timeout: 30000,
-                            ThrottlePerMinute: 0,
                             CORSOrigin: '',
                             StripPathPrefix: false,
                             AuthEnabled: false,
+                            Timeout: 14400000,
+                            ThrottlePerMinute: 10000,
+                            SmartShield: {
+                              Enabled: true,
+                            }
                           }} 
+                          routeNames={config.HTTPConfig.ProxyConfig.Routes.map((r) => r.Name)}
                           setRouteConfig={(_newRoute) => {
                             setNewRoute(sanitizeRoute(_newRoute));
                           }}
@@ -187,7 +183,7 @@ const ServeApps = () => {
                 </Stack>}
                 <Button onClick={() => setOpenModal(false)}>Cancel</Button>
                 <Button onClick={() => {
-                  let errors = testRoute(newRoute);
+                  let errors = ValidateRoute(newRoute, config);
                   if (errors && errors.length > 0) {
                     errors = errors.map((err) => {
                       return `${err}`;
@@ -305,7 +301,7 @@ const ServeApps = () => {
                 <Typography  variant="h6" color="text.secondary">
                   URLs
                 </Typography>
-                <Stack spacing={2} direction="row">
+                <Stack style={noOver} spacing={2} direction="row">
                   {getContainersRoutes(app.Names[0].replace('/', '')).map((route) => {
                     return <HostChip route={route} settings/>
                   })}

+ 56 - 1
client/src/utils/routes.jsx

@@ -1,12 +1,27 @@
 import Folder from '../assets/images/icons/folder(1).svg';
+import * as Yup from 'yup';
+
+export const sanitizeRoute = (_route) => {
+  let route = {..._route};
 
-export const sanitizeRoute = (route) => {
   if (!route.UseHost) {
     route.Host = "";
   }
   if (!route.UsePathPrefix) {
     route.PathPrefix = "";
   }
+  
+  route.Name = route.Name.trim();
+
+  if(!route.SmartShield) {
+    route.SmartShield = {};
+  }
+
+  if(typeof route._SmartShield_Enabled !== "undefined") {
+    route.SmartShield.Enabled = route._SmartShield_Enabled;
+    delete route._SmartShield_Enabled;
+  }
+
   return route;
 }
 
@@ -36,4 +51,44 @@ export const getFaviconURL = (route) => {
   } else {
     return addRemote(addProtocol(getOrigin(route)));
   }
+}
+
+export const ValidateRouteSchema = Yup.object().shape({
+  Name: Yup.string().required('Name is required'),
+  Mode: Yup.string().required('Mode is required'),
+  Target: Yup.string().required('Target is required').when('Mode', {
+    is: 'SERVAPP',
+    then: Yup.string().matches(/:[0-9]+$/, 'Invalid Target, must have a port'),
+  }),
+
+  Host: Yup.string().when('UseHost', {
+    is: true,
+    then: Yup.string().required('Host is required')
+      .matches(/[\.|\:]/, 'Host must be full domain ([sub.]domain.com) or an IP')
+  }),
+
+  PathPrefix: Yup.string().when('UsePathPrefix', {
+    is: true,
+    then: Yup.string().required('Path Prefix is required').matches(/^\//, 'Path Prefix must start with / (e.g. /api). Do not include a domain/subdomain in it, use the Host for this.')
+  }),
+
+  UseHost: Yup.boolean().when('UsePathPrefix',
+    {
+      is: false,
+      then: Yup.boolean().oneOf([true], 'Source must at least be either Host or Path Prefix')
+    }),
+})
+
+export const ValidateRoute = (routeConfig, config) => {
+  let routeNames= config.HTTPConfig.ProxyConfig.Routes.map((r) => r.Name);
+
+  try {
+    ValidateRouteSchema.validateSync(routeConfig);
+  } catch (e) {
+    return e.errors;
+  }
+  if (routeNames.includes(routeConfig.Name)) {
+    return ['Route Name already exists. Name must be unique.'];
+  }
+  return [];
 }

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "cosmos-server",
-  "version": "0.2.0-unstable3",
+  "version": "0.2.0-unstable4",
   "description": "",
   "main": "test-server.js",
   "bugs": {

+ 45 - 30
src/configapi/patch.go

@@ -36,42 +36,57 @@ func ConfigApiPatch(w http.ResponseWriter, req *http.Request) {
 	routes := config.HTTPConfig.ProxyConfig.Routes
 	routeIndex := -1
 
-	for i, route := range routes {
-		if route.Name == updateReq.RouteName {
-			routeIndex = i
-			break
+	if updateReq.Operation != "add" {
+		if updateReq.RouteName == "" {
+			utils.Error("SettingsUpdate: RouteName must be provided", nil)
+			utils.HTTPError(w, "RouteName must be provided", http.StatusBadRequest, "UR002")
+			return
+		}
+	
+		for i, route := range routes {
+			if route.Name == updateReq.RouteName {
+				routeIndex = i
+				break
+			}
+		}
+	
+		if routeIndex == -1 {
+			utils.Error("SettingsUpdate: Route not found: "+updateReq.RouteName, nil)
+			utils.HTTPError(w, "Route not found", http.StatusNotFound, "UR002")
+			return
 		}
-	}
-
-	if routeIndex == -1 {
-		utils.Error("SettingsUpdate: Route not found: "+updateReq.RouteName, nil)
-		utils.HTTPError(w, "Route not found", http.StatusNotFound, "UR002")
-		return
 	}
 
 	switch updateReq.Operation {
-	case "replace":
-		if updateReq.NewRoute == nil {
-			utils.Error("SettingsUpdate: NewRoute must be provided for replace operation", nil)
-			utils.HTTPError(w, "NewRoute must be provided for replace operation", http.StatusBadRequest, "UR003")
+		case "replace":
+			if updateReq.NewRoute == nil {
+				utils.Error("SettingsUpdate: NewRoute must be provided for replace operation", nil)
+				utils.HTTPError(w, "NewRoute must be provided for replace operation", http.StatusBadRequest, "UR003")
+				return
+			}
+			routes[routeIndex] = *updateReq.NewRoute
+		case "move_up":
+			if routeIndex > 0 {
+				routes[routeIndex-1], routes[routeIndex] = routes[routeIndex], routes[routeIndex-1]
+			}
+		case "move_down":
+			if routeIndex < len(routes)-1 {
+				routes[routeIndex+1], routes[routeIndex] = routes[routeIndex], routes[routeIndex+1]
+			}
+		case "delete":
+			routes = append(routes[:routeIndex], routes[routeIndex+1:]...)
+		case "add":
+			if updateReq.NewRoute == nil {
+				utils.Error("SettingsUpdate: NewRoute must be provided for add operation", nil)
+				utils.HTTPError(w, "NewRoute must be provided for add operation", http.StatusBadRequest, "UR003")
+				return
+			}
+			routes = append([]utils.ProxyRouteConfig{*updateReq.NewRoute}, routes...)
+		default:
+			utils.Error("SettingsUpdate: Unsupported operation: "+updateReq.Operation, nil)
+			utils.HTTPError(w, "Unsupported operation", http.StatusBadRequest, "UR004")
 			return
 		}
-		routes[routeIndex] = *updateReq.NewRoute
-	case "move_up":
-		if routeIndex > 0 {
-			routes[routeIndex-1], routes[routeIndex] = routes[routeIndex], routes[routeIndex-1]
-		}
-	case "move_down":
-		if routeIndex < len(routes)-1 {
-			routes[routeIndex+1], routes[routeIndex] = routes[routeIndex], routes[routeIndex+1]
-		}
-	case "delete":
-		routes = append(routes[:routeIndex], routes[routeIndex+1:]...)
-	default:
-		utils.Error("SettingsUpdate: Unsupported operation: "+updateReq.Operation, nil)
-		utils.HTTPError(w, "Unsupported operation", http.StatusBadRequest, "UR004")
-		return
-	}
 
 	config.HTTPConfig.ProxyConfig.Routes = routes
 	utils.SaveConfigTofile(config)