[release] v0.12.0-unstable41

This commit is contained in:
Yann Stepienik 2023-11-05 15:16:57 +00:00
parent 3abd0ee6ea
commit aa963bb89f
36 changed files with 1603 additions and 220 deletions

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View file

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

View file

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

View file

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

View file

@ -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 />} */}
</>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
});
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;
}
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: 'Proxy',
children: <ProxyDashboard xAxis={xAxis} zoom={zoom} setZoom={setZoom} slot={slot} metrics={metrics} />
},
{
title: 'Events',
children: <AlertPage />
},
{
title: 'Alerts',
children: <AlertPage />
},
]}
/>

View file

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

View file

@ -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(')
@ -269,41 +258,23 @@ const HomePage = () => {
labels: []
};
const bigNb = {
fontSize: '23px',
fontWeight: "bold",
textAlign: "center",
color: isDark ? "white" : "black",
textShadow: "0px 0px 5px #000",
lineHeight: "97px",
let latestCPU, latestRAM, latestRAMRaw, maxRAM, maxRAMRaw = 0;
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={{

View file

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

10
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -781,3 +789,11 @@ func Stats(container types.Container) (ContainerStats, error) {
return containerStatsList, nil
}
func StopContainer(containerName string) {
err := DockerClient.ContainerStop(DockerContext, containerName, container.StopOptions{})
if err != nil {
utils.Error("StopContainer", err)
return
}
}

View file

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

View file

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

View file

@ -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
@ -91,6 +92,9 @@ func AggloMetrics(metricsList []string) []DataDefDB {
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
src/metrics/alerts.go Normal file
View file

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

View file

@ -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,
})
}
@ -109,3 +118,57 @@ func PushShieldMetrics(reason string) {
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
}
}

View file

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

View file

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

View file

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

View file

@ -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"
@ -80,3 +83,123 @@ 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
}

View file

@ -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 := ""

View file

@ -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
src/utils/notifications.go Normal file
View file

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

View file

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