소스 검색

[release] v0.12.0-unstable41

Yann Stepienik 1 년 전
부모
커밋
aa963bb89f

+ 6 - 1
changelog.md

@@ -1,15 +1,20 @@
 ## Version 0.12.0
  - New Dashboard
  - New metrics gathering system
+ - New alerts system
+ - New notification center
+ - New events manager
  - Integrated a new docker-less mode of functioning for networking
  - Added Button to force reset HTTPS cert in settings
  - New color slider with reset buttons
- - Fixed blinking modals issues
+ - Added a notification when updating a container
  - Added lazyloading to URL and Servapp pages images
  - Added a dangerous IP detector that stops sending HTTP response to IPs that are abusing various shields features
  - Added a button in the servapp page to easily download the docker backup
  - Redirect static folder to host if possible
  - New Homescreen look
+ - Added option to disable routes without deleting them
+ - Fixed blinking modals issues
  - Improve display or icons [fixes #121]
  - Refactored Mongo connection code [fixes #111]
  - Forward simultaneously TCP and UDP [fixes #122]

+ 14 - 0
client/src/api/metrics.demo.jsx

@@ -6,6 +6,20 @@ function get() {
   });
 }
 
+function reset() {
+  return new Promise((resolve, reject) => {
+    resolve()
+  });
+}
+
+// function list() {
+//   return new Promise((resolve, reject) => {
+//     resolve()
+//   });
+// }
+
 export {
   get,
+  reset,
+  // list,
 };

+ 10 - 0
client/src/api/metrics.jsx

@@ -18,7 +18,17 @@ function reset() {
   }))
 }
 
+function list() {
+  return wrap(fetch('/cosmos/api/list-metrics', {
+    method: 'GET',
+    headers: {
+        'Content-Type': 'application/json'
+    },
+  }))
+}
+
 export {
   get,
   reset,
+  list,
 };

+ 20 - 0
client/src/api/users.jsx

@@ -110,6 +110,24 @@ function resetPassword(values) {
   }))
 }
 
