`;
+ });
+
+ if(docker) {
+ let parts = html.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/)
+ let restString = html.replace(parts[0], '')
+
+ return
+
+ {parts[0].replace('T', ' ').split('.')[0]}
+
+
+ ;
+ }
+
+
+ return ;
+};
+
+const getColor = (code) => {
+ switch (code) {
+ case '30':
+ case '90':
+ return 'black';
+ case '31':
+ case '91':
+ return 'red';
+ case '32':
+ case '92':
+ return 'green';
+ case '33':
+ case '93':
+ return 'yellow';
+ case '34':
+ case '94':
+ return 'blue';
+ case '35':
+ case '95':
+ return 'magenta';
+ case '36':
+ case '96':
+ return 'cyan';
+ case '37':
+ case '97':
+ return 'white';
+ default:
+ return 'inherit';
+ }
+};
+
+export default LogLine;
\ No newline at end of file
diff --git a/client/src/components/tabbedView/tabbedView.jsx b/client/src/components/tabbedView/tabbedView.jsx
index a43727a..4cee7a7 100644
--- a/client/src/components/tabbedView/tabbedView.jsx
+++ b/client/src/components/tabbedView/tabbedView.jsx
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
-import { Box, Tab, Tabs, Typography, MenuItem, Select, useMediaQuery } from '@mui/material';
+import { Box, Tab, Tabs, Typography, MenuItem, Select, useMediaQuery, CircularProgress } from '@mui/material';
import { styled } from '@mui/system';
const StyledTabs = styled(Tabs)`
@@ -36,7 +36,7 @@ const a11yProps = (index) => {
};
};
-const PrettyTabbedView = ({ tabs }) => {
+const PrettyTabbedView = ({ tabs, isLoading }) => {
const [value, setValue] = useState(0);
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'));
@@ -67,15 +67,32 @@ const PrettyTabbedView = ({ tabs }) => {
aria-label="Vertical tabs"
>
{tabs.map((tab, index) => (
-
+
))}
)}
- {tabs.map((tab, index) => (
+ {!isLoading && tabs.map((tab, index) => (
{tab.children}
))}
+ {isLoading && (
+
+
+
+ )}
);
};
diff --git a/client/src/components/tableView/prettyTableView.jsx b/client/src/components/tableView/prettyTableView.jsx
index d5f5f71..bb63856 100644
--- a/client/src/components/tableView/prettyTableView.jsx
+++ b/client/src/components/tableView/prettyTableView.jsx
@@ -20,6 +20,8 @@ const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo }) => {
sm: useMediaQuery((theme) => theme.breakpoints.up('sm')),
md: useMediaQuery((theme) => theme.breakpoints.up('md')),
lg: useMediaQuery((theme) => theme.breakpoints.up('lg')),
+ xl: useMediaQuery((theme) => theme.breakpoints.up('xl')),
+ xxl: useMediaQuery((theme) => theme.breakpoints.up('xxl')),
}
return (
diff --git a/client/src/index.css b/client/src/index.css
index de814ac..a2edbe1 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -61,4 +61,8 @@
color:white;
background-color: rgba(0,0,0,0.8);
}
+}
+
+.darken {
+ filter: brightness(0.5);
}
\ No newline at end of file
diff --git a/client/src/pages/config/routeConfigPage.jsx b/client/src/pages/config/routeConfigPage.jsx
index 26d1b03..63f3f8c 100644
--- a/client/src/pages/config/routeConfigPage.jsx
+++ b/client/src/pages/config/routeConfigPage.jsx
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import * as API from "../../api";
import RouteSecurity from "./routes/routeSecurity";
import RouteOverview from "./routes/routeoverview";
+import IsLoggedIn from "../../isLoggedIn";
const RouteConfigPage = () => {
const { routeName } = useParams();
@@ -28,6 +29,7 @@ const RouteConfigPage = () => {
}, []);
return
+
diff --git a/client/src/pages/config/routes/routeoverview.jsx b/client/src/pages/config/routes/routeoverview.jsx
index c643a33..925b5ed 100644
--- a/client/src/pages/config/routes/routeoverview.jsx
+++ b/client/src/pages/config/routes/routeoverview.jsx
@@ -7,6 +7,13 @@ import { RouteMode, RouteSecurity } from '../../../components/routeComponents';
import { getFaviconURL } from '../../../utils/routes';
import * as API from '../../../api';
import { CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, UpOutlined } from "@ant-design/icons";
+import IsLoggedIn from '../../../isLoggedIn';
+
+const info = {
+ backgroundColor: 'rgba(0, 0, 0, 0.1)',
+ padding: '10px',
+ borderRadius: '5px',
+}
const RouteOverview = ({ routeConfig }) => {
const [openModal, setOpenModal] = React.useState(false);
@@ -35,7 +42,7 @@ const RouteOverview = ({ routeConfig }) => {
Description
- {routeConfig.Description}
+ {routeConfig.Description}
URL
Target
diff --git a/client/src/pages/newInstall/newInstall.jsx b/client/src/pages/newInstall/newInstall.jsx
index 330f585..ef947ad 100644
--- a/client/src/pages/newInstall/newInstall.jsx
+++ b/client/src/pages/newInstall/newInstall.jsx
@@ -23,6 +23,9 @@ const NewInstall = () => {
const [activeStep, setActiveStep] = useState(0);
const [status, setStatus] = useState(null);
const [counter, setCounter] = useState(0);
+ let [hostname, setHostname] = useState('');
+ const [databaseEnable, setDatabaseEnable] = useState(true);
+
const refreshStatus = async () => {
try {
const res = await API.getStatus()
@@ -34,7 +37,7 @@ const NewInstall = () => {
if (typeof status !== 'undefined') {
setTimeout(() => {
setCounter(counter + 1);
- }, 2000);
+ }, 2500);
}
}
@@ -43,7 +46,7 @@ const NewInstall = () => {
}, [counter]);
useEffect(() => {
- if(activeStep == 4 && status && !status.database) {
+ if(activeStep == 4 && status && !databaseEnable) {
setActiveStep(5);
}
}, [activeStep, status]);
@@ -122,8 +125,12 @@ const NewInstall = () => {
MongoDBMode: values.DBMode,
MongoDB: values.MongoDB,
});
- if(res.status == "OK")
+ if(res.status == "OK") {
+ if(values.DBMode === "DisableUserManagement") {
+ setDatabaseEnable(false);
+ }
setStatus({ success: true });
+ }
} catch (error) {
setStatus({ success: false });
setErrors({ submit: error.message });
@@ -205,9 +212,14 @@ const NewInstall = () => {
If you enable HTTPS, it will be effective after the next restart.
- {status &&
- HTTPS Certificate Mode is currently: {status.HTTPSCertificateMode}
-
}
+ {status && <>
+
+ HTTPS Certificate Mode is currently: {status.HTTPSCertificateMode}
+
+
+ Hostname is currently: {status.hostname}
+
+ >}
{
TLSCert: values.HTTPSCertificateMode === "PROVIDED" ? values.TLSCert : '',
Hostname: values.Hostname,
});
- if(res.status == "OK")
+ if(res.status == "OK") {
setStatus({ success: true });
+ setHostname((values.HTTPSCertificateMode == "DISABLED" ? "http://" : "https://") + values.Hostname);
+ }
} catch (error) {
setStatus({ success: false });
setErrors({ submit: "Please check you have filled all the inputs properly" });
@@ -264,11 +278,15 @@ const NewInstall = () => {
["LETSENCRYPT", "Use Let's Encrypt automatic HTTPS (recommended)"],
["PROVIDED", "Supply my own HTTPS certificate"],
["SELFSIGNED", "Generate a self-signed certificate"],
- ["DISABLE", "Use HTTP only (not recommended)"],
+ ["DISABLED", "Use HTTP only (not recommended)"],
]}
/>
{formik.values.HTTPSCertificateMode === "LETSENCRYPT" && (
<>
+
+ If you are using Cloudflare, make sure the DNS record is NOT set to Proxied (you should not see the orange cloud but a grey one).
+ Otherwise Cloudflare will not allow Let's Encrypt to verify your domain.
+
{
}
- onClick={() => setActiveStep(activeStep - 1)}
+ onClick={() => {
+ if(activeStep == 5 && !databaseEnable) {
+ setActiveStep(activeStep - 2)
+ }
+ setActiveStep(activeStep - 1)
+ }}
disabled={activeStep <= 0}
>Back
@@ -471,7 +494,7 @@ const NewInstall = () => {
step: "5",
})
setTimeout(() => {
- window.location.href = "/ui/login";
+ window.location.href = hostname + "/ui/login";
}, 500);
} else
setActiveStep(activeStep + 1)
diff --git a/client/src/pages/servapps/actionBar.jsx b/client/src/pages/servapps/actionBar.jsx
new file mode 100644
index 0000000..a213e35
--- /dev/null
+++ b/client/src/pages/servapps/actionBar.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { IconButton, Tooltip } from '@mui/material';
+import { CloseSquareOutlined, DeleteOutlined, PauseCircleOutlined, PlaySquareOutlined, ReloadOutlined, RollbackOutlined, StopOutlined, UpCircleOutlined } from '@ant-design/icons';
+import * as API from '../../api';
+
+const GetActions = ({
+ Id,
+ state,
+ refreshServeApps,
+ setIsUpdatingId
+}) => {
+ const doTo = (action) => {
+ setIsUpdatingId(Id, true);
+ API.docker.manageContainer(Id, action).then((res) => {
+ refreshServeApps();
+ });
+ };
+
+ let actions = [
+ {
+ t: 'Update Available',
+ if: ['update_available'],
+ e: {doTo('update')}} size='large'>
+
+
+ },
+ {
+ t: 'Start',
+ if: ['exited', 'created'],
+ e: {doTo('start')}} size='large'>
+
+
+ },
+ {
+ t: 'Unpause',
+ if: ['paused'],
+ e: {doTo('unpause')}} size='large'>
+
+
+ },
+ {
+ t: 'Pause',
+ if: ['running'],
+ e: {doTo('pause')}} size='large'>
+
+
+ },
+ {
+ t: 'Stop',
+ if: ['paused', 'restarting', 'running'],
+ e: {doTo('stop')}} size='large' variant="outlined">
+
+
+ },
+ {
+ t: 'Restart',
+ if: ['exited', 'running', 'paused', 'created', 'restarting'],
+ e: doTo('restart')} size='large'>
+
+
+ },
+ {
+ t: 'Re-create',
+ if: ['exited', 'running', 'paused', 'created', 'restarting'],
+ e: doTo('recreate')} color="error" size='large'>
+
+
+ },
+ {
+ t: 'Kill',
+ if: ['running', 'paused', 'created', 'restarting'],
+ e: doTo('kill')} color="error" size='large'>
+
+
+ },
+ {
+ t: 'Delete',
+ if: ['exited', 'created'],
+ e: {doTo('remove')}} color="error" size='large'>
+
+
+ }
+ ];
+
+ return actions.filter((action) => {
+ let updateAvailable = false;
+ return action.if.includes(state) ?? (updateAvailable && action.if.includes('update_available'));
+ }).map((action) => {
+ return {action.e}
+ });
+}
+
+export default GetActions;
\ No newline at end of file
diff --git a/client/src/pages/servapps/containers/index.jsx b/client/src/pages/servapps/containers/index.jsx
new file mode 100644
index 0000000..ace3f0e
--- /dev/null
+++ b/client/src/pages/servapps/containers/index.jsx
@@ -0,0 +1,86 @@
+import * as React from 'react';
+import MainCard from '../../../components/MainCard';
+import RestartModal from '../../config/users/restart';
+import { Chip, Divider, Stack, useMediaQuery } from '@mui/material';
+import HostChip from '../../../components/hostChip';
+import { RouteMode, RouteSecurity } from '../../../components/routeComponents';
+import { getFaviconURL } from '../../../utils/routes';
+import * as API from '../../../api';
+import { CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, UpOutlined } from "@ant-design/icons";
+import IsLoggedIn from '../../../isLoggedIn';
+import PrettyTabbedView from '../../../components/tabbedView/tabbedView';
+import Back from '../../../components/back';
+import { useParams } from 'react-router';
+import ContainerOverview from './overview';
+import Logs from './logs';
+
+const ContainerIndex = () => {
+ const { containerName } = useParams();
+ const [container, setContainer] = React.useState(null);
+ const [config, setConfig] = React.useState(null);
+
+ const refreshContainer = () => {
+ return Promise.all([API.docker.get(containerName).then((res) => {
+ setContainer(res.data);
+ }),
+ API.config.get().then((res) => {
+ setConfig(res.data);
+ })]);
+ };
+
+ React.useEffect(() => {
+ refreshContainer();
+ }, []);
+
+ return
+
+
+
+ {containerName}
+
+
+
+
+ },
+ {
+ title: 'Logs',
+ children:
+ },
+ {
+ title: 'Terminal',
+ children:
+ },
+ {
+ title: 'Links',
+ children: Links
+ },
+ // {
+ // title: 'Advanced'
+ // },
+ {
+ title: 'Setup',
+ children: Image, Restart Policy, Environment Variables, Labels, etc...
+ },
+ {
+ title: 'Network',
+ children: Urls, Networks, Ports, etc...
+ },
+ {
+ title: 'Volumes',
+ children: Volumes
+ },
+ {
+ title: 'Resources',
+ children: Runtime Resources, Capabilities...
+ },
+ ]} />
+
+
;
+}
+
+export default ContainerIndex;
\ No newline at end of file
diff --git a/client/src/pages/servapps/containers/logs.jsx b/client/src/pages/servapps/containers/logs.jsx
new file mode 100644
index 0000000..1f99a3e
--- /dev/null
+++ b/client/src/pages/servapps/containers/logs.jsx
@@ -0,0 +1,193 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Box, Button, Checkbox, CircularProgress, Input, Stack, TextField, Typography, useMediaQuery } from '@mui/material';
+import * as API from '../../../api';
+import { ReactTerminal } from "react-terminal";
+import LogLine from '../../../components/logLine';
+import { useTheme } from '@emotion/react';
+
+const Logs = ({ containerInfo }) => {
+ const { Name, Config, NetworkSettings, State } = containerInfo;
+ const containerName = Name;
+ const [logs, setLogs] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [errorOnly, setErrorOnly] = useState(false);
+ const [limit, setLimit] = useState(100);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [page, setPage] = useState('');
+ const [hasMore, setHasMore] = useState(true);
+ const [hasScrolled, setHasScrolled] = useState(false);
+ const [fetching, setFetching] = useState(false);
+ const [forceUpdate, setForceUpdate] = useState(false);
+ const [lastReceivedLogs, setLastReceivedLogs] = useState('');
+ const theme = useTheme();
+ const isDark = theme.palette.mode === 'dark';
+ const [scrollToMe, setScrollToMe] = useState(null);
+ const screenMin = useMediaQuery((theme) => theme.breakpoints.up('sm'))
+
+ const bottomRef = useRef(null);
+ const topRef = useRef(null);
+ const terminalRef = useRef(null);
+
+ const scrollToBottom = () => {
+ bottomRef.current.scrollIntoView({ behavior: 'smooth' });
+ };
+
+ const fetchLogs = async (reset, ignoreState) => {
+ setLoading(true);
+ try {
+ const response = await API.docker.getContainerLogs(
+ containerName,
+ searchTerm,
+ limit,
+ ignoreState ? '' : lastReceivedLogs,
+ errorOnly
+ );
+ const { data } = response;
+ if (data.length > 0) {
+ const date = data[0].output.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/)[0];
+ if (date) {
+ date.replace(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.)(\d+)Z/, (match, p1, p2) => {
+ const newNumber = parseInt(p2) - 1;
+ const newDate = `${p1}${newNumber}Z`;
+ setLastReceivedLogs(newDate);
+ });
+ } else {
+ console.error('Could not parse date from log: ', data[0]);
+ setLastReceivedLogs('');
+ }
+ }
+ if(reset) {
+ setLogs(data);
+ } else {
+ // const current = topRef.current;
+ // setScrollToMe(() => current);
+ setLogs((logs) => [...data, ...logs]);
+ // calculate the height of the new logs and scroll to that position
+ // OK I will fix this later
+ // const newHeight = 999999999;
+ // terminalRef.current.scrollTop = newHeight;
+ }
+ setHasMore(true);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ setFetching(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchLogs(true);
+ }, [searchTerm, errorOnly, limit]);
+
+ useEffect(() => {
+ if (!fetching) return;
+ fetchLogs();
+ }, [fetching]);
+
+ useEffect(() => {
+ if (!hasScrolled) {
+ scrollToBottom();
+ } else {
+ // scrollToMe && scrollToMe.scrollIntoView({ });
+ // setScrollToMe(null);
+ }
+ }, [logs]);
+
+ const handleScroll = (event) => {
+ const { scrollTop } = event.target;
+ setHasScrolled(true);
+ if (scrollTop === 0) {
+ if(!hasMore) return;
+ setFetching(true);
+ setHasMore(false);
+ } else {
+ setHasMore(true);
+ }
+ };
+
+ return (
+
+
+
+
+ {
+ setHasScrolled(false);
+ setSearchTerm(e.target.value);
+ setLastReceivedLogs('');
+ }}
+ />
+
+ {
+ setHasScrolled(false);
+ setErrorOnly(e.target.checked);
+ setLastReceivedLogs('');
+ }}
+ />
+ Error Only
+
+
+
+
+ {
+ setHasScrolled(false);
+ setLimit(e.target.value);
+ setLastReceivedLogs('');
+ }}
+ />
+
+
+
+
+ {loading && }
+
+
+ {logs.map((log, index) => (
+
+
+
+ ))}
+ {fetching && }
+
+
+
+ );
+};
+
+export default Logs;
\ No newline at end of file
diff --git a/client/src/pages/servapps/containers/overview.jsx b/client/src/pages/servapps/containers/overview.jsx
new file mode 100644
index 0000000..70650be
--- /dev/null
+++ b/client/src/pages/servapps/containers/overview.jsx
@@ -0,0 +1,153 @@
+import React from 'react';
+import { Checkbox, Chip, CircularProgress, Stack, Typography, useMediaQuery } from '@mui/material';
+import MainCard from '../../../components/MainCard';
+import { ContainerOutlined, DesktopOutlined, InfoCircleOutlined, NodeExpandOutlined, PlayCircleOutlined, PlusCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
+import { getFaviconURL, getContainersRoutes } from '../../../utils/routes';
+import HostChip from '../../../components/hostChip';
+import ExposeModal from '../exposeModal';
+import * as API from '../../../api';
+import RestartModal from '../../config/users/restart';
+import GetActions from '../actionBar';
+
+const info = {
+ backgroundColor: 'rgba(0, 0, 0, 0.1)',
+ padding: '10px',
+ borderRadius: '5px',
+}
+
+const ContainerOverview = ({ containerInfo, config, refresh }) => {
+ const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'));
+ const [openModal, setOpenModal] = React.useState(false);
+ const [openRestartModal, setOpenRestartModal] = React.useState(false);
+ const [isUpdating, setIsUpdating] = React.useState(false);
+
+ const { Name, Config, NetworkSettings, State } = containerInfo;
+ const Image = Config.Image;
+ const IPAddress = NetworkSettings.Networks?.[Object.keys(NetworkSettings.Networks)[0]]?.IPAddress;
+ const Health = State.Health;
+ const healthStatus = Health ? Health.Status : 'Healthy';
+ const healthIconColor = healthStatus === 'Healthy' ? 'green' : 'red';
+ const routes = getContainersRoutes(config, Name.replace('/', ''));
+
+ let refreshAll = refresh && (() => refresh().then(() => {
+ setIsUpdating(false);
+ }));
+
+ const updateRoutes = (newRoute) => {
+ API.config.addRoute(newRoute).then(() => {
+ refreshAll();
+ });
+ }
+
+ const addNewRoute = async () => {
+ const apps = (await API.docker.list()).data;
+ const app = apps.find((a) => a.Names[0] === Name);
+ setOpenModal(app);
+ }
+
+ return (
+
+
+ {
+ updateRoutes(_newRoute);
+ setOpenModal(false);
+ setOpenRestartModal(true);
+ }
+ }
+ />
+ {Name}
}>
+
+
+
+
![]({getFaviconURL(routes)
+ {isUpdating ? (
+
+ ) : null}
+
+
+ {({
+ "created": ,
+ "restarting": ,
+ "running": ,
+ "removing": ,
+ "paused": ,
+ "exited": ,
+ "dead": ,
+ })[State.Status]}
+
+
+
+
+
+ {
+ refreshAll()
+ }}
+ setIsUpdatingId={() => {
+ setIsUpdating(true);
+ }}
+ />
+
+ Image
+ {Image}
+ Name
+ {Name}
+ IP Address
+ {IPAddress}
+
+ Health
+
+ {healthStatus}
+ Settings {State.Status !== 'running' ? '(Start container to edit)' : ''}
+
+ {
+ setIsUpdating(true);
+ API.docker.secure(Name, e.target.checked).then(() => {
+ setTimeout(() => {
+ refreshAll();
+ }, 3000);
+ })
+ }}
+ /> Force Secure Network
+
+ URLs
+
+ {routes.map((route) => {
+ return
+ })}
+
+ }
+ onClick={() => {
+ addNewRoute();
+ }}
+ onDelete={() => {
+ addNewRoute();
+ }}
+ />
+
+
+
+
+
+ );
+};
+
+export default ContainerOverview;
\ No newline at end of file
diff --git a/client/src/pages/servapps/exposeModal.jsx b/client/src/pages/servapps/exposeModal.jsx
new file mode 100644
index 0000000..1bf6c7f
--- /dev/null
+++ b/client/src/pages/servapps/exposeModal.jsx
@@ -0,0 +1,98 @@
+import React, { useState } from 'react';
+import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Stack } from '@mui/material';
+import { Alert } from '@mui/material';
+import RouteManagement from '../config/routes/routeman';
+import { ValidateRoute, getFaviconURL, sanitizeRoute, getContainersRoutes } from '../../utils/routes';
+import * as API from '../../api';
+
+const getHostnameFromName = (name) => {
+ return name.replace('/', '').replace(/_/g, '-').replace(/[^a-zA-Z0-9-]/g, '').toLowerCase().replace(/\s/g, '-') + '.' + window.location.origin.split('://')[1]
+}
+
+const ExposeModal = ({ openModal, setOpenModal, config, updateRoutes, container }) => {
+ const [submitErrors, setSubmitErrors] = useState([]);
+ const [newRoute, setNewRoute] = useState(null);
+
+ let containerName = openModal && (openModal.Names[0]);
+
+ const hasCosmosNetwork = () => {
+ return container && container.NetworkSettings.Networks && Object.keys(container.NetworkSettings.Networks).some((network) => {
+ if(network.startsWith('cosmos-network'))
+ return true;
+ })
+ }
+
+ return
+};
+
+export default ExposeModal;
\ No newline at end of file
diff --git a/client/src/pages/servapps/index.jsx b/client/src/pages/servapps/index.jsx
new file mode 100644
index 0000000..883ac81
--- /dev/null
+++ b/client/src/pages/servapps/index.jsx
@@ -0,0 +1,41 @@
+import * as React from 'react';
+import MainCard from '../../components/MainCard';
+import RestartModal from '../config/users/restart';
+import { Chip, Divider, Stack, useMediaQuery } from '@mui/material';
+import HostChip from '../../components/hostChip';
+import { RouteMode, RouteSecurity } from '../../components/routeComponents';
+import { getFaviconURL } from '../../utils/routes';
+import * as API from '../../api';
+import { CheckOutlined, ClockCircleOutlined, DashboardOutlined, DeleteOutlined, DownOutlined, LockOutlined, UpOutlined } from "@ant-design/icons";
+import IsLoggedIn from '../../isLoggedIn';
+import PrettyTabbedView from '../../components/tabbedView/tabbedView';
+import ServeApps from './servapps';
+import VolumeManagementList from './volumes';
+import NetworkManagementList from './networks';
+
+const ServappsIndex = () => {
+ return
+
+
+
,
+ path: 'containers'
+ },
+ {
+ title: 'Volumes',
+ children:
,
+ path: 'volumes'
+ },
+ {
+ title: 'Networks',
+ children:
,
+ path: 'networks'
+ },
+ ]}/>
+
+
;
+}
+
+export default ServappsIndex;
\ No newline at end of file
diff --git a/client/src/pages/servapps/networks.jsx b/client/src/pages/servapps/networks.jsx
new file mode 100644
index 0000000..2a59257
--- /dev/null
+++ b/client/src/pages/servapps/networks.jsx
@@ -0,0 +1,118 @@
+// material-ui
+import { CloseSquareOutlined, DeleteOutlined, PlusCircleOutlined, SyncOutlined } from '@ant-design/icons';
+import { Button, Chip, CircularProgress, Stack, useTheme } from '@mui/material';
+import { useEffect, useState } from 'react';
+
+import * as API from '../../api';
+import PrettyTableView from '../../components/tableView/prettyTableView';
+
+const NetworkManagementList = () => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [rows, setRows] = useState(null);
+ const [tryDelete, setTryDelete] = useState(null);
+ const theme = useTheme();
+ const isDark = theme.palette.mode === 'dark';
+
+ function refresh() {
+ setIsLoading(true);
+ API.docker.networkList()
+ .then(data => {
+ setRows(data.data);
+ setIsLoading(false);
+ });
+ }
+
+ useEffect(() => {
+ refresh();
+ }, [])
+
+ return (
+ <>
+
+ } onClick={refresh}>
+ Refresh
+
+
+
+ {isLoading && (
+
+
+
+
+
+ )}
+
+ {!isLoading && rows && (
+ { }}
+ getKey={(r) => r.Id}
+ columns={[
+ {
+ title: 'Network Name',
+ field: (r) =>
+ {r.Name}
+ {r.Driver} driver
+ ,
+ search: (r) => r.Name,
+ },
+ {
+ title: 'Properties',
+ screenMin: 'md',
+ field: (r) => (
+
+
+ {r.Internal && }
+ {r.Attachable && }
+ {r.Ingress && }
+
+ ),
+ },
+ {
+ title: 'IPAM gateway / mask',
+ screenMin: 'lg',
+ field: (r) => r.IPAM.Config.map((config, index) => (
+
+ {config.Gateway} / {config.Subnet}
+
+ )),
+ },
+ {
+ title: 'Created At',
+ screenMin: 'lg',
+ field: (r) => new Date(r.Created).toLocaleString(),
+ },
+ {
+ title: '',
+ clickable: true,
+ field: (r) => (
+ <>
+ }
+ onClick={() => {
+ if (tryDelete === r.Id) {
+ setIsLoading(true);
+ API.docker.networkDelete(r.Id).then(() => {
+ refresh();
+ setIsLoading(false);
+ });
+ } else {
+ setTryDelete(r.Id);
+ }
+ }}
+ >
+ {tryDelete === r.Id ? "Really?" : "Delete"}
+
+ >
+ ),
+ },
+ ]}
+ />
+ )}
+ >
+ );
+}
+
+export default NetworkManagementList;
\ No newline at end of file
diff --git a/client/src/pages/servapps/servapps.jsx b/client/src/pages/servapps/servapps.jsx
index 57c0d87..32fad95 100644
--- a/client/src/pages/servapps/servapps.jsx
+++ b/client/src/pages/servapps/servapps.jsx
@@ -11,8 +11,11 @@ import * as API from '../../api';
import IsLoggedIn from '../../isLoggedIn';
import RestartModal from '../config/users/restart';
import RouteManagement from '../config/routes/routeman';
-import { ValidateRoute, getFaviconURL, sanitizeRoute } from '../../utils/routes';
+import { ValidateRoute, getFaviconURL, sanitizeRoute, getContainersRoutes } from '../../utils/routes';
import HostChip from '../../components/hostChip';
+import { Link } from 'react-router-dom';
+import ExposeModal from './exposeModal';
+import GetActions from './actionBar';
const Item = styled(Paper)(({ theme }) => ({
backgroundColor: theme.palette.mode === 'dark' ? '#1A2027' : '#fff',
@@ -39,16 +42,6 @@ const ServeApps = () => {
const [submitErrors, setSubmitErrors] = useState([]);
const [openRestartModal, setOpenRestartModal] = useState(false);
- const hasCosmosNetwork = (containerName) => {
- const container = serveApps.find((app) => {
- return app.Names[0].replace('/', '') === containerName.replace('/', '');
- });
- return container && container.NetworkSettings.Networks && Object.keys(container.NetworkSettings.Networks).some((network) => {
- if(network.startsWith('cosmos-network'))
- return true;
- })
- }
-
const refreshServeApps = () => {
API.docker.list().then((res) => {
setServeApps(res.data);
@@ -66,22 +59,11 @@ const ServeApps = () => {
});
}
- const getContainersRoutes = (containerName) => {
- return (config && config.HTTPConfig && config.HTTPConfig.ProxyConfig.Routes.filter((route) => {
- let reg = new RegExp(`^(([a-z]+):\/\/)?${containerName}(:?[0-9]+)?$`, 'i');
- return route.Mode == "SERVAPP" && reg.test(route.Target)
- // (
- // route.Target.startsWith(containerName) ||
- // route.Target.split('://')[1].startsWith(containerName)
- // )
- })) || [];
- }
-
useEffect(() => {
refreshServeApps();
}, []);
- function updateRoutes() {
+ function updateRoutes(newRoute) {
let con = {
...config,
HTTPConfig: {
@@ -112,12 +94,15 @@ const ServeApps = () => {
},
};
- const getHostnameFromName = (name) => {
- return name.replace('/', '').replace(/_/g, '-').replace(/[^a-zA-Z0-9-]/g, '').toLowerCase().replace(/\s/g, '-') + '.' + window.location.origin.split('://')[1]
+ const selectable = {
+ cursor: 'pointer',
+ "&:hover": {
+ textDecoration: 'underline',
+ }
}
const getFirstRouteFavIcon = (app) => {
- let routes = getContainersRoutes(app.Names[0].replace('/', ''));
+ let routes = getContainersRoutes(config, app.Names[0].replace('/', ''));
if(routes.length > 0) {
let url = getFaviconURL(routes[0]);
return url;
@@ -126,162 +111,21 @@ const ServeApps = () => {
}
}
- const getActions = (app) => {
- const doTo = (action) => {
- setIsUpdatingId(app.Id, true);
- API.docker.manageContainer(app.Id, action).then((res) => {
- refreshServeApps();
- });
- };
-
- let actions = [
- {
- t: 'Update Available',
- if: ['update_available'],
- e: {doTo('update')}} size='large'>
-
-
- },
- {
- t: 'Start',
- if: ['exited'],
- e: {doTo('start')}} size='large'>
-
-
- },
- {
- t: 'Unpause',
- if: ['paused'],
- e: {doTo('unpause')}} size='large'>
-
-
- },
- {
- t: 'Pause',
- if: ['running'],
- e: {doTo('pause')}} size='large'>
-
-
- },
- {
- t: 'Stop',
- if: ['created', 'paused', 'restarting', 'running'],
- e: {doTo('stop')}} size='large' variant="outlined">
-
-
- },
- {
- t: 'Restart',
- if: ['exited', 'running', 'paused', 'created', 'restarting'],
- e: doTo('restart')} size='large'>
-
-
- },
- {
- t: 'Re-create',
- if: ['exited', 'running', 'paused', 'created', 'restarting'],
- e: doTo('recreate')} color="error" size='large'>
-
-
- },
- {
- t: 'Delete',
- if: ['exited'],
- e: {doTo('remove')}} color="error" size='large'>
-
-
- },
- {
- t: 'Kill',
- if: ['running', 'paused', 'created', 'restarting'],
- e: doTo('kill')} color="error" size='large'>
-
-
- }
- ];
-
- return actions.filter((action) => {
- let updateAvailable = false;
- return action.if.includes(app.State) ?? (updateAvailable && action.if.includes('update_available'));
- }).map((action) => {
- return {action.e}
- });
- }
-
return
-
-
+
{
+ return app.Names[0].replace('/', '') === openModal && openModal.Names[0].replace('/', '');
+ })}
+ config={config}
+ updateRoutes={
+ (_newRoute) => {
+ updateRoutes(_newRoute);
+ }
+ }
+ />
@@ -306,7 +150,7 @@ const ServeApps = () => {
-
+
{serveApps && serveApps.filter(app => search.length < 2 || app.Names[0].toLowerCase().includes(search.toLowerCase())).map((app) => {
return
-
@@ -342,8 +186,7 @@ const ServeApps = () => {
{/* */}
-
- {getActions(app)}
+
@@ -369,35 +212,39 @@ const ServeApps = () => {
{isUpdating[app.Id] ?
-
-
:
-
-
- Settings
-
-
- {
- setIsUpdatingId(app.Id, true);
- API.docker.secure(app.Id, e.target.checked).then(() => {
- setTimeout(() => {
- setIsUpdatingId(app.Id, false);
- refreshServeApps();
- }, 3000);
- })
- }}
- /> Force Secure Network
- }
+
+
+ :
+
+
+ Settings {app.State !== 'running' ? '(Start container to edit)' : ''}
+
+
+ {
+ setIsUpdatingId(app.Id, true);
+ API.docker.secure(app.Id, e.target.checked).then(() => {
+ setTimeout(() => {
+ setIsUpdatingId(app.Id, false);
+ refreshServeApps();
+ }, 3000);
+ })
+ }}
+ /> Force Secure Network
+
+
+ }
URLs
- {getContainersRoutes(app.Names[0].replace('/', '')).map((route) => {
+ {getContainersRoutes(config, app.Names[0].replace('/', '')).map((route) => {
return
})}
- {/* {getContainersRoutes(app.Names[0].replace('/', '')).length == 0 && */}
+ {/* {getContainersRoutes(config, app.Names[0].replace('/', '')).length == 0 && */}
{
{/* } */}
+
+
+
+
+
{/*
+
+
+ {isLoading && (
+
+
+
+
+
+ )}
+
+ {!isLoading && rows && (
+ {}}
+ getKey={(r) => r.Name}
+ columns={[
+ {
+ title: 'Volume Name',
+ field: (r) =>
+ {r.Name}
+ {r.Mountpoint}
+ ,
+ search: (r) => r.Name,
+ },
+ {
+ title: 'Driver',
+ screenMin: 'lg',
+ field: (r) => r.Driver,
+ },
+ {
+ title: 'Scope',
+ screenMin: 'lg',
+ field: (r) => r.Scope,
+ },
+ {
+ title: 'Created At',
+ screenMin: 'lg',
+ field: (r) => new Date(r.CreatedAt).toLocaleString(),
+ },
+ {
+ title: '',
+ clickable: true,
+ field: (r) => (
+ <>
+ }
+ onClick={() => {
+ if(tryDelete === r.Name) {
+ setIsLoading(true);
+ API.docker.volumeDelete(r.Name).then(() => {
+ refresh();
+ setIsLoading(false);
+ });
+ } else {
+ setTryDelete(r.Name);
+ }
+ }}
+ >
+ {tryDelete === r.Name ? "Really?" : "Delete"}
+
+ >
+ ),
+ },
+ ]}
+ />
+ )}
+ >
+ );
+};
+
+export default VolumeManagementList;
\ No newline at end of file
diff --git a/client/src/routes/MainRoutes.jsx b/client/src/routes/MainRoutes.jsx
index 0850ffb..8fe4543 100644
--- a/client/src/routes/MainRoutes.jsx
+++ b/client/src/routes/MainRoutes.jsx
@@ -6,11 +6,12 @@ import MainLayout from '../layout/MainLayout';
import UserManagement from '../pages/config/users/usermanagement';
import ConfigManagement from '../pages/config/users/configman';
import ProxyManagement from '../pages/config/users/proxyman';
-import ServeApps from '../pages/servapps/servapps';
+import ServeAppsIndex from '../pages/servapps/';
import { Navigate } from 'react-router';
import RouteConfigPage from '../pages/config/routeConfigPage';
import logo from '../assets/images/icons/cosmos.png';
import HomePage from '../pages/home';
+import ContainerIndex from '../pages/servapps/containers';
// render - dashboard
@@ -52,7 +53,7 @@ const MainRoutes = {
},
{
path: '/ui/servapps',
- element:
+ element:
},
{
path: '/ui/config-users',
@@ -70,6 +71,10 @@ const MainRoutes = {
path: '/ui/config-url/:routeName',
element: ,
},
+ {
+ path: '/ui/servapps/containers/:containerName',
+ element: ,
+ },
]
};
diff --git a/client/src/utils/routes.jsx b/client/src/utils/routes.jsx
index c6870dd..655d18c 100644
--- a/client/src/utils/routes.jsx
+++ b/client/src/utils/routes.jsx
@@ -52,6 +52,10 @@ export const getFaviconURL = (route) => {
return demoicons[route.Name] || logogray;
}
+ if(!route) {
+ return logogray;
+ }
+
const addRemote = (url) => {
return '/cosmos/api/favicon?q=' + encodeURIComponent(url)
}
@@ -102,4 +106,11 @@ export const ValidateRoute = (routeConfig, config) => {
return ['Route Name already exists. Name must be unique.'];
}
return [];
+}
+
+export const getContainersRoutes = (config, containerName) => {
+ return (config && config.HTTPConfig && config.HTTPConfig.ProxyConfig.Routes.filter((route) => {
+ let reg = new RegExp(`^(([a-z]+):\/\/)?${containerName}(:?[0-9]+)?$`, 'i');
+ return route.Mode == "SERVAPP" && reg.test(route.Target)
+ })) || [];
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index b645d5e..1f554b4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cosmos-server",
- "version": "0.3.0-unstable",
+ "version": "0.4.0-unstable2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "cosmos-server",
- "version": "0.3.0-unstable",
+ "version": "0.4.0-unstable2",
"dependencies": {
"@ant-design/colors": "^6.0.0",
"@ant-design/icons": "^4.7.0",
@@ -41,6 +41,7 @@
"react-router": "^6.4.1",
"react-router-dom": "^6.4.1",
"react-syntax-highlighter": "^15.5.0",
+ "react-terminal": "^1.3.1",
"react-window": "^1.8.7",
"redux": "^4.2.0",
"simplebar": "^5.3.8",
@@ -8116,6 +8117,50 @@
"react": ">= 0.14.0"
}
},
+ "node_modules/react-terminal": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/react-terminal/-/react-terminal-1.3.1.tgz",
+ "integrity": "sha512-lbkrih1be0nlJptZR7uwV6YF8PMuxKJOKhGN+GVuFKp9dY/qpSYF76KMGZZJnWbbxAt5Bkf+aUt4iyy5F8NBdQ==",
+ "dependencies": {
+ "prop-types": "^15.7.2",
+ "react-device-detect": "2.1.2"
+ },
+ "peerDependencies": {
+ "prop-types": "^15.8.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ }
+ },
+ "node_modules/react-terminal/node_modules/react-device-detect": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.1.2.tgz",
+ "integrity": "sha512-N42xttwez3ECgu4KpOL2ICesdfoz8NCBfmc1rH9FRYSjH7NmMyANPSrQ3EvAtJyj/6TzJNhrANSO38iXjCB2Ug==",
+ "dependencies": {
+ "ua-parser-js": "^0.7.30"
+ },
+ "peerDependencies": {
+ "react": ">= 0.14.0 < 18.0.0",
+ "react-dom": ">= 0.14.0 < 18.0.0"
+ }
+ },
+ "node_modules/react-terminal/node_modules/ua-parser-js": {
+ "version": "0.7.35",
+ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.35.tgz",
+ "integrity": "sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ua-parser-js"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/faisalman"
+ }
+ ],
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -15065,6 +15110,30 @@
"refractor": "^3.6.0"
}
},
+ "react-terminal": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/react-terminal/-/react-terminal-1.3.1.tgz",
+ "integrity": "sha512-lbkrih1be0nlJptZR7uwV6YF8PMuxKJOKhGN+GVuFKp9dY/qpSYF76KMGZZJnWbbxAt5Bkf+aUt4iyy5F8NBdQ==",
+ "requires": {
+ "prop-types": "^15.7.2",
+ "react-device-detect": "2.1.2"
+ },
+ "dependencies": {
+ "react-device-detect": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.1.2.tgz",
+ "integrity": "sha512-N42xttwez3ECgu4KpOL2ICesdfoz8NCBfmc1rH9FRYSjH7NmMyANPSrQ3EvAtJyj/6TzJNhrANSO38iXjCB2Ug==",
+ "requires": {
+ "ua-parser-js": "^0.7.30"
+ }
+ },
+ "ua-parser-js": {
+ "version": "0.7.35",
+ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.35.tgz",
+ "integrity": "sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g=="
+ }
+ }
+ },
"react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
diff --git a/package.json b/package.json
index 4e99b4e..cb2d4a4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cosmos-server",
- "version": "0.4.0-unstable2",
+ "version": "0.4.0-unstable3",
"description": "",
"main": "test-server.js",
"bugs": {
@@ -41,6 +41,7 @@
"react-router": "^6.4.1",
"react-router-dom": "^6.4.1",
"react-syntax-highlighter": "^15.5.0",
+ "react-terminal": "^1.3.1",
"react-window": "^1.8.7",
"redux": "^4.2.0",
"simplebar": "^5.3.8",
@@ -53,11 +54,11 @@
"scripts": {
"client": "vite",
"client-build": "vite build --base=/ui/",
- "start": "env CONFIG_FILE=./config_dev.json EZ=UTC build/cosmos",
+ "start": "env COSMOS_HOSTNAME=localhost CONFIG_FILE=./config_dev.json EZ=UTC build/cosmos",
"build": " sh build.sh",
"dev": "npm run build && npm run start",
"dockerdevbuild": "sh build.sh && npm run client-build && docker build --tag cosmos-dev .",
- "dockerdevrun": "docker stop cosmos-dev; docker rm cosmos-dev; docker run -d -p 80:80 -p 443:443 -e DOCKER_HOST=tcp://host.docker.internal:2375 -e COSMOS_HOSTNAME=localhost -e COSMOS_MONGODB=$MONGODB -e COSMOS_LOG_LEVEL=DEBUG --restart=unless-stopped -h cosmos-dev --name cosmos-dev cosmos-dev",
+ "dockerdevrun": "docker stop cosmos-dev; docker rm cosmos-dev; docker run -d -p 80:80 -p 443:443 -e DOCKER_HOST=tcp://host.docker.internal:2375 -e COSMOS_MONGODB=$MONGODB -e COSMOS_LOG_LEVEL=DEBUG --restart=unless-stopped -h cosmos-dev --name cosmos-dev cosmos-dev",
"dockerdev": "npm run dockerdevbuild && npm run dockerdevrun",
"demo": "vite build --base=/ui/ --mode demo",
"devdemo": "vite --mode demo"
diff --git a/src/docker/api_getcontainers.go b/src/docker/api_getcontainers.go
new file mode 100644
index 0000000..b5711b6
--- /dev/null
+++ b/src/docker/api_getcontainers.go
@@ -0,0 +1,46 @@
+package docker
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/azukaar/cosmos-server/src/utils"
+)
+
+func GetContainerRoute(w http.ResponseWriter, req *http.Request) {
+ if utils.AdminOnly(w, req) != nil {
+ return
+ }
+
+ vars := mux.Vars(req)
+ containerId := vars["containerId"]
+
+
+ if req.Method == "GET" {
+ errD := Connect()
+ if errD != nil {
+ utils.Error("GetContainerRoute", errD)
+ utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "LN001")
+ return
+ }
+
+ // get Docker container
+ container, err := DockerClient.ContainerInspect(context.Background(), containerId)
+ if err != nil {
+ utils.Error("GetContainerRoute: Error while getting container", err)
+ utils.HTTPError(w, "Container Get Error: " + err.Error(), http.StatusInternalServerError, "LN002")
+ return
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "OK",
+ "data": container,
+ })
+ } else {
+ utils.Error("GetContainerRoute: Method not allowed " + req.Method, nil)
+ utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+ return
+ }
+}
\ No newline at end of file
diff --git a/src/docker/api_getlogs.go b/src/docker/api_getlogs.go
new file mode 100644
index 0000000..ccfb5a7
--- /dev/null
+++ b/src/docker/api_getlogs.go
@@ -0,0 +1,140 @@
+package docker
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "bufio"
+ "io"
+ "strings"
+ "encoding/binary"
+
+ "github.com/docker/docker/api/types"
+ "github.com/gorilla/mux"
+ "github.com/azukaar/cosmos-server/src/utils"
+)
+
+type LogOutput struct {
+ StreamType byte `json:"streamType"`
+ Size uint32 `json:"size"`
+ Output string `json:"output"`
+}
+
+// parseDockerLogHeader parses the first 8 bytes of a Docker log message
+// and returns the stream type, size, and the rest of the message as output.
+// It also checks if the message contains a log header and extracts the log message from it.
+func parseDockerLogHeader(data []byte) (LogOutput) {
+ var logOutput LogOutput
+ logOutput.StreamType = 1 // assume stdout if header not present
+ logOutput.Size = uint32(len(data))
+ logOutput.Output = string(data)
+
+ if len(data) < 8 {
+ return logOutput
+ }
+
+ // check if the output contains a log header
+ hasHeader := true
+ streamType := data[0]
+ if(!(streamType >= 0 && streamType <= 2)) {
+ hasHeader = false
+ }
+ if(data[1] != 0 || data[2] != 0 || data[3] != 0) {
+ hasHeader = false
+ }
+ if hasHeader {
+ sizeBytes := data[4:8]
+ size := binary.BigEndian.Uint32(sizeBytes)
+
+ output := string(data[8:])
+
+ logOutput.StreamType = streamType
+ logOutput.Size = size
+ logOutput.Output = output
+ }
+
+ return logOutput
+}
+
+func FilterLogs(logReader io.Reader, searchQuery string, limit int) []LogOutput {
+ scanner := bufio.NewScanner(logReader)
+ logLines := make([]LogOutput, 0)
+
+ // Read all logs into a slice
+ for scanner.Scan() {
+ line := scanner.Text()
+
+ if len(searchQuery) > 0 && !strings.Contains(strings.ToUpper(line), strings.ToUpper(searchQuery)) {
+ continue
+ }
+
+ logLines = append(logLines, parseDockerLogHeader(([]byte)(line)))
+ }
+
+ from := utils.Max(len(logLines)-limit, 0)
+ logLines = logLines[from:]
+
+ return logLines
+}
+
+func GetContainerLogsRoute(w http.ResponseWriter, req *http.Request) {
+ if utils.AdminOnly(w, req) != nil {
+ return
+ }
+
+ vars := mux.Vars(req)
+ containerId := vars["containerId"]
+
+ if req.Method == "GET" {
+ errD := Connect()
+ if errD != nil {
+ utils.Error("GetContainerLogsRoute", errD)
+ utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "LN001")
+ return
+ }
+
+ query := req.URL.Query()
+ limit := 100
+ lastReceivedLogs := ""
+
+ if query.Get("limit") != "" {
+ limit, _ = strconv.Atoi(query.Get("limit"))
+ }
+
+ if query.Get("lastReceivedLogs") != "" {
+ lastReceivedLogs = query.Get("lastReceivedLogs")
+ }
+
+ errorOnly := false
+ if query.Get("errorOnly") != "" {
+ errorOnly, _ = strconv.ParseBool(query.Get("errorOnly"))
+ }
+
+ options := types.ContainerLogsOptions{
+ ShowStdout: !errorOnly,
+ ShowStderr: true,
+ Timestamps: true,
+ Until: lastReceivedLogs,
+ }
+
+ logReader, err := DockerClient.ContainerLogs(context.Background(), containerId, options)
+ if err != nil {
+ utils.Error("GetContainerLogsRoute: Error while getting container logs", err)
+ utils.HTTPError(w, "Container Logs Error: "+err.Error(), http.StatusInternalServerError, "LN002")
+ return
+ }
+ defer logReader.Close()
+
+ lines := FilterLogs(logReader, query.Get("search"), limit)
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "OK",
+ "data": lines,
+ })
+ } else {
+ utils.Error("GetContainerLogsRoute: Method not allowed "+req.Method, nil)
+ utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+ return
+ }
+}
\ No newline at end of file
diff --git a/src/docker/api_managecont.go b/src/docker/api_managecont.go
index 35cdd28..d03c0e8 100644
--- a/src/docker/api_managecont.go
+++ b/src/docker/api_managecont.go
@@ -9,7 +9,6 @@ import (
"github.com/azukaar/cosmos-server/src/utils"
"github.com/gorilla/mux"
- // "github.com/docker/docker/client"
contstuff "github.com/docker/docker/api/types/container"
doctype "github.com/docker/docker/api/types"
)
diff --git a/src/docker/api_networks.go b/src/docker/api_networks.go
new file mode 100644
index 0000000..9bad5ac
--- /dev/null
+++ b/src/docker/api_networks.go
@@ -0,0 +1,80 @@
+package docker
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/azukaar/cosmos-server/src/utils"
+ "github.com/docker/docker/api/types"
+)
+
+func ListNetworksRoute(w http.ResponseWriter, req *http.Request) {
+ if utils.AdminOnly(w, req) != nil {
+ return
+ }
+
+ if req.Method == "GET" {
+ errD := Connect()
+ if errD != nil {
+ utils.Error("ListNetworksRoute", errD)
+ utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "LN001")
+ return
+ }
+
+ // List Docker networks
+ networks, err := DockerClient.NetworkList(context.Background(), types.NetworkListOptions{})
+ if err != nil {
+ utils.Error("ListNetworksRoute: Error while getting networks", err)
+ utils.HTTPError(w, "Networks Get Error: " + err.Error(), http.StatusInternalServerError, "LN002")
+ return
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "OK",
+ "data": networks,
+ })
+ } else {
+ utils.Error("ListNetworksRoute: Method not allowed " + req.Method, nil)
+ utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+ return
+ }
+}
+
+
+func DeleteNetworkRoute(w http.ResponseWriter, req *http.Request) {
+ if utils.AdminOnly(w, req) != nil {
+ return
+ }
+
+ if req.Method == "DELETE" {
+ // Get the network ID from URL
+ vars := mux.Vars(req)
+ networkID := vars["networkID"]
+
+ errD := Connect()
+ if errD != nil {
+ utils.Error("DeleteNetworkRoute", errD)
+ utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "DN001")
+ return
+ }
+
+ // Delete the specified Docker network
+ err := DockerClient.NetworkRemove(context.Background(), networkID)
+ if err != nil {
+ utils.Error("DeleteNetworkRoute: Error while deleting network", err)
+ utils.HTTPError(w, "Network Deletion Error: " + err.Error(), http.StatusInternalServerError, "DN002")
+ return
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "OK",
+ "message": "Network deleted successfully",
+ })
+ } else {
+ utils.Error("DeleteNetworkRoute: Method not allowed " + req.Method, nil)
+ utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+ return
+ }
+}
\ No newline at end of file
diff --git a/src/docker/api_secureContainer.go b/src/docker/api_secureContainer.go
index c63ca25..6328204 100644
--- a/src/docker/api_secureContainer.go
+++ b/src/docker/api_secureContainer.go
@@ -35,7 +35,7 @@ func SecureContainerRoute(w http.ResponseWriter, req *http.Request) {
_, errEdit := EditContainer(container.ID, container)
if errEdit != nil {
utils.Error("ContainerSecureEdit", errEdit)
- utils.HTTPError(w, "Internal server error: " + err.Error(), http.StatusInternalServerError, "DS003")
+ utils.HTTPError(w, "Internal server error: " + errEdit.Error(), http.StatusInternalServerError, "DS003")
return
}
diff --git a/src/docker/api_volumes.go b/src/docker/api_volumes.go
new file mode 100644
index 0000000..5a1d6ad
--- /dev/null
+++ b/src/docker/api_volumes.go
@@ -0,0 +1,79 @@
+package docker
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/azukaar/cosmos-server/src/utils"
+ filters "github.com/docker/docker/api/types/filters"
+)
+
+func ListVolumeRoute(w http.ResponseWriter, req *http.Request) {
+ if utils.AdminOnly(w, req) != nil {
+ return
+ }
+
+ if req.Method == "GET" {
+ errD := Connect()
+ if errD != nil {
+ utils.Error("ManageContainer", errD)
+ utils.HTTPError(w, "Internal server error: " + errD.Error(), http.StatusInternalServerError, "LV001")
+ return
+ }
+
+ // List Docker volumes
+ volumes, err := DockerClient.VolumeList(context.Background(), filters.Args{})
+ if err != nil {
+ utils.Error("ListVolumeRoute: Error while getting volumes", err)
+ utils.HTTPError(w, "Volumes Get Error", http.StatusInternalServerError, "LV002")
+ return
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "OK",
+ "data": volumes,
+ })
+ } else {
+ utils.Error("ListVolumeRoute: Method not allowed " + req.Method, nil)
+ utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+ return
+ }
+}
+
+func DeleteVolumeRoute(w http.ResponseWriter, req *http.Request) {
+ if utils.AdminOnly(w, req) != nil {
+ return
+ }
+
+ if req.Method == "DELETE" {
+ // Get the volume name from URL
+ vars := mux.Vars(req)
+ volumeName := vars["volumeName"]
+
+ errD := Connect()
+ if errD != nil {
+ utils.Error("DeleteVolumeRoute", errD)
+ utils.HTTPError(w, "Internal server error: "+errD.Error(), http.StatusInternalServerError, "DV001")
+ return
+ }
+
+ // Delete the specified Docker volume
+ err := DockerClient.VolumeRemove(context.Background(), volumeName, true)
+ if err != nil {
+ utils.Error("DeleteVolumeRoute: Error while deleting volume", err)
+ utils.HTTPError(w, "Volume Deletion Error", http.StatusInternalServerError, "DV002")
+ return
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "OK",
+ "message": "Volume deleted successfully",
+ })
+ } else {
+ utils.Error("DeleteVolumeRoute: Method not allowed " + req.Method, nil)
+ utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+ return
+ }
+}
\ No newline at end of file
diff --git a/src/httpServer.go b/src/httpServer.go
index 0e85493..8315872 100644
--- a/src/httpServer.go
+++ b/src/httpServer.go
@@ -24,7 +24,7 @@ var serverPortHTTP = ""
var serverPortHTTPS = ""
func startHTTPServer(router *mux.Router) {
- utils.Log("Listening to HTTP on :" + serverPortHTTP)
+ utils.Log("Listening to HTTP on : 0.0.0.0:" + serverPortHTTP)
err := http.ListenAndServe("0.0.0.0:" + serverPortHTTP, router)
@@ -35,15 +35,14 @@ func startHTTPServer(router *mux.Router) {
func startHTTPSServer(router *mux.Router, tlsCert string, tlsKey string) {
config := utils.GetMainConfig()
- serverHostname := "0.0.0.0"
cfg := simplecert.Default
cfg.Domains = utils.GetAllHostnames()
cfg.CacheDir = "/config/certificates"
cfg.SSLEmail = config.HTTPConfig.SSLEmail
- cfg.HTTPAddress = serverHostname+":"+serverPortHTTP
- cfg.TLSAddress = serverHostname+":"+serverPortHTTPS
+ cfg.HTTPAddress = "0.0.0.0:"+serverPortHTTP
+ cfg.TLSAddress = "0.0.0.0:"+serverPortHTTPS
if config.HTTPConfig.DNSChallengeProvider != "" {
cfg.DNSProvider = config.HTTPConfig.DNSChallengeProvider
@@ -59,7 +58,7 @@ func startHTTPSServer(router *mux.Router, tlsCert string, tlsKey string) {
certReloader, errSimCert = simplecert.Init(cfg, nil)
if errSimCert != nil {
// Temporary before we have a better way to handle this
- utils.Error("simplecert init failed, HTTPS wont renew", errSimCert)
+ utils.Error("Failed to Init Let's Encrypt. HTTPS wont renew", errSimCert)
startHTTPServer(router)
return
}
@@ -106,7 +105,7 @@ func startHTTPSServer(router *mux.Router, tlsCert string, tlsKey string) {
server := http.Server{
TLSConfig: tlsConf,
- Addr: serverHostname + ":" + serverPortHTTPS,
+ Addr: "0.0.0.0:" + serverPortHTTPS,
ReadTimeout: 0,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 0,
@@ -150,7 +149,6 @@ func StartServer() {
HTTPConfig := config.HTTPConfig
serverPortHTTP = HTTPConfig.HTTPPort
serverPortHTTPS = HTTPConfig.HTTPSPort
- // serverHostname := HTTPConfig.Hostname
var tlsCert = HTTPConfig.TLSCert
var tlsKey= HTTPConfig.TLSKey
@@ -219,14 +217,22 @@ func StartServer() {
srapi.HandleFunc("/api/users/{nickname}", user.UsersIdRoute)
srapi.HandleFunc("/api/users", user.UsersRoute)
+
+ srapi.HandleFunc("/api/volume/{volumeName}", docker.DeleteVolumeRoute)
+ srapi.HandleFunc("/api/volumes", docker.ListVolumeRoute)
+
+ srapi.HandleFunc("/api/network/{networkID}", docker.DeleteNetworkRoute)
+ srapi.HandleFunc("/api/networks", docker.ListNetworksRoute)
srapi.HandleFunc("/api/servapps/{containerId}/manage/{action}", docker.ManageContainerRoute)
srapi.HandleFunc("/api/servapps/{containerId}/secure/{status}", docker.SecureContainerRoute)
+ srapi.HandleFunc("/api/servapps/{containerId}/logs", docker.GetContainerLogsRoute)
+ srapi.HandleFunc("/api/servapps/{containerId}/", docker.GetContainerRoute)
srapi.HandleFunc("/api/servapps", docker.ContainersRoute)
- // if(!config.HTTPConfig.AcceptAllInsecureHostname) {
- // srapi.Use(utils.EnsureHostname(serverHostname))
- // }
+ if(!config.HTTPConfig.AcceptAllInsecureHostname) {
+ srapi.Use(utils.EnsureHostname)
+ }
srapi.Use(tokenMiddleware)
srapi.Use(proxy.SmartShieldMiddleware(
@@ -236,10 +242,10 @@ func StartServer() {
PerUserRequestLimit: 5000,
},
))
- srapi.Use(utils.MiddlewareTimeout(20 * time.Second))
+ srapi.Use(utils.MiddlewareTimeout(30 * time.Second))
srapi.Use(utils.BlockPostWithoutReferer)
srapi.Use(proxy.BotDetectionMiddleware)
- srapi.Use(httprate.Limit(60, 1*time.Minute,
+ srapi.Use(httprate.Limit(120, 1*time.Minute,
httprate.WithKeyFuncs(httprate.KeyByIP),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
utils.Error("Too many requests. Throttling", nil)
@@ -258,9 +264,9 @@ func StartServer() {
fs := spa.SpaHandler(pwd + "/static", "index.html")
- // if(!config.HTTPConfig.AcceptAllInsecureHostname) {
- // fs = utils.EnsureHostname(serverHostname)(fs)
- // }
+ if(!config.HTTPConfig.AcceptAllInsecureHostname) {
+ fs = utils.EnsureHostname(fs)
+ }
router.PathPrefix("/ui").Handler(http.StripPrefix("/ui", fs))
diff --git a/src/status.go b/src/status.go
index cfe5155..be3553f 100644
--- a/src/status.go
+++ b/src/status.go
@@ -45,6 +45,7 @@ func StatusRoute(w http.ResponseWriter, req *http.Request) {
"HTTPSCertificateMode": utils.GetMainConfig().HTTPConfig.HTTPSCertificateMode,
"needsRestart": utils.NeedsRestart,
"newVersionAvailable": utils.NewVersionAvailable,
+ "hostname": utils.GetMainConfig().HTTPConfig.Hostname,
},
})
} else {
diff --git a/src/utils/middleware.go b/src/utils/middleware.go
index dfa0801..ecaa143 100644
--- a/src/utils/middleware.go
+++ b/src/utils/middleware.go
@@ -166,3 +166,37 @@ func BlockPostWithoutReferer(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
+
+func EnsureHostname(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ Debug("Request requested resource from : " + r.Host)
+
+ og := GetMainConfig().HTTPConfig.Hostname
+ ni := GetMainConfig().NewInstall
+
+ if ni || og == "0.0.0.0" {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ port := ""
+ if (IsHTTPS && MainConfig.HTTPConfig.HTTPSPort != "443") {
+ port = ":" + MainConfig.HTTPConfig.HTTPSPort
+ } else if (!IsHTTPS && MainConfig.HTTPConfig.HTTPPort != "80") {
+ port = ":" + MainConfig.HTTPConfig.HTTPPort
+ }
+
+ hostnames := GetAllHostnames()
+
+ for _, hostname := range hostnames {
+ if r.Host != hostname + port {
+ Error("Invalid Hostname " + r.Host + "for request. Expecting " + hostname, nil)
+ w.WriteHeader(http.StatusBadRequest)
+ http.Error(w, "Bad Request: Invalid hostname.", http.StatusBadRequest)
+ return
+ }
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
\ No newline at end of file
diff --git a/src/utils/utils.go b/src/utils/utils.go
index f57e20d..fe6d0e4 100644
--- a/src/utils/utils.go
+++ b/src/utils/utils.go
@@ -68,7 +68,7 @@ var DefaultConfig = Config{
GenerateMissingAuthCert: true,
HTTPPort: "80",
HTTPSPort: "443",
- Hostname: "localhost",
+ Hostname: "0.0.0.0",
ProxyConfig: ProxyConfig{
Routes: []ProxyRouteConfig{},
},
@@ -230,26 +230,6 @@ func GetConfigFileName() string {
return configFile
}
-func EnsureHostname(hostname string) func(http.Handler) http.Handler {
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- Debug("Request requested resource from : " + r.Host)
- port := ""
- if (IsHTTPS && MainConfig.HTTPConfig.HTTPSPort != "443") {
- port = ":" + MainConfig.HTTPConfig.HTTPSPort
- } else if (!IsHTTPS && MainConfig.HTTPConfig.HTTPPort != "80") {
- port = ":" + MainConfig.HTTPConfig.HTTPPort
- }
- if r.Host != hostname + port {
- Error("Invalid Hostname " + r.Host + "for request. Expecting " + hostname, nil)
- w.WriteHeader(http.StatusBadRequest)
- fmt.Fprint(w, "Bad Request.")
- return
- }
- next.ServeHTTP(w, r)
- })
- }
-}
func CreateDefaultConfigFileIfNecessary() bool {
configFile := GetConfigFileName()
@@ -420,3 +400,10 @@ func ImageToBase64(path string) (string, error) {
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, encodedData)
return dataURI, nil
}
+
+func Max(x, y int) int {
+ if x < y {
+ return y
+ }
+ return x
+}
\ No newline at end of file