+function getNotifs() {
+  return wrap(fetch('/cosmos/api/notifications', {
+    method: 'GET',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+  }))
+}
+
+function readNotifs(notifs) {
+  return wrap(fetch('/cosmos/api/notifications/read?ids=' + notifs.join(','), {
+    method: 'GET',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+  }))
+}
+
 export {
   list,
   create,
@@ -122,4 +140,6 @@ export {
   check2FA,
   reset2FA,
   resetPassword,
+  getNotifs,
+  readNotifs,
 };

BIN
client/src/assets/images/wallpaper2.jpg


+ 5 - 3
client/src/components/delete.jsx

@@ -3,11 +3,13 @@ import { Card, Chip, Stack, Tooltip } from "@mui/material";
 import { useState } from "react";
 import { useTheme } from '@mui/material/styles';
 
-export const DeleteButton = ({onDelete}) => {
+export const DeleteButton = ({onDelete, disabled}) => {
   const [confirmDelete, setConfirmDelete] = useState(false);
 
   return (<>
-    {!confirmDelete && (<Chip label={<DeleteOutlined />} onClick={() => setConfirmDelete(true)}/>)}
-    {confirmDelete && (<Chip label={<CheckOutlined />} color="error" onClick={(event) => onDelete(event)}/>)}
+    {!confirmDelete && (<Chip label={<DeleteOutlined />} 
+      onClick={() => !disabled && setConfirmDelete(true)}/>)}
+    {confirmDelete && (<Chip label={<CheckOutlined />} color="error" 
+      onClick={(event) => !disabled && onDelete(event)}/>)}
   </>);
 }

+ 3 - 3
client/src/components/tabbedView/tabbedView.jsx

@@ -37,7 +37,7 @@ const a11yProps = (index) => {
   };
 };
 
-const PrettyTabbedView = ({ tabs, isLoading, currentTab, setCurrentTab, fullwidth }) => {
+const PrettyTabbedView = ({ tabs, isLoading, currentTab, setCurrentTab }) => {
   const [value, setValue] = useState(0);
   const isMobile = useMediaQuery((theme) => theme.breakpoints.down('md'));
   
@@ -55,8 +55,8 @@ const PrettyTabbedView = ({ tabs, isLoading, currentTab, setCurrentTab, fullwidt
   };
 
   return (
-    <Box fullwidth={fullwidth} display="flex" height="100%" flexDirection={isMobile ? 'column' : 'row'}>
-      {(isMobile && !currentTab) ? (
+    <Box display="flex" height="100%" flexDirection={isMobile ? 'column' : 'row'}>
+      {(isMobile) ? (
         <Select value={value} onChange={handleSelectChange} sx={{ minWidth: 120, marginBottom: '15px' }}>
           {tabs.map((tab, index) => (
             <MenuItem key={index} value={index}>

+ 121 - 123
client/src/layout/MainLayout/Header/HeaderContent/Notification.jsx

@@ -1,4 +1,4 @@
-import { useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 
 // material-ui
 import { useTheme } from '@mui/material/styles';
@@ -19,12 +19,16 @@ import {
     Typography,
     useMediaQuery
 } from '@mui/material';
+import * as timeago from 'timeago.js';
 
 // project import
 import MainCard from '../../../../components/MainCard';
 import Transitions from '../../../../components/@extended/Transitions';
 // assets
-import { BellOutlined, CloseOutlined, GiftOutlined, MessageOutlined, SettingOutlined } from '@ant-design/icons';
+import { BellOutlined, CloseOutlined, ExclamationCircleOutlined, GiftOutlined, InfoCircleOutlined, MessageOutlined, SettingOutlined, WarningOutlined } from '@ant-design/icons';
+
+import * as API from '../../../../api';
+import { redirectToLocal } from '../../../../utils/indexs';
 
 // sx styles
 const avatarSX = {
@@ -48,10 +52,51 @@ const actionSX = {
 const Notification = () => {
     const theme = useTheme();
     const matchesXs = useMediaQuery(theme.breakpoints.down('md'));
+    const [notifications, setNotifications] = useState([]);
+    const [from, setFrom] = useState('');
+
+    const refreshNotifications = () => {
+        API.users.getNotifs(from).then((res) => {
+            setNotifications(() => res.data);
+        });
+    };
+
+    const setAsRead = () => {
+        let unread = [];
+        
+        let newN = notifications.map((notif) => {
+            if (!notif.Read) {
+                unread.push(notif.ID);
+            }
+            notif.Read = true;
+            return notif;
+        })
+
+        if (unread.length > 0) {
+            API.users.readNotifs(unread);
+        }
+
+        setNotifications(newN);
+    }
+
+    useEffect(() => {
+        refreshNotifications();
+
+        const interval = setInterval(() => {
+            refreshNotifications();
+        }, 10000);
+
+        return () => clearInterval(interval);
+    }, []);
 
     const anchorRef = useRef(null);
     const [open, setOpen] = useState(false);
+    
     const handleToggle = () => {
+        if (!open) {
+            setAsRead();
+        }
+
         setOpen((prevOpen) => !prevOpen);
     };
 
@@ -62,9 +107,44 @@ const Notification = () => {
         setOpen(false);
     };
 
+    const getNotifIcon = (notification) => {
+        switch (notification.Level) {
+            case 'warn':
+                return <Avatar
+                    sx={{
+                        color: 'warning.main',
+                        bgcolor: 'warning.lighter'
+                    }}
+                >
+                    <WarningOutlined />
+                </Avatar>
+            case 'error':
+                return <Avatar
+                    sx={{
+                        color: 'error.main',
+                        bgcolor: 'error.lighter'
+                    }}
+                >
+                    <ExclamationCircleOutlined />
+                </Avatar>
+            default:
+                
+            return <Avatar
+                sx={{
+                    color: 'info.main',
+                    bgcolor: 'info.lighter'
+                }}
+            >
+                <InfoCircleOutlined />
+            </Avatar>
+        }
+    };
+
     const iconBackColor = theme.palette.mode === 'dark' ? 'grey.700' : 'grey.100';
     const iconBackColorOpen = theme.palette.mode === 'dark' ? 'grey.800' : 'grey.200';
 
+    const nbUnread = notifications.filter((notif) => !notif.Read).length;
+
     return (
         <Box sx={{ flexShrink: 0, ml: 0.75 }}>
             <IconButton
@@ -77,7 +157,7 @@ const Notification = () => {
                 aria-haspopup="true"
                 onClick={handleToggle}
             >
-                <Badge badgeContent={4} color="primary">
+                <Badge badgeContent={nbUnread} color="error">
                     <BellOutlined />
                 </Badge>
             </IconButton>
@@ -127,6 +207,8 @@ const Notification = () => {
                                     <List
                                         component="nav"
                                         sx={{
+                                            maxHeight: 350,
+                                            overflow: 'auto',
                                             p: 0,
                                             '& .MuiListItemButton-root': {
                                                 py: 0.5,
@@ -135,127 +217,43 @@ const Notification = () => {
                                             }
                                         }}
                                     >
-                                        <ListItemButton>
-                                            <ListItemAvatar>
-                                                <Avatar
-                                                    sx={{
-                                                        color: 'success.main',
-                                                        bgcolor: 'success.lighter'
-                                                    }}
-                                                >
-                                                    <GiftOutlined />
-                                                </Avatar>
-                                            </ListItemAvatar>
-                                            <ListItemText
-                                                primary={
-                                                    <Typography variant="h6">
-                                                        It&apos;s{' '}
-                                                        <Typography component="span" variant="subtitle1">
-                                                            Cristina danny&apos;s
-                                                        </Typography>{' '}
-                                                        birthday today.
-                                                    </Typography>
-                                                }
-                                                secondary="2 min ago"
-                                            />
-                                            <ListItemSecondaryAction>
-                                                <Typography variant="caption" noWrap>
-                                                    3:00 AM
-                                                </Typography>
-                                            </ListItemSecondaryAction>
-                                        </ListItemButton>
-                                        <Divider />
-                                        <ListItemButton>
-                                            <ListItemAvatar>
-                                                <Avatar
-                                                    sx={{
-                                                        color: 'primary.main',
-                                                        bgcolor: 'primary.lighter'
-                                                    }}
-                                                >
-                                                    <MessageOutlined />
-                                                </Avatar>
-                                            </ListItemAvatar>
-                                            <ListItemText
-                                                primary={
-                                                    <Typography variant="h6">
-                                                        <Typography component="span" variant="subtitle1">
-                                                            Aida Burg
-                                                        </Typography>{' '}
-                                                        commented your post.
-                                                    </Typography>
-                                                }
-                                                secondary="5 August"
-                                            />
-                                            <ListItemSecondaryAction>
-                                                <Typography variant="caption" noWrap>
-                                                    6:00 PM
-                                                </Typography>
-                                            </ListItemSecondaryAction>
-                                        </ListItemButton>
-                                        <Divider />
-                                        <ListItemButton>
-                                            <ListItemAvatar>
-                                                <Avatar
-                                                    sx={{
-                                                        color: 'error.main',
-                                                        bgcolor: 'error.lighter'
-                                                    }}
-                                                >
-                                                    <SettingOutlined />
-                                                </Avatar>
-                                            </ListItemAvatar>
-                                            <ListItemText
-                                                primary={
-                                                    <Typography variant="h6">
-                                                        Your Profile is Complete &nbsp;
-                                                        <Typography component="span" variant="subtitle1">
-                                                            60%
-                                                        </Typography>{' '}
-                                                    </Typography>
-                                                }
-                                                secondary="7 hours ago"
-                                            />
-                                            <ListItemSecondaryAction>
-                                                <Typography variant="caption" noWrap>
-                                                    2:45 PM
-                                                </Typography>
-                                            </ListItemSecondaryAction>
-                                        </ListItemButton>
-                                        <Divider />
-                                        <ListItemButton>
-                                            <ListItemAvatar>
-                                                <Avatar
-                                                    sx={{
-                                                        color: 'primary.main',
-                                                        bgcolor: 'primary.lighter'
-                                                    }}
-                                                >
-                                                    C
-                                                </Avatar>
-                                            </ListItemAvatar>
-                                            <ListItemText
-                                                primary={
-                                                    <Typography variant="h6">
-                                                        <Typography component="span" variant="subtitle1">
-                                                            Cristina Danny
-                                                        </Typography>{' '}
-                                                        invited to join{' '}
-                                                        <Typography component="span" variant="subtitle1">
-                                                            Meeting.
+                                        {notifications && notifications.map(notification => (<>
+                                            <ListItemButton onClick={() => {
+                                                notification.Link && redirectToLocal(notification.Link);
+                                            }}
+                                            style={{
+                                                borderLeft: notification.Read ? 'none' : `4px solid ${notification.Level === 'warn' ? theme.palette.warning.main : notification.Level === 'error' ? theme.palette.error.main : theme.palette.info.main}`,
+                                                paddingLeft: notification.Read ? '14px' : '10px',
+                                            }}>
+                                            
+                                                <ListItemAvatar>
+                                                    {getNotifIcon(notification)}
+                                                </ListItemAvatar>
+                                                <ListItemText
+                                                    primary={<>
+                                                        <Typography variant={notification.Read ? 'body' : 'h6'} noWrap>
+                                                            {notification.Title}
                                                         </Typography>
+                                                        <div style={{ 
+                                                            overflow: 'hidden',
+                                                            maxHeight: '48px',
+                                                            borderLeft: '1px solid grey',
+                                                            paddingLeft: '8px',
+                                                            margin: '2px'
+                                                        }}>
+                                                            {notification.Message}
+                                                        </div></>
+                                                    }
+                                                />
+                                                <ListItemSecondaryAction>
+                                                    <Typography variant="caption" noWrap>
+                                                        {timeago.format(notification.Date)}
                                                     </Typography>
-                                                }
-                                                secondary="Daily scrum meeting time"
-                                            />
-                                            <ListItemSecondaryAction>
-                                                <Typography variant="caption" noWrap>
-                                                    9:10 PM
-                                                </Typography>
-                                            </ListItemSecondaryAction>
-                                        </ListItemButton>
-                                        <Divider />
-                                        <ListItemButton sx={{ textAlign: 'center', py: `${12}px !important` }}>
+                                                </ListItemSecondaryAction>
+                                            </ListItemButton>
+                                        <Divider /></>))}
+
+                                        {/* <ListItemButton sx={{ textAlign: 'center', py: `${12}px !important` }}>
                                             <ListItemText
                                                 primary={
                                                     <Typography variant="h6" color="primary">
@@ -263,7 +261,7 @@ const Notification = () => {
                                                     </Typography>
                                                 }
                                             />
-                                        </ListItemButton>
+                                        </ListItemButton> */}
                                     </List>
                                 </MainCard>
                             </ClickAwayListener>

+ 8 - 5
client/src/layout/MainLayout/Header/HeaderContent/index.jsx

@@ -1,5 +1,5 @@
 // material-ui
-import { Box, Chip, IconButton, Link, useMediaQuery } from '@mui/material';
+import { Box, Chip, IconButton, Link, Stack, useMediaQuery } from '@mui/material';
 import { GithubOutlined } from '@ant-design/icons';
 
 // project import
@@ -18,10 +18,13 @@ const HeaderContent = () => {
             {!matchesXs && <Search />}
             {matchesXs && <Box sx={{ width: '100%', ml: 1 }} />}
 
-            <Link href="/cosmos-ui/logout" underline="none">
-                <Chip label="Logout" />
-            </Link>
-            {/* <Notification /> */}
+            <Stack direction="row" spacing={2}>
+                <Notification />
+
+                <Link href="/cosmos-ui/logout" underline="none">
+                    <Chip label="Logout" />
+                </Link>
+            </Stack>
             {/* {!matchesXs && <Profile />}
             {matchesXs && <MobileSection />} */}
         </>

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

@@ -332,7 +332,7 @@ const ConfigManagement = () => {
                         }}
                       />
 
-<Button
+                      <Button
                         variant="outlined"
                         onClick={() => {
                           formik.setFieldValue('Background', "");

+ 14 - 1
client/src/pages/config/users/formShortcuts.jsx

@@ -27,6 +27,19 @@ import { strengthColor, strengthIndicator } from '../../../utils/password-streng
 
 import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
 
+export const getNestedValue = (values, path) => {
+  return path.split('.').reduce((current, key) => {
+    if (current && current[key] !== undefined) {
+      return current[key];
+    }
+    if (Array.isArray(current)) {
+      const index = parseInt(key, 10);
+      return current[index];
+    }
+    return undefined;
+  }, values);
+};
+
 export const CosmosInputText = ({ name, style, value, errors, multiline, type, placeholder, onChange, label, formik }) => {
   return <Grid item xs={12}>
     <Stack spacing={1} style={style}>
@@ -146,7 +159,7 @@ export const CosmosSelect = ({ name, onChange, label, formik, disabled, options
         id={name}
         disabled={disabled}
         select
-        value={formik.values[name]}
+        value={getNestedValue(formik.values, name)}
         onChange={(...ar) => {
           onChange && onChange(...ar);
           formik.handleChange(...ar);

+ 17 - 0
client/src/pages/config/users/proxyman.jsx

@@ -66,6 +66,15 @@ const ProxyManagement = () => {
   const [submitErrors, setSubmitErrors] = React.useState([]);
   const [needSave, setNeedSave] = React.useState(false);
   const [openNewModal, setOpenNewModal] = React.useState(false);
+  const [isLoading, setIsLoading] = React.useState(false);
+
+  function setRouteEnabled(key) {
+    return (event) => {
+      routes[key].Disabled = !event.target.checked;
+      updateRoutes(routes);
+      setNeedSave(true);
+    }
+  }
   
   function updateRoutes(routes) {
     let con = {
@@ -163,6 +172,14 @@ const ProxyManagement = () => {
               textAlign: 'center',
             },
           },
+          {
+            title: 'Enabled', 
+            clickable:true, 
+            field: (r, k) => <Checkbox disabled={isLoading} size='large' color={!r.Disabled ? 'success' : 'default'}
+              onChange={setRouteEnabled(k)}
+              checked={!r.Disabled}
+            />,
+          },
           { title: 'URL',
             search: (r) => r.Name + ' ' + r.Description,
             style: {

+ 507 - 0
client/src/pages/dashboard/AlertPage.jsx

@@ -0,0 +1,507 @@
+import * as React from 'react';
+import IsLoggedIn from '../../isLoggedIn';
+import * as API from '../../api';
+import MainCard from '../../components/MainCard';
+import { Formik, Field, useFormik, FormikProvider } from 'formik';
+import * as Yup from 'yup';
+import {
+  Alert,
+  Button,
+  Checkbox,
+  FormControlLabel,
+  Grid,
+  InputLabel,
+  OutlinedInput,
+  Stack,
+  FormHelperText,
+  TextField,
+  MenuItem,
+  Skeleton,
+  CircularProgress,
+  Dialog,
+  DialogTitle,
+  DialogContent,
+  DialogContentText,
+  DialogActions,
+  Box,
+} from '@mui/material';
+import { ExclamationCircleOutlined, InfoCircleOutlined, PlusCircleOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons';
+import PrettyTableView from '../../components/tableView/prettyTableView';
+import { DeleteButton } from '../../components/delete';
+import { CosmosCheckbox, CosmosFormDivider, CosmosInputText, CosmosSelect } from '../config/users/formShortcuts';
+import { MetricPicker } from './MetricsPicker';
+
+const DisplayOperator = (operator) => {
+  switch (operator) {
+    case 'gt':
+      return '>';
+    case 'lt':
+      return '<';
+    case 'eq':
+      return '=';
+    default:
+      return '?';
+  }
+}
+const AlertValidationSchema = Yup.object().shape({
+  name: Yup.string().required('Name is required'),
+  trackingMetric: Yup.string().required('Tracking metric is required'),
+  conditionOperator: Yup.string().required('Condition operator is required'),
+  conditionValue: Yup.number().required('Condition value is required'),
+  period: Yup.string().required('Period is required'),
+});
+
+const EditAlertModal = ({ open, onClose, onSave }) => {
+  const formik = useFormik({
+    initialValues: {
+      name: open.Name || 'New Alert',
+      trackingMetric: open.TrackingMetric || '',
+      conditionOperator: (open.Condition && open.Condition.Operator) || 'gt',
+      conditionValue: (open.Condition && open.Condition.Value) || 0,
+      conditionPercent: (open.Condition && open.Condition.Percent) || false,
+      period: open.Period || 'latest',
+      actions: open.Actions || [],
+      throttled: typeof open.Throttled === 'boolean' ? open.Throttled : true,
+      severity: open.Severity || 'error',
+    },
+    validationSchema: AlertValidationSchema,
+    onSubmit: (values) => {
+      values.actions = values.actions.filter((a) => !a.removed);
+      onSave(values);
+      onClose();
+    },
+  });
+
+  return (
+    <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
+      <DialogTitle>Edit Alert</DialogTitle>
+      <FormikProvider value={formik}>
+      <form onSubmit={formik.handleSubmit}>
+        <DialogContent>
+          <Stack spacing={2}>
+            <CosmosInputText
+              name="name"
+              label="Name of the alert"
+              formik={formik}
+              required
+            />
+            <MetricPicker
+              name="trackingMetric"
+              label="Metric to track"
+              formik={formik}
+              required
+            />
+            <Stack direction="row" spacing={2} alignItems="center">
+              <CosmosSelect
+                name="conditionOperator"
+                label="Trigger Condition Operator"
+                formik={formik}
+                options={[
+                  ['gt', '>'],
+                  ['lt', '<'],
+                  ['eq', '='],
+                ]}
+              >
+              </CosmosSelect>
+              <CosmosInputText
+                name="conditionValue"
+                label="Trigger Condition Value"
+                formik={formik}
+                required
+              />
+              <CosmosCheckbox
+                style={{paddingTop: '20px'}}
+                name="conditionPercent"
+                label="Condition is a percent of max value"
+                formik={formik}
+              />
+            </Stack>
+
+            <CosmosSelect
+              name="period"
+              label="Period (how often to check the metric)"
+              formik={formik}
+              options={[
+                ['latest', 'Latest'],
+                ['hourly', 'Hourly'],
+                ['daily', 'Daily'],
+            ]}></CosmosSelect>
+
+            <CosmosSelect
+              name="severity"
+              label="Severity"
+              formik={formik}
+              options={[
+                ['info', 'Info'],
+                ['warn', 'Warning'],
+                ['error', 'Error'],
+            ]}></CosmosSelect>
+
+            <CosmosCheckbox
+              name="throttled"
+              label="Throttle (only triggers a maximum of once a day)"
+              formik={formik}
+            />
+
+            <CosmosFormDivider title={'Action Triggers'} />
+            
+            <Stack direction="column" spacing={2}>
+              {formik.values.actions
+              .map((action, index) => {
+                return !action.removed && <>
+                  {action.Type === 'stop' && 
+                    <Alert severity="info">Stop action will attempt to stop/disable any resources (ex. Containers, routes, etc... ) attachted to the metric.
+                    This will only have an effect on metrics specific to a resources (ex. CPU of a specific container). It will not do anything on global metric such as global used CPU</Alert>
+                  }
+                  <Stack direction="row" spacing={2} key={index}>
+                    <Box style={{
+                      width: '100%',
+                    }}>
+                      <CosmosSelect
+                        name={`actions.${index}.Type`}
+                        label="Action Type"
+                        formik={formik}
+                        options={[
+                          ['notification', 'Send a notification'],
+                          ['email', 'Send an email'],
+                          ['stop', 'Stop resources causing the alert'],
+                        ]}
+                      />
+                    </Box>
+                    
+                    <Box  style={{
+                      height: '95px',
+                      display: 'flex',
+                      alignItems: 'center',
+                    }}>
+                      <DeleteButton
+                        onDelete={() => {
+                          formik.setFieldValue(`actions.${index}.removed`, true);
+                        }}
+                      />
+                    </Box>
+                  </Stack>
+                </>
+              })}
+
+              <Button
+                variant="outlined"
+                color="primary"
+                startIcon={<PlusCircleOutlined />}
+                onClick={() => {
+                  formik.setFieldValue('actions', [
+                    ...formik.values.actions,
+                    {
+                      Type: 'notification',
+                    },
+                  ]);
+                }}>
+                Add Action
+              </Button>
+            </Stack>
+
+          </Stack>
+        </DialogContent>
+        <DialogActions>
+          <Button onClick={onClose}>Cancel</Button>
+          <Button variant='contained' type="submit">Save</Button>
+        </DialogActions>
+      </form>
+      </FormikProvider>
+    </Dialog>
+  );
+};
+
+const AlertPage = () => {
+  const [config, setConfig] = React.useState(null);
+  const [isLoading, setIsLoading] = React.useState(false);
+  const [openModal, setOpenModal] = React.useState(false);
+  const [metrics, setMetrics] = React.useState({});
+
+  function refresh() {
+    API.config.get().then((res) => {
+      setConfig(res.data);
+      setIsLoading(false);
+    });
+    API.metrics.list().then((res) => {
+      setMetrics(res.data);
+    });
+  }
+
+  React.useEffect(() => {
+    refresh();
+  }, []);
+
+  const setEnabled = (name) => (event) => {
+    setIsLoading(true);
+    let toSave = {
+      ...config,
+      MonitoringAlerts: {
+        ...config.MonitoringAlerts,
+        [name]: {
+          ...config.MonitoringAlerts[name],
+          Enabled: event.target.checked,
+        }
+      }
+    };
+
+    API.config.set(toSave).then(() => {
+      refresh();
+    });
+  }
+
+  const deleteAlert = (name) => {
+    setIsLoading(true);
+    let toSave = {
+      ...config,
+      MonitoringAlerts: {
+        ...config.MonitoringAlerts,
+      }
+    };
+    delete toSave.MonitoringAlerts[name];
+
+    API.config.set(toSave).then(() => {
+      refresh();
+    });
+  }
+  
+  const saveAlert = (data) => {
+    setIsLoading(true);
+    
+    data.conditionValue = parseInt(data.conditionValue);
+
+    let toSave = {
+      ...config,
+      MonitoringAlerts: {
+        ...config.MonitoringAlerts,
+        [data.name]: {
+          Name: data.name,
+          Enabled: true,
+          TrackingMetric: data.trackingMetric,
+          Condition: {
+            Operator: data.conditionOperator,
+            Value: data.conditionValue,
+            Percent: data.conditionPercent,
+          },
+          Period: data.period,
+          Actions: data.actions,
+          LastTriggered: null,
+          Throttled: data.throttled,
+          Severity: data.severity,
+        }
+      }
+    };
+
+    API.config.set(toSave).then(() => {
+      refresh();
+    });
+  }
+  
+  const resetTodefault = () => {
+    setIsLoading(true);
+
+    let toSave = {
+      ...config,
+      MonitoringAlerts: {
+        "Anti Crypto-Miner": {
+          "Name": "Anti Crypto-Miner",
+          "Enabled": false,
+          "Period": "daily",
+          "TrackingMetric": "cosmos.system.docker.cpu.*",
+          "Condition": {
+            "Operator": "gt",
+            "Value": 80
+          },
+          "Actions": [
+            {
+              "Type": "notification",
+              "Target": ""
+            },
+            {
+              "Type": "email",
+              "Target": ""
+            },
+            {
+              "Type": "stop",
+              "Target": ""
+            }
+          ],
+          "LastTriggered": "0001-01-01T00:00:00Z",
+          "Throttled": false,
+          "Severity": "warn"
+        },
+        "Anti Memory Leak": {
+          "Name": "Anti Memory Leak",
+          "Enabled": false,
+          "Period": "daily",
+          "TrackingMetric": "cosmos.system.docker.ram.*",
+          "Condition": {
+            "Percent": true,
+            "Operator": "gt",
+            "Value": 80
+          },
+          "Actions": [
+            {
+              "Type": "notification",
+              "Target": ""
+            },
+            {
+              "Type": "email",
+              "Target": ""
+            },
+            {
+              "Type": "stop",
+              "Target": ""
+            }
+          ],
+          "LastTriggered": "0001-01-01T00:00:00Z",
+          "Throttled": false,
+          "Severity": "warn"
+        },
+        "Disk Full Notification": {
+          "Name": "Disk Full Notification",
+          "Enabled": true,
+          "Period": "latest",
+          "TrackingMetric": "cosmos.system.disk./",
+          "Condition": {
+            "Percent": true,
+            "Operator": "gt",
+            "Value": 95
+          },
+          "Actions": [
+            {
+              "Type": "notification",
+              "Target": ""
+            }
+          ],
+          "LastTriggered": "0001-01-01T00:00:00Z",
+          "Throttled": true,
+          "Severity": "warn"
+        }
+      }
+    };
+
+    API.config.set(toSave).then(() => {
+      refresh();
+    });
+  }
+
+  const GetSevIcon = ({level}) => {
+    switch (level) {
+      case 'info':
+        return <span style={{color: '#2196f3'}}><InfoCircleOutlined /></span>;
+      case 'warn':
+        return <span style={{color: '#ff9800'}}><WarningOutlined /></span>;
+      case 'error':
+        return <span style={{color: '#f44336'}}><ExclamationCircleOutlined /></span>;
+      default:
+        return '';
+    }
+  }
+
+  return <div style={{maxWidth: '1200px', margin: ''}}>
+    <IsLoggedIn />
+
+    {openModal && <EditAlertModal open={openModal} onClose={() => setOpenModal(false)} onSave={saveAlert} />}
+
+    <Stack direction="row" spacing={2} style={{marginBottom: '15px'}}>
+      <Button variant="contained" color="primary" startIcon={<SyncOutlined />} onClick={() => {
+          refresh();
+      }}>Refresh</Button>
+      <Button variant="contained" color="primary" startIcon={<PlusCircleOutlined />} onClick={() => {
+          setOpenModal(true);
+      }}>Create</Button>
+      <Button variant="outlined" color="warning" startIcon={<WarningOutlined />} onClick={() => {
+        resetTodefault();
+      }}>Reset to default</Button>
+    </Stack>
+    
+    {config && <>
+      <Formik
+        initialValues={{
+          Actions: config.MonitoringAlerts
+        }}
+
+        // validationSchema={Yup.object().shape({
+        // })}
+
+        onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {
+          setSubmitting(true);
+        
+          let toSave = {
+            ...config,
+            MonitoringAlerts: values.Actions
+          };
+
+          return API.config.set(toSave);
+        }}
+      >
+        {(formik) => (
+          <form noValidate onSubmit={formik.handleSubmit}>
+            <Stack spacing={3}>
+            {!config && <Skeleton variant="rectangular" height={300} />}
+            {config && (!config.MonitoringAlerts || !Object.values(config.MonitoringAlerts).length) ? <Alert severity="info">No alerts configured.</Alert> : ''}
+            {config && config.MonitoringAlerts && Object.values(config.MonitoringAlerts).length ? <PrettyTableView 
+              data={Object.values(config.MonitoringAlerts)}
+              getKey={(r) => r.Name + r.Target + r.Mode}
+              onRowClick={(r, k) => {
+                setOpenModal(r);
+              }}
+
+              columns={[
+                { 
+                  title: 'Enabled', 
+                  clickable:true, 
+                  field: (r, k) => <Checkbox disabled={isLoading} size='large' color={r.Enabled ? 'success' : 'default'}
+                    onChange={setEnabled(Object.keys(config.MonitoringAlerts)[k])}
+                    checked={r.Enabled}
+                  />,
+                  style: {
+                  },
+                },
+                { 
+                  title: 'Name', 
+                  field: (r) => <><GetSevIcon level={r.Severity} /> {r.Name}</>,
+                },
+                { 
+                  title: 'Tracking Metric', 
+                  field: (r) => metrics[r.TrackingMetric] ? metrics[r.TrackingMetric] : r.TrackingMetric,
+                },
+                { 
+                  title: 'Condition', 
+                  screenMin: 'md',
+                  field: (r) => DisplayOperator(r.Condition.Operator) + ' ' + r.Condition.Value + (r.Condition.Percent ? '%' : ''),
+                },
+                { 
+                  title: 'Period',
+                  field: (r) => r.Period,
+                },
+                { 
+                  title: 'Last Triggered',
+                  screenMin: 'md',
+                  field: (r) => (r.LastTriggered != "0001-01-01T00:00:00Z") ? new Date(r.LastTriggered).toLocaleString() : 'Never',
+                },
+                { 
+                  title: 'Actions', 
+                  field: (r) => r.Actions.map((a) => a.Type).join(', '),
+                  screenMin: 'md',
+                },
+                { title: '', clickable:true, field: (r, k) =>  <DeleteButton disabled={isLoading} onDelete={() => {
+                  deleteAlert(Object.keys(config.MonitoringAlerts)[k])
+                }}/>,
+                  style: {
+                    textAlign: 'right',
+                  }
+                },
+              ]}
+            /> : ''}
+            </Stack>
+          </form>
+        )}
+      </Formik>
+    </>}
+
+  </div>;
+}
+
+export default AlertPage;

+ 121 - 0
client/src/pages/dashboard/MetricsPicker.jsx

@@ -0,0 +1,121 @@
+import * as React from 'react';
+import {
+  Checkbox,
+  Divider,
+  FormControlLabel,
+  Grid,
+  InputLabel,
+  OutlinedInput,
+  Stack,
+  Typography,
+  FormHelperText,
+  TextField,
+  MenuItem,
+  AccordionSummary,
+  AccordionDetails,
+  Accordion,
+  Chip,
+  Box,
+  FormControl,
+  IconButton,
+  InputAdornment,
+  Autocomplete,
+
+} from '@mui/material';
+import { Field } from 'formik';
+import { DownOutlined, UpOutlined } from '@ant-design/icons';
+import * as API from '../../api';
+
+import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
+
+export const MetricPicker = ({ metricsInit, name, style, value, errors, placeholder, onChange, label, formik }) => {
+  const [metrics, setMetrics] = React.useState(metricsInit || {});
+
+  function refresh() {
+    API.metrics.list().then((res) => {
+      let m = [];
+      let wildcards = {};
+      
+      Object.keys(res.data).forEach((key) => {
+        m.push({
+          label: res.data[key],
+          value: key,
+        });
+
+        let keysplit = key.split('.');
+        if (keysplit.length > 1) {
+          for (let i = 0; i < keysplit.length - 1; i++) {
+            let wildcard = keysplit.slice(0, i + 1).join('.') + '.*';
+            wildcards[wildcard] = true;
+          }
+        }
+      });
+
+      Object.keys(wildcards).forEach((key) => {
+        m.push({
+          label: "Wildcard for " + key.split('.*')[0],
+          value: key,
+        });
+      });
+
+      setMetrics(m);
+    });
+  }
+
+  React.useEffect(() => {
+    if (!metricsInit)
+      refresh();
+  }, []);
+  
+  return <Grid item xs={12}>
+    <Stack spacing={1} style={style}>
+      {label && <InputLabel htmlFor={name}>{label}</InputLabel>}
+      {/* <OutlinedInput
+        id={name}
+        type={'text'}
+        value={value || (formik && formik.values[name])}
+        name={name}
+        onBlur={(...ar) => {
+          return formik && formik.handleBlur(...ar);
+        }}
+        onChange={(...ar) => {
+          onChange && onChange(...ar);
+          return formik && formik.handleChange(...ar);
+        }}
+        placeholder={placeholder}
+        fullWidth
+        error={Boolean(formik && formik.touched[name] && formik.errors[name])}
+      /> */}
+
+      <Autocomplete
+        disablePortal
+        name={name}
+        value={value || (formik && formik.values[name])}
+        id="combo-box-demo"
+        isOptionEqualToValue={(option, value) => option.value === value}
+        options={metrics}
+        freeSolo
+        getOptionLabel={(option) => {
+          return option.label ?
+            `${option.value} - ${option.label}` : (formik && formik.values[name]);
+        }}
+        onChange={(event, newValue) => {
+          onChange && onChange(newValue.value);
+          return formik && formik.setFieldValue(name, newValue.value);
+        }}
+        renderInput={(params) => <TextField {...params} />}
+      />
+
+      {formik && formik.touched[name] && formik.errors[name] && (
+        <FormHelperText error id="standard-weight-helper-text-name-login">
+          {formik.errors[name]}
+        </FormHelperText>
+      )}
+      {errors && (
+        <FormHelperText error id="standard-weight-helper-text-name-login">
+          {formik.errors[name]}
+        </FormHelperText>
+      )}
+    </Stack>
+  </Grid>
+}

+ 30 - 17
client/src/pages/dashboard/index.jsx

@@ -48,6 +48,7 @@ import MiniPlotComponent from './components/mini-plot';
 import ResourceDashboard from './resourceDashboard';
 import PrettyTabbedView from '../../components/tabbedView/tabbedView';
 import ProxyDashboard from './proxyDashboard';
+import AlertPage from './AlertPage';
 
 // avatar style
 const avatarSX = {
@@ -108,22 +109,25 @@ const DashboardDefault = () => {
         let todo = [
             ["cosmos.system.*"],
             ["cosmos.proxy.*"],
+            [],
+            [],
         ]
 
         let t = typeof override === 'number' ? override : currentTabRef.current;
 
-        API.metrics.get(todo[t]).then((res) => {
-            setMetrics(prevMetrics => {
-                let finalMetrics = prevMetrics ? { ...prevMetrics } : {};
-                if(res.data) {
-                    res.data.forEach((metric) => {
-                        finalMetrics[metric.Key] = metric;
-                    });
-                    
-                    return finalMetrics;
-                }
+        if (t < 2)
+            API.metrics.get(todo[t]).then((res) => {
+                setMetrics(prevMetrics => {
+                    let finalMetrics = prevMetrics ? { ...prevMetrics } : {};
+                    if(res.data) {
+                        res.data.forEach((metric) => {
+                            finalMetrics[metric.Key] = metric;
+                        });
+                        
+                        return finalMetrics;
+                    }
+                });
             });
-        });
     };
 
     const refreshStatus = () => {
@@ -195,7 +199,7 @@ const DashboardDefault = () => {
             <Grid container rowSpacing={4.5} columnSpacing={2.75} >
                 <Grid item xs={12} sx={{ mb: -2.25 }}>
                     <Typography variant="h4">Server Monitoring</Typography>
-                    <Stack direction="row" alignItems="center" spacing={0} style={{marginTop: 10}}>
+                    {currentTab <= 2 && <Stack direction="row" alignItems="center" spacing={0} style={{marginTop: 10}}>
                         <Button
                             size="small"
                             onClick={() => {setSlot('latest'); resetZoom()}}
@@ -233,7 +237,8 @@ const DashboardDefault = () => {
                         >
                             Reset Zoom
                         </Button>}
-                    </Stack>
+                    </Stack>}
+                    {currentTab > 2 && <div style={{height: 41}}></div>}
                 </Grid>
 
 
@@ -248,12 +253,20 @@ const DashboardDefault = () => {
                         isLoading={!metrics}
                         tabs={[
                             {
-                            title: 'Resources',
-                            children: <ResourceDashboard xAxis={xAxis} zoom={zoom} setZoom={setZoom} slot={slot} metrics={metrics} />
+                                title: 'Resources',
+                                children: <ResourceDashboard xAxis={xAxis} zoom={zoom} setZoom={setZoom} slot={slot} metrics={metrics} />
+                            },
+                            {
+                                title: 'Proxy',
+                                children: <ProxyDashboard xAxis={xAxis} zoom={zoom} setZoom={setZoom} slot={slot} metrics={metrics} />
+                            },
+                            {
+                                title: 'Events',
+                                children: <AlertPage />
                             },
                             {
-                            title: 'Proxy',
-                            children: <ProxyDashboard xAxis={xAxis} zoom={zoom} setZoom={setZoom} slot={slot} metrics={metrics} />
+                                title: 'Alerts',
+                                children: <AlertPage />
                             },
                         ]}
                     />

+ 0 - 1
client/src/pages/dashboard/proxyDashboard.jsx

@@ -9,7 +9,6 @@ import TableComponent from './components/table';
 import { InfoCircleOutlined } from '@ant-design/icons';
 
 const ProxyDashboard = ({ xAxis, zoom, setZoom, slot, metrics }) => {
-  console.log(metrics)
   return (<>
 
     <Grid container rowSpacing={4.5} columnSpacing={2.75} >

+ 16 - 45
client/src/pages/home/index.jsx

@@ -78,11 +78,6 @@ export const TransparentHeader = () => {
         font-weight: bold;
     }
 
-    .MuiDrawer-paper {
-        backdrop-filter: blur(15px);
-        background: rgba(${backColor}, 1) !important;
-        border-right-color: rgba(${backColor},0.45) !important;
-    }
 `}
     </style>;
 }
@@ -184,12 +179,6 @@ const HomePage = () => {
     useEffect(() => {
         refreshConfig();
         refreshStatus();
-
-        // const interval = setInterval(() => {
-        //     refreshStatus();
-        // }, 5000);
-
-        // return () => clearInterval(interval);
     }, []);
 
     const primCol = theme.palette.primary.main.replace('rgb(', 'rgba(')
@@ -268,42 +257,24 @@ const HomePage = () => {
         },
         labels: []
     };
+    
+    let latestCPU, latestRAM, latestRAMRaw, maxRAM, maxRAMRaw = 0;
 
-    const bigNb = {
-        fontSize: '23px',
-        fontWeight: "bold",
-        textAlign: "center",
-        color: isDark ? "white" : "black",
-        textShadow: "0px 0px 5px #000",
-        lineHeight: "97px",
+    if(metrics) {
+    
+        if(metrics["cosmos.system.cpu.0"] && metrics["cosmos.system.cpu.0"].Values && metrics["cosmos.system.cpu.0"].Values.length > 0)
+            latestCPU = metrics["cosmos.system.cpu.0"].Values[metrics["cosmos.system.cpu.0"].Values.length - 1].Value;
+        
+        if(metrics["cosmos.system.ram"] && metrics["cosmos.system.ram"].Values && metrics["cosmos.system.ram"].Values.length > 0) {
+            let formatRAM = metrics && FormaterForMetric(metrics["cosmos.system.ram"], false);
+            latestRAMRaw = metrics["cosmos.system.ram"].Values[metrics["cosmos.system.ram"].Values.length - 1].Value;
+            latestRAM = formatRAM(metrics["cosmos.system.ram"].Values[metrics["cosmos.system.ram"].Values.length - 1].Value);
+            maxRAM = formatRAM(metrics["cosmos.system.ram"].Max);
+            maxRAMRaw = metrics["cosmos.system.ram"].Max;
+        }
     }
 
-    let latestCPU = metrics && metrics["cosmos.system.cpu.0"] &&  metrics["cosmos.system.cpu.0"].Values[metrics["cosmos.system.cpu.0"].Values.length - 1].Value;
-    
-    let formatRAM = metrics && FormaterForMetric(metrics["cosmos.system.ram"], false);
-    let latestRAMRaw = metrics && metrics["cosmos.system.ram"] && metrics["cosmos.system.ram"].Values[metrics["cosmos.system.ram"].Values.length - 1].Value;
-    let latestRAM = metrics && metrics["cosmos.system.ram"] && formatRAM(metrics["cosmos.system.ram"].Values[metrics["cosmos.system.ram"].Values.length - 1].Value);
-    let maxRAM = metrics && metrics["cosmos.system.ram"] && formatRAM(metrics["cosmos.system.ram"].Max);
-    let maxRAMRaw = metrics && metrics["cosmos.system.ram"] && metrics["cosmos.system.ram"].Max;
-
-    let now = new Date();
-    now = "day_" + formatDate(now);
-
-    let formatNetwork = metrics && FormaterForMetric(metrics["cosmos.system.netTx"], false);
-    let latestNetworkRawTx = (metrics && metrics["cosmos.system.netTx"] && metrics["cosmos.system.netTx"].ValuesAggl[now].Value) || 0;
-    let latestNetworkTx = metrics && formatNetwork(latestNetworkRawTx);
-    let latestNetworkRawRx = (metrics && metrics["cosmos.system.netRx"] && metrics["cosmos.system.netRx"].ValuesAggl[now].Value) || 0;
-    let latestNetworkRx = metrics && formatNetwork(latestNetworkRawRx);
-    let latestNetworkSum = metrics && formatNetwork(latestNetworkRawTx + latestNetworkRawRx);
-
-    let formatRequests = metrics && FormaterForMetric(metrics["cosmos.proxy.all.success"], false);
-    let latestRequestsRaw = (metrics && metrics["cosmos.proxy.all.success"] && metrics["cosmos.proxy.all.success"].ValuesAggl[now].Value) || 0;
-    let latestRequests = metrics && formatRequests(latestRequestsRaw);
-    let latestRequestsErrorRaw = (metrics && metrics["cosmos.proxy.all.error"] && metrics["cosmos.proxy.all.error"].ValuesAggl[now].Value) || 0;
-    let latestRequestsError = metrics && formatRequests(latestRequestsErrorRaw);
-    let latestRequestSum = metrics && formatRequests(latestRequestsRaw + latestRequestsErrorRaw);
-
-    return <Stack spacing={2} style={{maxWidth: '1500px', margin:'auto'}}>
+    return <Stack spacing={2} style={{maxWidth: '1450px', margin:'auto'}}>
         <IsLoggedIn />
         <HomeBackground status={coStatus} />
         <TransparentHeader />
@@ -474,7 +445,7 @@ const HomePage = () => {
                     <Grid2 item xs={12} sm={6} md={6} lg={3} xl={3} xxl={3} key={'001'}>
                         <Box className='app' style={{height: '106px',borderRadius: 5, ...appColor }}>
                         <Stack direction="row" justifyContent={'center'} alignItems={'center'} style={{ height: "100%" }}>
-                            <MiniPlotComponent noBackground title='PROXY' agglo metrics={[
+                            <MiniPlotComponent noBackground title='URLS' agglo metrics={[
                                 "cosmos.proxy.all.success",
                                 "cosmos.proxy.all.error",
                             ]} labels={{

+ 2 - 2
client/src/themes/theme/index.jsx

@@ -1,6 +1,6 @@
 // ==============================|| PRESET THEME - THEME SELECTOR ||============================== //
 
-import { purple, pink, deepPurple, blueGrey, lightBlue, lightGreen, orange, teal } from '@mui/material/colors';
+import { purple, pink, deepPurple, blueGrey, lightBlue, lightGreen, orange, teal, indigo } from '@mui/material/colors';
 
 const Theme = (colors, darkMode) => {
     const { blue, red, gold, cyan, green, grey } = colors;
@@ -30,7 +30,7 @@ const Theme = (colors, darkMode) => {
             main: purple[400],
         },
         secondary: {
-            main: blueGrey[500],
+            main: indigo[700],
         },
         error: {
             lighter: red[0],

+ 8 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cosmos-server",
-  "version": "0.12.0-unstable24",
+  "version": "0.12.0-unstable40",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cosmos-server",
-      "version": "0.12.0-unstable24",
+      "version": "0.12.0-unstable40",
       "dependencies": {
         "@ant-design/colors": "^6.0.0",
         "@ant-design/icons": "^4.7.0",
@@ -56,6 +56,7 @@
         "semver-compare": "^1.0.0",
         "simplebar": "^5.3.8",
         "simplebar-react": "^2.4.1",
+        "timeago.js": "^4.0.2",
         "typescript": "4.8.3",
         "vite": "^4.2.0",
         "web-vitals": "^3.0.2",
@@ -10155,6 +10156,11 @@
       "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
       "dev": true
     },
+    "node_modules/timeago.js": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/timeago.js/-/timeago.js-4.0.2.tgz",
+      "integrity": "sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w=="
+    },
     "node_modules/tiny-warning": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "cosmos-server",
-  "version": "0.12.0-unstable40",
+  "version": "0.12.0-unstable41",
   "description": "",
   "main": "test-server.js",
   "bugs": {
@@ -56,6 +56,7 @@
     "semver-compare": "^1.0.0",
     "simplebar": "^5.3.8",
     "simplebar-react": "^2.4.1",
+    "timeago.js": "^4.0.2",
     "typescript": "4.8.3",
     "vite": "^4.2.0",
     "web-vitals": "^3.0.2",

+ 1 - 0
readme.md

@@ -8,6 +8,7 @@
 <a href="https://github.com/soldier1"><img src="https://avatars.githubusercontent.com/soldier1" style="border-radius:48px" width="48" height="48" alt="null" title="null" /></a>
 <a href="https://github.com/devcircus"><img src="https://avatars.githubusercontent.com/devcircus" style="border-radius:48px" width="48" height="48" alt="Clayton Stone" title="Clayton Stone" /></a>
 <a href="https://github.com/BlackrazorNZ"><img src="https://avatars.githubusercontent.com/BlackrazorNZ" style="border-radius:48px" width="48" height="48" alt="null" title="null" /></a>
+<a href="https://github.com/owengraven"><img src="https://avatars.githubusercontent.com/owengraven" style="border-radius:48px" width="48" height="48" alt="Owen" title="Owen" /></a>
 <a href="https://github.com/saltyautomation"><img src="https://avatars.githubusercontent.com/saltyautomation" style="border-radius:48px" width="48" height="48" alt="T Morton" title="T Morton" /></a>
 </p><!-- /sponsors -->
 

+ 1 - 0
src/CRON.go

@@ -122,6 +122,7 @@ func CRON() {
 		s.Every(1).Day().At("00:00").Do(checkVersion)
 		s.Every(1).Day().At("01:00").Do(checkCerts)
 		s.Every(6).Hours().Do(checkUpdatesAvailable)
+		s.Every(1).Hours().Do(utils.CleanBannedIPs)
 		s.Start()
 	}()
 }

+ 23 - 7
src/docker/docker.go

@@ -537,10 +537,18 @@ func CheckUpdatesAvailable() map[string]bool {
 		}
 
 		if needsUpdate && HasAutoUpdateOn(fullContainer) {
-			utils.Log("Downlaoded new update for " + container.Image + " ready to install")
+			utils.WriteNotification(utils.Notification{
+				Recipient: "admin",
+				Title: "Container Update",
+				Message: "Container " + container.Names[0][1:] + " updated to the latest version!",
+				Level: "info",
+				Link: "/cosmos-ui/servapps/containers/" + container.Names[0][1:],
+			})
+
+			utils.Log("Downloaded new update for " + container.Image + " ready to install")
 			_, err := RecreateContainer(container.Names[0], fullContainer)
 			if err != nil {
-				utils.Error("CheckUpdatesAvailable - Failed to update - ", err)
+				utils.MajorError("Container failed to update", err)
 			} else {
 				result[container.Names[0]] = false
 			}
@@ -675,8 +683,8 @@ type ContainerStats struct {
 }
 
 func Stats(container types.Container) (ContainerStats, error) {
-		utils.Debug("StatsAll - Getting stats for " + container.Names[0])
-		utils.Debug("Time: " + time.Now().String())
+		// utils.Debug("StatsAll - Getting stats for " + container.Names[0])
+		// utils.Debug("Time: " + time.Now().String())
 		
 		statsBody, err := DockerClient.ContainerStats(DockerContext, container.ID, false)
 		if err != nil {
@@ -698,7 +706,7 @@ func Stats(container types.Container) (ContainerStats, error) {
 
 		perCore := len(stats.CPUStats.CPUUsage.PercpuUsage)
 		if perCore == 0 {
-			utils.Warn("StatsAll - Docker CPU PercpuUsage is 0")
+			utils.Debug("StatsAll - Docker CPU PercpuUsage is 0")
 			perCore = 1
 		}
 
@@ -715,9 +723,9 @@ func Stats(container types.Container) (ContainerStats, error) {
 		if systemDelta > 0 && cpuDelta > 0 {
 			cpuUsage = (cpuDelta / systemDelta) * float64(perCore) * 100
 			
-			utils.Debug("StatsAll - CPU CPUUsage " + strconv.FormatFloat(cpuUsage, 'f', 6, 64))
+			// utils.Debug("StatsAll - CPU CPUUsage " + strconv.FormatFloat(cpuUsage, 'f', 6, 64))
 		} else {
-			utils.Error("StatsAll - Error calculating CPU usage for " + container.Names[0], nil)
+			utils.Debug("StatsAll - Error calculating CPU usage for " + container.Names[0])
 		}
 
 		// memUsage := float64(stats.MemoryStats.Usage) / float64(stats.MemoryStats.Limit) * 100
@@ -780,4 +788,12 @@ func Stats(container types.Container) (ContainerStats, error) {
 		wg.Wait() // Wait for all goroutines to finish.
 	
 		return containerStatsList, nil
+	}
+
+	func StopContainer(containerName string) {
+		err := DockerClient.ContainerStop(DockerContext, containerName, container.StopOptions{})
+		if err != nil {
+			utils.Error("StopContainer", err)
+			return
+		}
 	}

+ 6 - 2
src/httpServer.go

@@ -154,11 +154,11 @@ func SecureAPI(userRouter *mux.Router, public bool) {
 	userRouter.Use(proxy.SmartShieldMiddleware(
 		"__COSMOS",
 		utils.ProxyRouteConfig{
-			Name: "_Cosmos",
+			Name: "Cosmos-Internal",
 			SmartShield: utils.SmartShieldPolicy{
 				Enabled: true,
 				PolicyStrictness: 1,
-				PerUserRequestLimit: 5000,
+				PerUserRequestLimit: 6000,
 			},
 		},
 	))
@@ -350,6 +350,10 @@ func InitServer() *mux.Router {
 
 	srapi.HandleFunc("/api/metrics", metrics.API_GetMetrics)
 	srapi.HandleFunc("/api/reset-metrics", metrics.API_ResetMetrics)
+	srapi.HandleFunc("/api/list-metrics", metrics.ListMetrics)
+
+	srapi.HandleFunc("/api/notifications/read", utils.MarkAsRead)
+	srapi.HandleFunc("/api/notifications", utils.NotifGet)
 
 	if(!config.HTTPConfig.AcceptAllInsecureHostname) {
 		srapi.Use(utils.EnsureHostname)

+ 2 - 0
src/index.go

@@ -23,6 +23,8 @@ func main() {
 
 	LoadConfig()
 
+	utils.InitDBBuffers()
+	
 	go CRON()
 
 	docker.ExportDocker()

+ 23 - 1
src/metrics/aggl.go

@@ -33,6 +33,7 @@ type DataDefDB struct {
 	AggloType string
 	Scale int
 	Unit string
+	Object string
 }
 
 func AggloMetrics(metricsList []string) []DataDefDB {
@@ -61,7 +62,7 @@ func AggloMetrics(metricsList []string) []DataDefDB {
     for _, metric := range metricsList {
         if strings.Contains(metric, "*") {
             // Convert wildcard to regex. Replace * with .*
-            regexPattern := "^" + strings.ReplaceAll(metric, "*", ".*")
+            regexPattern := "^" + strings.ReplaceAll(metric, "*", ".*?")
             regexPatterns = append(regexPatterns, bson.M{"Key": bson.M{"$regex": regexPattern}})
         } else {
             // If there's no wildcard, match the metric directly
@@ -90,6 +91,9 @@ func AggloMetrics(metricsList []string) []DataDefDB {
 	hourlyPoolTo := ModuloTime(time.Now().Add(1 * time.Hour), time.Hour)
 	dailyPool := ModuloTime(time.Now(), 24 * time.Hour)
 	dailyPoolTo := ModuloTime(time.Now().Add(24 * time.Hour), 24 * time.Hour)
+
+	previousHourlyPool := ModuloTime(time.Now().Add(-1 * time.Hour), time.Hour)
+	previousDailyPool := ModuloTime(time.Now().Add(-24 * time.Hour), 24 * time.Hour)
 	
 	for metInd, metric := range metrics {
 		values := metric.Values
@@ -109,6 +113,15 @@ func AggloMetrics(metricsList []string) []DataDefDB {
 				AggloTo: hourlyPoolTo,
 				AggloExpire: hourlyPoolTo.Add(48 * time.Hour),
 			}
+
+			// check alerts on previous pool
+			if agMet, ok := metric.ValuesAggl["hour_" + previousHourlyPool.UTC().Format("2006-01-02 15:04:05")]; ok {
+				CheckAlerts(metric.Key, "hourly", utils.AlertMetricTrack{
+					Key: metric.Key,
+					Object: metric.Object,
+					Max: metric.Max,
+				}, agMet.Value)
+			}
 		}
 	
 		// if daily pool does not exist, create it
@@ -121,6 +134,15 @@ func AggloMetrics(metricsList []string) []DataDefDB {
 				AggloTo: dailyPoolTo,
 				AggloExpire: dailyPoolTo.Add(30 * 24 * time.Hour),
 			}
+
+			// check alerts on previous pool
+			if agMet, ok := metric.ValuesAggl["day_" + previousDailyPool.UTC().Format("2006-01-02 15:04:05")]; ok {
+				CheckAlerts(metric.Key, "daily", utils.AlertMetricTrack{
+					Key: metric.Key,
+					Object: metric.Object,
+					Max: metric.Max,
+				}, agMet.Value)
+			}
 		}
 
 		for valInd, value := range values {

+ 175 - 0
src/metrics/alerts.go

@@ -0,0 +1,175 @@
+package metrics 
+
+import (
+	"strings"
+	"regexp"
+	"fmt"
+	"time"
+
+	"github.com/azukaar/cosmos-server/src/utils"
+	"github.com/azukaar/cosmos-server/src/docker"
+)
+
+func CheckAlerts(TrackingMetric string, Period string, metric utils.AlertMetricTrack, Value int) {
+	config := utils.GetMainConfig()
+	ActiveAlerts := config.MonitoringAlerts
+	
+	alerts := []utils.Alert{}
+	ok := false
+
+	// if tracking metric contains a wildcard
+	if strings.Contains(TrackingMetric, "*") {
+		regexPattern := "^" + strings.ReplaceAll(TrackingMetric, "*", ".*?")
+		regex, _ := regexp.Compile(regexPattern)
+
+		// Iterate over the map to find a match
+		for _, val := range ActiveAlerts {
+			if regex.MatchString(val.TrackingMetric) && val.Period == Period {
+				alerts = append(alerts, val)
+				ok = true
+			}
+		}
+	} else {
+		for _, val := range ActiveAlerts {
+			if val.TrackingMetric == TrackingMetric  && val.Period == Period {
+				alerts = append(alerts, val)
+				ok = true
+				break 
+			}
+		}
+	}
+
+	if !ok {
+		return
+	}
+
+	for _, alert := range alerts {
+		if !alert.Enabled {
+			continue
+		}
+
+		if alert.Throttled && alert.LastTriggered.Add(time.Hour * 24).After(time.Now()) {
+			continue
+		}
+		
+		ValueToTest := Value 
+
+		if alert.Condition.Percent {
+			ValueToTest = int(float64(Value) / float64(metric.Max) * 100)
+
+			utils.Debug(fmt.Sprintf("Alert %s: %d / %d = %d%%", alert.Name, Value, metric.Max, ValueToTest))
+		}
+
+		// Check if the condition is met
+		if alert.Condition.Operator == "gt" {
+			if ValueToTest > alert.Condition.Value {
+				ExecuteAllActions(alert, alert.Actions, metric)
+			}
+		} else if alert.Condition.Operator == "lt" {
+			if ValueToTest < alert.Condition.Value {
+				ExecuteAllActions(alert, alert.Actions, metric)
+			}
+		} else if alert.Condition.Operator == "eq" {
+			if ValueToTest == alert.Condition.Value {
+				ExecuteAllActions(alert, alert.Actions, metric)
+			}
+		}
+	}
+}
+
+func ExecuteAllActions(alert utils.Alert, actions []utils.AlertAction, metric utils.AlertMetricTrack) {
+	utils.Debug("Alert triggered: " + alert.Name)
+	for _, action := range actions {
+		ExecuteAction(alert, action, metric)
+	}
+
+	// set LastTriggered to now
+	alert.LastTriggered = time.Now()
+
+	// update alert in config
+	config := utils.GetMainConfig()
+	for i, val := range config.MonitoringAlerts {
+		if val.Name == alert.Name {
+			config.MonitoringAlerts[i] = alert
+			break
+		}
+	}
+
+	utils.SetBaseMainConfig(config)
+}
+
+func ExecuteAction(alert utils.Alert, action utils.AlertAction, metric utils.AlertMetricTrack) {
+	utils.Log("Executing action " + action.Type + " on " + metric.Key + " " + metric.Object	)
+
+	if action.Type == "email" {
+		utils.Debug("Sending email to " + action.Target)
+
+		if utils.GetMainConfig().EmailConfig.Enabled {
+			users := utils.ListAllUsers("admin")
+			for _, user := range users {
+				if user.Email != "" {
+					utils.SendEmail([]string{user.Email}, "Alert Triggered: " + alert.Name,
+					fmt.Sprintf(`<h1>Alert Triggered [%s]</h1>
+You are recevining this email because you are admin on a Cosmos
+server where an Alert has been subscribed to.<br />
+You can manage your subscriptions in the Monitoring tab.<br />
+Alert triggered on %s. Please refer to the Monitoring tab for
+more information.<br />`, alert.Severity, metric.Key))
+				}
+			}
+		} else {
+			utils.Warn("Alert triggered but Email is not enabled")
+		}
+
+	} else if action.Type == "webhook" {
+		utils.Debug("Calling webhook " + action.Target)
+
+	} else if action.Type == "stop" {
+		utils.Debug("Stopping application")
+
+		parts := strings.Split(metric.Object, "@")
+
+		if len(parts) > 1 {
+			object := parts[0]
+			objectName := strings.Join(parts[1:], "@")
+
+			if object == "container" {
+				docker.StopContainer(objectName)
+			} else if object == "route" {
+				config := utils.ReadConfigFromFile()
+
+				objectIndex := -1
+				for i, route := range config.HTTPConfig.ProxyConfig.Routes {
+					if route.Name == objectName {
+						objectIndex = i
+						break
+					}
+				}
+
+				if objectIndex != -1 {
+					config.HTTPConfig.ProxyConfig.Routes[objectIndex].Disabled = true		
+
+					utils.SetBaseMainConfig(config)
+				} else {
+					utils.Warn("No route found, for " + objectName)
+				}
+
+				utils.RestartHTTPServer()
+			}
+		} else {
+			utils.Warn("No object found, for " + metric.Object)
+		}
+
+	} else if action.Type == "notification" {
+		utils.WriteNotification(utils.Notification{
+			Recipient: "admin",
+			Title: "Alert triggered",
+			Message: "The alert \"" + alert.Name + "\" was triggered.",
+			Level: alert.Severity,
+			Link: "/cosmos-ui/monitoring",
+		})
+
+	} else if action.Type == "script" {
+		utils.Debug("Executing script")
+	}
+}

+ 63 - 0
src/metrics/middleware.go → src/metrics/http.go

@@ -2,6 +2,11 @@ package metrics
 
 import (
 	"time"
+	"net/http"
+	"encoding/json"
+
+	"go.mongodb.org/mongo-driver/mongo/options"
+	"go.mongodb.org/mongo-driver/bson"
 
 	"github.com/azukaar/cosmos-server/src/utils"
 )
@@ -25,6 +30,7 @@ func PushRequestMetrics(route utils.ProxyRouteConfig, statusCode int, TimeStarte
 				Label: "Request Errors " + route.Name,
 				AggloType: "sum",
 				SetOperation: "sum",
+				Object: "route@" + route.Name,
 			})
 		} else {
 			PushSetMetric("proxy.all.success", 1, DataDef{
@@ -40,6 +46,7 @@ func PushRequestMetrics(route utils.ProxyRouteConfig, statusCode int, TimeStarte
 				Label: "Request Success " + route.Name,
 				AggloType: "sum",
 				SetOperation: "sum",
+				Object: "route@" + route.Name,
 			})
 		}
 
@@ -59,6 +66,7 @@ func PushRequestMetrics(route utils.ProxyRouteConfig, statusCode int, TimeStarte
 			AggloType: "sum",
 			SetOperation: "sum",
 			Unit: "ms",
+			Object: "route@" + route.Name,
 		})
 
 		PushSetMetric("proxy.all.bytes", int(size), DataDef{
@@ -77,6 +85,7 @@ func PushRequestMetrics(route utils.ProxyRouteConfig, statusCode int, TimeStarte
 			AggloType: "sum",
 			SetOperation: "sum",
 			Unit: "B",
+			Object: "route@" + route.Name,
 		})
 	}
 
@@ -108,4 +117,58 @@ func PushShieldMetrics(reason string) {
 		AggloType: "sum",
 		SetOperation: "sum",
 	})
+}
+
+type MetricList struct {
+	Key string
+	Label string
+}
+
+func ListMetrics(w http.ResponseWriter, req *http.Request) {
+	if utils.AdminOnly(w, req) != nil {
+		return
+	}
+	
+	if(req.Method == "GET") {
+		c, errCo := utils.GetCollection(utils.GetRootAppId(), "metrics")
+		if errCo != nil {
+				utils.Error("Database Connect", errCo)
+				utils.HTTPError(w, "Database", http.StatusInternalServerError, "DB001")
+				return
+		}
+
+		metrics := []MetricList{}
+
+		cursor, err := c.Find(nil, map[string]interface{}{}, options.Find().SetProjection(bson.M{"Key": 1, "Label":1, "_id": 0}))
+
+		if err != nil {
+			utils.Error("metrics: Error while getting metrics", err)
+			utils.HTTPError(w, "metrics Get Error", http.StatusInternalServerError, "UD001")
+			return
+		}
+
+		defer cursor.Close(nil)
+
+		if err = cursor.All(nil, &metrics); err != nil {
+			utils.Error("metrics: Error while decoding metrics", err)
+			utils.HTTPError(w, "metrics decode Error", http.StatusInternalServerError, "UD002")
+			return
+		}
+
+		// Extract the names into a string slice
+		metricNames := map[string]string{}
+
+		for _, metric := range metrics {
+				metricNames[metric.Key] = metric.Label
+		}
+
+		json.NewEncoder(w).Encode(map[string]interface{}{
+				"status": "OK",
+				"data":   metricNames,
+		})
+	} else {
+		utils.Error("metrics: Method not allowed" + req.Method, nil)
+		utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+		return
+	}
 }

+ 10 - 0
src/metrics/index.go

@@ -19,6 +19,7 @@ type DataDef struct {
 	Scale int
 	Unit string
 	Decumulate bool
+	Object string
 }
 
 type DataPush struct {
@@ -33,6 +34,7 @@ type DataPush struct {
 	Scale int
 	Unit string
 	Decumulate bool
+	Object string
 }
 
 var dataBuffer = map[string]DataPush{}
@@ -109,6 +111,7 @@ func SaveMetrics() {
 							"AggloType": dp.AggloType,
 							"Scale": scale,
 							"Unit": dp.Unit,
+							"Object": dp.Object,
 					},
 			}
 			
@@ -181,9 +184,16 @@ func PushSetMetric(key string, value int, def DataDef) {
 				AggloType: def.AggloType,
 				Scale: def.Scale,
 				Unit: def.Unit,
+				Object: def.Object,
 			}
 		}
 
+		CheckAlerts(key, "latest", utils.AlertMetricTrack{
+			Key: key,
+			Object: def.Object,
+			Max: def.Max,
+		}, value)
+
 		lastInserted[key] = originalValue
 	}()
 }

+ 6 - 1
src/metrics/system.go

@@ -160,6 +160,7 @@ func GetSystemMetrics() {
 					Period: time.Second * 120,
 					Label: "Disk " + part.Mountpoint,
 					Unit: "B",
+					Object: "disk@" + part.Mountpoint,
 				})
 			}
 		}
@@ -208,13 +209,15 @@ func GetSystemMetrics() {
 			AggloType: "avg",
 			Scale: 100,
 			Unit: "%",
+			Object: "container@" + ds.Name,
 		})
 		PushSetMetric("system.docker.ram." + ds.Name, int(ds.MemUsage), DataDef{
-			Max: 0,
+			Max: memInfo.Total,
 			Period: time.Second * 30,
 			Label: "Docker RAM " + ds.Name,
 			AggloType: "avg",
 			Unit: "B",
+			Object: "container@" + ds.Name,
 		})
 		PushSetMetric("system.docker.netRx." + ds.Name, int(ds.NetworkRx), DataDef{
 			Max: 0,
@@ -224,6 +227,7 @@ func GetSystemMetrics() {
 			AggloType: "sum",
 			Decumulate: true,
 			Unit: "B",
+			Object: "container@" + ds.Name,
 		})
 		PushSetMetric("system.docker.netTx." + ds.Name, int(ds.NetworkTx), DataDef{
 			Max: 0,
@@ -233,6 +237,7 @@ func GetSystemMetrics() {
 			AggloType: "sum",
 			Decumulate: true,
 			Unit: "B",
+			Object: "container@" + ds.Name,
 		})
 	}
 }

+ 3 - 1
src/proxy/buildFromConfig.go

@@ -15,7 +15,9 @@ func BuildFromConfig(router *mux.Router, config utils.ProxyConfig) *mux.Router {
 
 	for i := len(config.Routes)-1; i >= 0; i-- {
 		routeConfig := config.Routes[i]
-		RouterGen(routeConfig, router, RouteTo(routeConfig))
+		if !routeConfig.Disabled {
+			RouterGen(routeConfig, router, RouteTo(routeConfig))
+		}
 	}
 	
 	return router

+ 124 - 1
src/utils/db.go

@@ -4,6 +4,9 @@ import (
 	"context"
 	"os"
 	"errors"
+	"sync"
+	"time"
+
 	"go.mongodb.org/mongo-driver/mongo"
 	"go.mongodb.org/mongo-driver/mongo/options"
 	"go.mongodb.org/mongo-driver/mongo/readpref"
@@ -79,4 +82,124 @@ func GetCollection(applicationId string, collection string) (*mongo.Collection,
 
 // func query(q string) (*sql.Rows, error) {
 // 	return db.Query(q)
-// }
+// }
+
+var (
+	bufferLock     sync.Mutex
+	writeBuffer    = make(map[string][]map[string]interface{})
+	bufferTicker   = time.NewTicker(1 * time.Minute)
+	bufferCapacity = 100
+)
+
+func InitDBBuffers() {
+	go func() {
+		for {
+			select {
+			case <-bufferTicker.C:
+				flushAllBuffers()
+			}
+		}
+	}()
+}
+
+func flushBuffer(collectionName string) {
+	bufferLock.Lock()
+	objects, exists := writeBuffer[collectionName]
+	if exists && len(objects) > 0 {
+		collection, errG := GetCollection(GetRootAppId(), collectionName)
+		if errG != nil {
+			Error("BulkDBWritter: Error getting collection", errG)
+		}
+
+		if err := WriteToDatabase(collection, objects); err != nil {
+			Error("BulkDBWritter: Error writing to database", err)
+		}
+		writeBuffer[collectionName] = make([]map[string]interface{}, 0)
+	}
+	bufferLock.Unlock()
+}
+
+func flushAllBuffers() {
+	bufferLock.Lock()
+	for collectionName, objects := range writeBuffer {
+		if len(objects) > 0 {
+			collection, errG := GetCollection(GetRootAppId(), collectionName)
+			if errG != nil {
+				Error("BulkDBWritter: Error getting collection", errG)
+			}
+
+			if err := WriteToDatabase(collection, objects); err != nil {
+				Error("BulkDBWritter: Error writing to database: ", err)
+			}
+			writeBuffer[collectionName] = make([]map[string]interface{}, 0)
+		}
+	}
+	bufferLock.Unlock()
+}
+
+func BufferedDBWrite(collectionName string, object map[string]interface{}) {
+	bufferLock.Lock()
+	writeBuffer[collectionName] = append(writeBuffer[collectionName], object)
+	if len(writeBuffer[collectionName]) >= bufferCapacity {
+		flushBuffer(collectionName)
+	}
+	bufferLock.Unlock()
+}
+
+func WriteToDatabase(collection *mongo.Collection, objects []map[string]interface{}) error {
+	if len(objects) == 0 {
+		return nil // Nothing to write
+	}
+
+	// Convert to a slice of interface{} for insertion
+	interfaceSlice := make([]interface{}, len(objects))
+	for i, v := range objects {
+		interfaceSlice[i] = v
+	}
+
+	_, err := collection.InsertMany(context.Background(), interfaceSlice)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func ListAllUsers(role string) []User { 
+	// list all users
+	c, errCo := GetCollection(GetRootAppId(), "users")
+	if errCo != nil {
+			Error("Database Connect", errCo)
+			return []User{}
+	}
+
+	users := []User{}
+
+	condition := map[string]interface{}{}
+
+	if role == "admin" {
+		condition = map[string]interface{}{
+			"Role": 2,
+		}
+	} else if role == "user" {
+		condition = map[string]interface{}{
+			"Role": 1,
+		}
+	}
+
+	cursor, err := c.Find(nil, condition)
+
+	if err != nil {
+		Error("Database: Error while getting users", err)
+		return []User{}
+	}
+	
+	defer cursor.Close(nil)
+
+	if err = cursor.All(nil, &users); err != nil {
+		Error("Database: Error while decoding users", err)
+		return []User{}
+	}
+
+	return users
+}

+ 18 - 0
src/utils/log.go

@@ -47,6 +47,24 @@ func Error(message string, err error) {
 	}
 }
 
+func MajorError(message string, err error) {
+	ll := LoggingLevelLabels[GetMainConfig().LoggingLevel]
+	errStr := ""
+	if err != nil {
+		errStr = err.Error()
+	}
+	if ll <= ERROR {
+		log.Println(Red + "[ERROR] " + message + " : " + errStr + Reset)
+	}
+	
+	WriteNotification(Notification{
+		Recipient: "admin",
+		Title: "Server Error",
+		Message: message + " : " + errStr,
+		Level: "error",
+	})
+}
+
 func Fatal(message string, err error) {
 	ll := LoggingLevelLabels[GetMainConfig().LoggingLevel]
 	errStr := ""

+ 11 - 2
src/utils/middleware.go

@@ -62,9 +62,11 @@ func BlockBannedIPs(next http.Handler) http.Handler {
 
 				nbAbuse := getIPAbuseCounter(ip)
 
-				// Debug("IP " + ip + " has " + fmt.Sprintf("%d", nbAbuse) + " abuse(s)")
+        if nbAbuse > 275 {
+					Warn("IP " + ip + " has " + fmt.Sprintf("%d", nbAbuse) + " abuse(s) and will soon be banned.")
+				}
 
-        if nbAbuse > 1000 {
+        if nbAbuse > 300 {
 					if hj, ok := w.(http.Hijacker); ok {
 							conn, _, err := hj.Hijack()
 							if err == nil {
@@ -78,6 +80,13 @@ func BlockBannedIPs(next http.Handler) http.Handler {
     })
 }
 
+func CleanBannedIPs() {
+	BannedIPs.Range(func(key, value interface{}) bool {
+		BannedIPs.Delete(key)
+		return true
+	})
+}
+
 func MiddlewareTimeout(timeout time.Duration) func(next http.Handler) http.Handler {
 	return func(next http.Handler) http.Handler {
 		fn := func(w http.ResponseWriter, r *http.Request) {

+ 201 - 0
src/utils/notifications.go

@@ -0,0 +1,201 @@
+package utils 
+
+import (
+	"net/http"
+	"encoding/json"
+	"time"
+	"fmt"
+	"strings"
+	
+	"go.mongodb.org/mongo-driver/mongo/options"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.mongodb.org/mongo-driver/bson"
+)
+
+type NotificationActions struct {
+	Text string
+	Link string
+}
+
+type Notification struct {
+	ID primitive.ObjectID      `bson:"_id,omitempty"`
+	Title string
+	Message string
+	Icon string
+	Link string
+	Date time.Time
+	Level string
+	Read bool
+	Recipient string
+	Actions []NotificationActions
+}
+
+func NotifGet(w http.ResponseWriter, req *http.Request) {
+	_from := req.URL.Query().Get("from")
+	from, _ := primitive.ObjectIDFromHex(_from)
+	
+	if LoggedInOnly(w, req) != nil {
+		return
+	}
+	
+	nickname := req.Header.Get("x-cosmos-user")
+
+	if(req.Method == "GET") {
+		c, errCo := GetCollection(GetRootAppId(), "notifications")
+		if errCo != nil {
+				Error("Database Connect", errCo)
+				HTTPError(w, "Database", http.StatusInternalServerError, "DB001")
+				return
+		}
+
+		Debug("Notifications: Get notif for " + nickname)
+
+		notifications := []Notification{}
+
+		reqdb := map[string]interface{}{
+			"Recipient": nickname, 
+		}
+
+		if from != primitive.NilObjectID {
+			reqdb = map[string]interface{}{
+				// nickname or role 
+				"Recipient": nickname, 
+				// get notif before from
+				"_id": map[string]interface{}{
+					"$lt": from,
+				},
+			}
+		}
+
+		limit := int64(20)
+
+		cursor, err := c.Find(nil, reqdb, &options.FindOptions{
+			Sort: map[string]interface{}{
+				"Date": -1,
+			},
+			Limit: &limit,
+		})
+
+		if err != nil {
+			Error("Notifications: Error while getting notifications", err)
+			HTTPError(w, "notifications Get Error", http.StatusInternalServerError, "UD001")
+			return
+		}
+
+		defer cursor.Close(nil)
+		
+		if err = cursor.All(nil, &notifications); err != nil {
+			Error("Notifications: Error while decoding notifications", err)
+			HTTPError(w, "notifications Get Error", http.StatusInternalServerError, "UD002")
+			return
+		}
+		
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"status": "OK",
+			"data": notifications,
+		})
+	} else {
+		Error("Notifications: Method not allowed" + req.Method, nil)
+		HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+		return
+	}
+}
+
+func MarkAsRead(w http.ResponseWriter, req *http.Request) {
+	if(req.Method == "GET") {
+		if LoggedInOnly(w, req) != nil {
+				return
+		}
+
+		notificationIDs := []primitive.ObjectID{}
+		nickname := req.Header.Get("x-cosmos-user")
+
+		notificationIDsRawRunes := req.URL.Query().Get("ids")
+
+		notificationIDsRaw := strings.Split(notificationIDsRawRunes, ",")
+
+		Debug(fmt.Sprintf("Marking %v notifications as read",notificationIDsRaw))
+
+		for _, notificationIDRaw := range notificationIDsRaw {
+			notificationID, err := primitive.ObjectIDFromHex(notificationIDRaw)
+
+			if err != nil {
+					HTTPError(w, "Invalid notification ID " + notificationIDRaw, http.StatusBadRequest, "InvalidID")
+					return
+			}
+
+			notificationIDs = append(notificationIDs, notificationID)
+		}
+
+
+		c, errCo := GetCollection(GetRootAppId(), "notifications")
+		if errCo != nil {
+				Error("Database Connect", errCo)
+				HTTPError(w, "Database connection error", http.StatusInternalServerError, "DB001")
+				return
+		}
+
+		filter := bson.M{"_id": bson.M{"$in": notificationIDs}, "Recipient": nickname}
+		update := bson.M{"$set": bson.M{"Read": true}}
+		result, err := c.UpdateMany(nil, filter, update)
+		if err != nil {
+				Error("Notifications: Error while marking notification as read", err)
+				HTTPError(w, "Error updating notification", http.StatusInternalServerError, "UpdateError")
+				return
+		}
+
+		if result.MatchedCount == 0 {
+				HTTPError(w, "No matching notification found", http.StatusNotFound, "NotFound")
+				return
+		}
+
+		json.NewEncoder(w).Encode(map[string]interface{}{
+				"status": "OK",
+				"message": "Notification marked as read",
+		})
+	} else {
+		Error("Notifications: Method not allowed" + req.Method, nil)
+		HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+		return
+	}
+}
+
+
+func WriteNotification(notification Notification) {
+	notification.Date = time.Now()
+
+	notification.Read = false
+
+	if notification.Recipient == "all" || notification.Recipient == "admin" || notification.Recipient == "user" {
+		// list all users
+		users := ListAllUsers(notification.Recipient)
+
+		Debug("Notifications: Sending notification to " + string(len(users)) + " users")
+
+		for _, user := range users {
+			BufferedDBWrite("notifications", map[string]interface{}{
+				"Title": notification.Title,
+				"Message": notification.Message,
+				"Icon": notification.Icon,
+				"Link": notification.Link,
+				"Date": notification.Date,
+				"Level": notification.Level,
+				"Read": notification.Read,
+				"Recipient": user.Nickname,
+				"Actions": notification.Actions,
+			})
+		}
+	} else {
+		BufferedDBWrite("notifications", map[string]interface{}{
+			"Title": notification.Title,
+			"Message": notification.Message,
+			"Icon": notification.Icon,
+			"Link": notification.Link,
+			"Date": notification.Date,
+			"Level": notification.Level,
+			"Read": notification.Read,
+			"Recipient": notification.Recipient,
+			"Actions": notification.Actions,
+		})
+	}
+}

+ 31 - 0
src/utils/types.go

@@ -92,6 +92,7 @@ type Config struct {
 	ThemeConfig ThemeConfig
 	ConstellationConfig ConstellationConfig
 	MonitoringDisabled bool
+	MonitoringAlerts map[string]Alert
 }
 
 type HomepageConfig struct {
@@ -160,6 +161,7 @@ type AddionalFiltersConfig struct {
 }
 
 type ProxyRouteConfig struct {
+	Disabled bool
 	Name string `validate:"required"`
 	Description string
 	UseHost bool
@@ -323,3 +325,32 @@ type Device struct {
 	PrivateKey string `json:"privateKey",omitempty`
 	IP string `json:"ip",validate:"required,ipv4"`
 }
+
+type Alert struct {
+	Name string
+	Enabled bool
+	Period string
+	TrackingMetric string
+	Condition AlertCondition
+	Actions []AlertAction
+	LastTriggered time.Time
+	Throttled bool
+	Severity string
+}
+
+type AlertCondition struct {
+	Operator string
+	Value int
+	Percent bool
+}
+
+type AlertAction struct {
+	Type string
+	Target string
+}
+
+type AlertMetricTrack struct {
+	Key string
+	Object string
+	Max uint64
+}